fix(web): синхронизация встроенного фронта с arm-web@test (1237105) (#120)

Перенесён диапазон arm-web 025233f..1237105 (3 коммита, 8 файлов набора синхронизации)
по шагам docs/scripts/sync-web.sh. После переноса web/ и arm-web@test идентичны по набору.
Коммиты arm-web:
- 54f88e9 feat(DocumentReview): подстроки под родителем, пустые строки детализации свёрнуты (#34, AURORA-1527)
- 85e1b86 fix(ChecksPanel): заголовок мягких проверок «Предупреждения проверок (N)» (#34, AURORA-1527)
- a4f5481 fix(DocumentReview): label переключателя пустых строк связан через htmlFor (#36, AURORA-1527)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 19:41:01 +04:00
parent 423929ce64
commit 4ba339914c
8 changed files with 165 additions and 9 deletions

View File

@ -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 {

View File

@ -8,6 +8,8 @@ export const failureText = (f: FailedCheckOut) =>
type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> };
// soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления;
// подтверждение они не блокируют
// длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
const failureList = (list: FailedCheckOut[]) => (
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
@ -45,7 +47,12 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
<Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed)} />
)}
{checks.soft_failed.length > 0 && (
<Alert type='warning' showIcon title='Расхождения в пределах округления' description={failureList(checks.soft_failed)} />
<Alert
type='warning'
showIcon
title={`Предупреждения проверок (${checks.soft_failed.length})`}
description={failureList(checks.soft_failed)}
/>
)}
{checks.cross_form && (
<Alert

View File

@ -1,5 +1,5 @@
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, message } from 'antd';
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
@ -12,7 +12,7 @@ import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch } from './statementGrid';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
const errorText = (e: unknown, fallback: string) => (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<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(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<string>()), [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 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
<label htmlFor='show-empty-sublines' style={{ cursor: 'pointer' }}>
Показать пустые строки детализации ({emptySublines.size})
</label>
</span>
)}
<StatementTable
documentType={statement.document.document_type}
columns={grid.columns}
rows={grid.rows}
rows={shownRows}
editable={editing && actions.canSave}
draft={draft}
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}

View File

@ -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 = <span style={{ fontWeight: lineWeight(r) }}>{r.displayName || '—'}</span>;
const text = (
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
{r.displayName || '—'}
</span>
);
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
},
},

View File

@ -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<typeof formLayout>) =>
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);
});
});

View File

@ -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<LayoutRow, { kind: 'line' }> {
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<string, GridRow[]>();
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;
}

View File

@ -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> = {}): 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<ValueOut>) => 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']);
});
});

View File

@ -6,6 +6,8 @@ export type GridRow = {
code: string | null;
name: string | null;
origin: 'source' | 'template';
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
parentCode: string | null;
cells: Record<string, ValueOut | undefined>;
};
@ -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<string, string>): Set<string> {
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<string, string | null>, rows: GridRow[]): CellPatch[] {
const byKey = new Map(rows.map((row) => [row.key, row]));