diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 222a082..023da04 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -138,6 +138,8 @@ export interface LineOut { origin: 'source' | 'template'; line_code: string | null; line_name: string | null; + // код четырёхзначной строки-родителя у пятизначной подстроки (считает ядро, AURORA-1527); иначе null + parent_code: string | null; values: ValueOut[]; } export interface FailedCheckOut { diff --git a/web/src/widgets/DocumentReview/ChecksPanel.tsx b/web/src/widgets/DocumentReview/ChecksPanel.tsx index 7e0f8f3..082aa38 100644 --- a/web/src/widgets/DocumentReview/ChecksPanel.tsx +++ b/web/src/widgets/DocumentReview/ChecksPanel.tsx @@ -8,6 +8,8 @@ export const failureText = (f: FailedCheckOut) => type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise }; +// soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления; +// подтверждение они не блокируют // длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран const failureList = (list: FailedCheckOut[]) => (
@@ -45,7 +47,12 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { )} {checks.soft_failed.length > 0 && ( - + )} {checks.cross_form && ( (e instanceof ApiError ? e.message : fallback); @@ -25,6 +25,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [saving, setSaving] = useState(false); const [confirmFailures, setConfirmFailures] = useState([]); const [confirmMessage, setConfirmMessage] = useState(null); + const [showEmptySublines, setShowEmptySublines] = useState(false); const load = useCallback(async () => { try { @@ -40,6 +41,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut }, [load]); const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); + // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) + const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set()), [grid, draft]); + const shownRows = useMemo(() => { + if (!grid) return []; + return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key)); + }, [grid, showEmptySublines, emptySublines]); const actions = reviewActions(statement?.document.status ?? 'ERROR', editing); // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR'; @@ -191,10 +198,18 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut } /> )} + {emptySublines.size > 0 && ( + + + + + )} setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} diff --git a/web/src/widgets/DocumentReview/StatementTable.tsx b/web/src/widgets/DocumentReview/StatementTable.tsx index f32f0f2..94400a4 100644 --- a/web/src/widgets/DocumentReview/StatementTable.tsx +++ b/web/src/widgets/DocumentReview/StatementTable.tsx @@ -33,6 +33,7 @@ function cellTip(value: ValueOut): string | undefined { return value.confidence ? `Уверенность: ${value.confidence}%` : undefined; } +const CHILD_INDENT = 20; // отступ названия строки детализации под родителем const SECTION_BG = '#f2f4f7'; const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание) @@ -61,7 +62,11 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o }), render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return r.title; - const text = {r.displayName || '—'}; + const text = ( + + {r.displayName || '—'} + + ); return r.fullName ? {text} : text; }, }, diff --git a/web/src/widgets/DocumentReview/formLayout.test.ts b/web/src/widgets/DocumentReview/formLayout.test.ts index 3b9f745..8fedfce 100644 --- a/web/src/widgets/DocumentReview/formLayout.test.ts +++ b/web/src/widgets/DocumentReview/formLayout.test.ts @@ -8,8 +8,10 @@ const row = (code: string | null, name: string | null = `строка ${code}`): origin: 'source', code, name, + parentCode: null, cells: {}, }); +const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode }); const shape = (rows: ReturnType) => rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`)); @@ -104,3 +106,34 @@ describe('formLayout — строки «БАЛАНС»', () => { expect(layout.some((r) => r.kind === 'line' && r.grand)).toBe(false); }); }); + +describe('formLayout — строки детализации (parent_code)', () => { + it('ББ: подстрока сразу под родителем, в порядке кода, с пометкой child; чужой раздел не задет', () => { + const rows = [row('1110'), sub('11102', '1110'), sub('11101', '1110'), row('1120'), sub('11201', '1120'), row('1100')]; + const layout = formLayout('BALANCE', rows); + expect(shape(layout)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1110', '11101', '11102', '1120', '11201', '1100 (итог)']); + expect(layout.map((r) => r.kind === 'line' && r.child)).toEqual([false, false, false, true, true, false, true, false]); + }); + it('ББ: подстрока без родителя в форме остаётся в «Прочие строки»', () => { + const rows = [row('1150'), sub('11701', '1170')]; + expect(shape(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '# Прочие строки', '11701']); + }); + it('ББ: родитель в «Прочих строках» — подстроки под ним', () => { + const rows = [row('9990'), sub('99901', '9990')]; + expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']); + }); + 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']); + }); + it('родитель, сам имеющий parent_code, и самоссылка не теряют строки', () => { + const rows = [row('1150'), sub('11501', '11502'), sub('11502', '1150'), sub('1160', '1160')]; + const codes = formLayout('BALANCE', rows).flatMap((r) => (r.kind === 'line' ? [r.row.code] : [])); + expect([...codes].sort()).toEqual(['1150', '11501', '11502', '1160']); + }); + it('два ряда с одним кодом родителя: подстроки выводятся один раз', () => { + const rows = [row('1110'), row('1110'), sub('11101', '1110')]; + const codes = formLayout('BALANCE', rows).flatMap((r) => (r.kind === 'line' ? [r.row.code] : [])); + expect(codes.filter((c) => c === '11101')).toHaveLength(1); + }); +}); diff --git a/web/src/widgets/DocumentReview/formLayout.ts b/web/src/widgets/DocumentReview/formLayout.ts index fe277e3..5a2d39f 100644 --- a/web/src/widgets/DocumentReview/formLayout.ts +++ b/web/src/widgets/DocumentReview/formLayout.ts @@ -7,7 +7,17 @@ import type { GridRow } from './statementGrid'; export type LayoutRow = | { kind: 'section'; key: string; title: string; level: 1 | 2 } - | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null }; + | { + kind: 'line'; + key: string; + row: GridRow; + total: boolean; + grand: boolean; + /** строка детализации, выведенная под своим родителем (отступ в названии) */ + child: boolean; + displayName: string; + fullName: string | null; + }; type Section = { title: string; hundred: number }; @@ -53,7 +63,7 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa // строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026) const GRAND_TOTALS = new Set(['1600', '1700']); -function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow { +function lineRow(row: GridRow, prefixes: string[], balance: boolean): Extract { const code = codeNum(row); return { kind: 'line', @@ -61,6 +71,7 @@ function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow row, total: code !== null && isTotal(code), grand: balance && GRAND_TOTALS.has(row.code ?? ''), + child: false, ...stripSectionPrefixes(row.name, prefixes), }; } @@ -111,6 +122,33 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] { return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false)); } +/** + * Строка детализации (parent_code от ядра) выводится сразу под родителем, по коду; родитель — только обычная строка формы + * (parent_code = null) из этого же набора. Без родителя в форме строка остаётся там, где была бы без parent_code. + */ export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { - return type === 'BALANCE' ? balanceLayout(rows) : incomeLayout(rows); + const parents = new Set(rows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string)); + const children = new Map(); + const top: GridRow[] = []; + for (const r of rows) { + if (r.parentCode !== null && parents.has(r.parentCode)) { + children.set(r.parentCode, [...(children.get(r.parentCode) ?? []), r]); + } else { + top.push(r); + } + } + const base = type === 'BALANCE' ? balanceLayout(top) : incomeLayout(top); + if (children.size === 0) return base; + const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : []; + const out: LayoutRow[] = []; + for (const item of base) { + out.push(item); + if (item.kind !== 'line' || !item.row.code) continue; + const kids = children.get(item.row.code); + if (!kids) continue; + children.delete(item.row.code); // два ряда с одним кодом родителя: подстроки выводим один раз + const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || a.key.localeCompare(b.key)); + for (const kid of sorted) out.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true }); + } + return out; } diff --git a/web/src/widgets/DocumentReview/statementGrid.test.ts b/web/src/widgets/DocumentReview/statementGrid.test.ts index 6a84abe..b0a2350 100644 --- a/web/src/widgets/DocumentReview/statementGrid.test.ts +++ b/web/src/widgets/DocumentReview/statementGrid.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { StatementOut, ValueOut } from '../../types/api'; -import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid'; +import type { GridRow } from './statementGrid'; +import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid'; const v = (id: string | null, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ id, @@ -107,3 +108,41 @@ describe('applyDraftInput', () => { expect(applyDraftInput({}, 'k', '12,3,4', '1.00', parse)).toEqual({ k: '12,3,4' }); }); }); + +describe('parent_code и пустые строки детализации', () => { + it('buildStatementGrid переносит parent_code', () => { + const withChild = { + ...statement, + lines: [{ id: 'l2', origin: 'source', line_code: '11101', line_name: 'п', parent_code: '1110', values: [] }, ...statement.lines], + } as unknown as StatementOut; + const grid = buildStatementGrid(withChild); + expect(grid.rows[0].parentCode).toBe('1110'); + expect(grid.rows[1].parentCode).toBeNull(); // поля нет в ответе (старый сервер) — не подстрока + }); + + const cell = (extra: Partial) => v('v', 'c1', 0, { amount: null, ...extra }); + const gridRow = (key: string, parentCode: string | null, cells: GridRow['cells']): GridRow => ({ + key, + lineId: key, + code: key, + name: null, + origin: 'source', + parentCode, + cells, + }); + it('пустая подстрока: все ячейки без суммы и без строки; родитель и нулевая сумма — не пустые', () => { + const rows = [ + gridRow('11101', '1110', { c1: cell({}), c2: cell({}) }), + gridRow('11102', '1110', {}), + gridRow('11103', '1110', { c1: cell({ amount: '0.00' }) }), + gridRow('11104', '1110', { c1: cell({ string_value: 'нет' }) }), + gridRow('11105', '1110', { c1: cell({ amount: '5.00' }) }), + gridRow('1110', null, { c1: cell({}) }), + ]; + expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']); + }); + it('подстрока с правкой в черновике не считается пустой', () => { + const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })]; + expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']); + }); +}); diff --git a/web/src/widgets/DocumentReview/statementGrid.ts b/web/src/widgets/DocumentReview/statementGrid.ts index 50c5905..8819844 100644 --- a/web/src/widgets/DocumentReview/statementGrid.ts +++ b/web/src/widgets/DocumentReview/statementGrid.ts @@ -6,6 +6,8 @@ export type GridRow = { code: string | null; name: string | null; origin: 'source' | 'template'; + /** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */ + parentCode: string | null; cells: Record; }; @@ -25,6 +27,7 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu code: line.line_code, name: line.line_name, origin: line.origin, + parentCode: line.parent_code ?? null, cells, }; }); @@ -42,6 +45,20 @@ export function cellFlags(value: ValueOut | undefined): { attention: boolean; mo export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`; +/** Ячейка со значением: сумма (в т. ч. нулевая) или текст; пусто — только null/пустая строка. */ +const hasValue = (value: ValueOut | undefined) => Boolean(value) && ((value?.amount ?? '') !== '' || (value?.string_value ?? '') !== ''); + +/** + * Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки в черновике. + * Ноль — значение, такая строка не пуста. Только показ: данные и PATCH не затрагиваются. + */ +export function emptySublineKeys(rows: GridRow[], draft: Record): Set { + const edited = new Set(Object.keys(draft).map((key) => key.slice(0, key.lastIndexOf(':')))); + return new Set( + rows.filter((row) => row.parentCode !== null && !edited.has(row.key) && !Object.values(row.cells).some(hasValue)).map((row) => row.key), + ); +} + /** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */ export function draftToPatch(draft: Record, rows: GridRow[]): CellPatch[] { const byKey = new Map(rows.map((row) => [row.key, row]));