Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44

Merged
aep merged 22 commits from feat/43-statements-v2 into test 2026-10-18 19:09:18 +03:00
7 changed files with 57 additions and 26 deletions
Showing only changes of commit 4783593fe6 - Show all commits

View File

@ -1,5 +1,5 @@
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, Switch, Tooltip, Typography, message } from 'antd';
import { Alert, Button, Space, Splitter, Switch, Typography, message } from 'antd';
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiError } from '../../api/client';
@ -18,7 +18,7 @@ import { confirmErrorInfo } from './confirmError';
import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces';
import { applyLineMenu } from './lineMenu';
import { applyLineMenu, showsCodeInput } from './lineMenu';
import { reviewActions } from './reviewView';
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
@ -83,10 +83,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
return result;
}, [draftErrors, lineServerError]);
const serverErrorOffScreen =
lineServerError !== null && !layout.some((item) => item.kind === 'line' && item.row.lineId === lineServerError.lineId);
const places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]);
lineServerError !== null &&
!layout.some(
(item) =>
item.kind === 'line' &&
item.row.lineId === lineServerError.lineId &&
editing &&
showsCodeInput(item.row, draft.lines[lineServerError.lineId]),
);
const places = useMemo(
() => (grid && statement ? issuePlaces(statement.issues ?? [], grid.rows, grid.columns) : null),
[grid, statement],
);
const issueItems = useMemo(
() => (grid && statement && places ? issueListItems(statement.issues, places, grid.rows, grid.columns) : []),
() => (grid && statement && places ? issueListItems(statement.issues ?? [], places, grid.rows, grid.columns) : []),
[grid, statement, places],
);
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
@ -224,21 +234,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
Редактировать
</Button>
<Tooltip
title={
unresolved.length > 0 ? (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
) : undefined
}>
<Button
type='primary'
icon={<CheckOutlined />}
style={confirmEnabled ? confirmButtonStyle : undefined}
disabled={!confirmEnabled}
onClick={confirm}>
Подтвердить данные
</Button>
</Tooltip>
<Button
type='primary'
icon={<CheckOutlined />}
style={confirmEnabled ? confirmButtonStyle : undefined}
disabled={!confirmEnabled}
onClick={confirm}>
Подтвердить данные
</Button>
{unresolved.length > 0 && (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
)}
</Space>
);

View File

@ -47,7 +47,7 @@ function lineWeight(r: LayoutRow): number | undefined {
if (r.grand) return 700;
return r.total ? 600 : undefined;
}
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined;
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && (r.row.lineStatus === 'unreadable' || r.row.lineStatus === 'missing');
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);

View File

@ -29,6 +29,13 @@ const CONFIRM_TITLE = {
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
} as const;
// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке
const CONFIRM_LABEL = {
confirm: 'Подтвердить значение',
'pending-confirm': 'Подтвердить значение',
unconfirm: 'Снять подтверждение',
'pending-unconfirm': 'Снять подтверждение',
} as const;
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({
@ -80,7 +87,7 @@ export function ValueCell({
size='small'
type={pending ? 'primary' : 'default'}
aria-pressed={pending}
aria-label={CONFIRM_TITLE[confirmState]}
aria-label={CONFIRM_LABEL[confirmState]}
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
/>

View File

@ -1,5 +1,5 @@
import { ApiError } from '../../api/client';
import type { EditionLineOut } from '../../types/api';
import type { EditionLineOut, LineErrorDetail } from '../../types/api';
import type { GridRow } from './statementGrid';
export type CodeOption = { code: string; name: string };
@ -40,7 +40,7 @@ const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVAL
/** P6-9: к какой строке относится ошибка элемента `lines[]` ответа на сохранение; иная ошибка — null. */
export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null {
if (!(error instanceof ApiError) || !error.code || !LINE_ERROR_CODES.has(error.code)) return null;
const detail = (error.body as { detail?: { line_id?: unknown; line_code?: unknown } } | undefined)?.detail;
const detail = (error.body as { detail?: Partial<Record<keyof LineErrorDetail, unknown>> } | undefined)?.detail;
if (typeof detail?.line_id !== 'string') return null;
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
}

View File

@ -12,6 +12,12 @@ describe('unresolvedLines', () => {
it('фикстура: строки без кода со значениями', () => {
expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']);
});
it('сортирует по row_index, без индекса — в конец, равные — как пришли', () => {
const withAmount = { ...emptyCell, amount: '1.00' } as ValueOut;
const mk = (lineId: string, rowIndex: number | null) => row({ lineId, rowIndex, lineStatus: 'unreadable', cells: { a: withAmount } });
const shuffled = [mk('n1', null), mk('c', 30), mk('a', 10), mk('b', 20), mk('n2', null), mk('b2', 20)];
expect(unresolvedLines(shuffled).map((r) => r.lineId)).toEqual(['a', 'b', 'b2', 'c', 'n1', 'n2']);
});
it('missing без значений не блокирует', () => {
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
});

View File

@ -1,12 +1,17 @@
import type { GridRow } from './statementGrid';
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */
export function unresolvedLines(rows: GridRow[]): GridRow[] {
return rows.filter(
const found = rows.filter(
(row) =>
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)),
);
// порядок как в бланке: по row_index (без индекса — в конец), при равенстве — как пришло
return found
.map((row, i) => ({ row, i }))
.sort((a, b) => (a.row.rowIndex ?? Number.POSITIVE_INFINITY) - (b.row.rowIndex ?? Number.POSITIVE_INFINITY) || a.i - b.i)
.map(({ row }) => row);
}
export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`;

View File

@ -136,6 +136,13 @@ describe('formLayout — строки детализации (parent_code)', ()
const rows = [row('9990'), sub('99901', '9990')];
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']);
});
it('строка без кода после строки из «Прочих строк» встаёт под ней', () => {
const rows = [
{ ...row('9990'), rowIndex: 5 },
{ ...row(null, 'нераспознанная'), key: 'u1', lineId: 'u1', lineStatus: 'unreadable' as const, rowIndex: 6 },
];
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', 'null']);
});
it('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => {
const rows = [row('2110'), sub('21101', '2110'), row('2120'), sub('21201', '2120'), row('2100'), sub('29901', '2990')];
expect(shape(formLayout('INCOME', rows))).toEqual(['2110', '21101', '2120', '21201', '2100 (итог)', '29901']);