diff --git a/web/src/types/api.ts b/web/src/types/api.ts
index 91f6943..2a8e074 100644
--- a/web/src/types/api.ts
+++ b/web/src/types/api.ts
@@ -121,7 +121,7 @@ export interface ColumnOut {
is_current: boolean | null;
}
export interface ValueOut {
- id: string;
+ id: string | null; // null — ячейка достроена сервером, в БД её нет
column_id: string;
column_index: number;
amount: string | null;
@@ -132,7 +132,8 @@ export interface ValueOut {
original_amount: string | null;
}
export interface LineOut {
- id: string;
+ id: string | null; // null — строка достроена по справочнику редакции
+ origin: 'source' | 'template';
line_code: string | null;
line_name: string | null;
values: ValueOut[];
@@ -167,9 +168,10 @@ export interface StatementOut {
lines: LineOut[];
checks: ChecksOut;
}
+export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null };
export interface StatementPatch {
header?: { inn?: string; report_date?: string; units?: string };
- values?: { line_id: string; column_id: string; amount: string | null }[];
+ values?: CellPatch[];
}
export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL';
diff --git a/web/src/widgets/DocumentReview/StatementReview.tsx b/web/src/widgets/DocumentReview/StatementReview.tsx
index bbdafec..081509b 100644
--- a/web/src/widgets/DocumentReview/StatementReview.tsx
+++ b/web/src/widgets/DocumentReview/StatementReview.tsx
@@ -57,7 +57,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}
const body: StatementPatch = {};
if (Object.keys(values).length > 0) {
- body.values = draftToPatch(values);
+ body.values = draftToPatch(values, grid?.rows ?? []);
}
const headerPatch: HeaderDraft = {};
for (const field of ['inn', 'report_date', 'units'] as const) {
diff --git a/web/src/widgets/DocumentReview/StatementTable.tsx b/web/src/widgets/DocumentReview/StatementTable.tsx
index 1d40494..f32f0f2 100644
--- a/web/src/widgets/DocumentReview/StatementTable.tsx
+++ b/web/src/widgets/DocumentReview/StatementTable.tsx
@@ -34,6 +34,14 @@ function cellTip(value: ValueOut): string | undefined {
}
const SECTION_BG = '#f2f4f7';
+const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание)
+
+function lineWeight(r: LayoutRow): number | undefined {
+ if (r.kind !== 'line') return undefined;
+ if (r.grand) return 700;
+ return r.total ? 600 : undefined;
+}
+const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined);
export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
const layout = formLayout(documentType, rows);
@@ -46,11 +54,14 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
title: 'Наименование показателя',
onCell: (r) => ({
...sectionSpan(r, true),
- style: r.kind === 'section' ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } : undefined,
+ style:
+ r.kind === 'section'
+ ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
+ : { background: lineBackground(r) },
}),
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title;
- const text = {r.displayName || '—'};
+ const text = {r.displayName || '—'};
return r.fullName ? {text} : text;
},
},
@@ -59,9 +70,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
title: 'Код',
width: 60,
align: 'center' as const,
- onCell: (r) => sectionSpan(r, false),
- render: (_: unknown, r: LayoutRow) =>
- r.kind === 'line' ? {r.row.code} : null,
+ onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }),
+ render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? {r.row.code} : null),
},
...columns.map((col) => ({
key: col.id,
@@ -71,7 +81,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
onCell: (r: LayoutRow) =>
r.kind === 'section'
? sectionSpan(r, false)
- : { style: { background: cellBackground(r.row.cells[col.id]), fontWeight: r.total ? 600 : undefined } },
+ : { style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) } },
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return null;
const row = r.row;
@@ -79,7 +89,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
if (!value) {
return null;
}
- const key = cellKey(row.lineId, col.id);
+ const key = cellKey(row.key, col.id);
const tip = cellTip(value);
if (!editable) {
return {formatAmount(value.amount, { fraction: 'auto', empty: '-' })};
diff --git a/web/src/widgets/DocumentReview/formLayout.test.ts b/web/src/widgets/DocumentReview/formLayout.test.ts
index eb6127c..3b9f745 100644
--- a/web/src/widgets/DocumentReview/formLayout.test.ts
+++ b/web/src/widgets/DocumentReview/formLayout.test.ts
@@ -5,6 +5,7 @@ import type { GridRow } from './statementGrid';
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
key: code ?? 'x',
lineId: code ?? 'x',
+ origin: 'source',
code,
name,
cells: {},
@@ -85,3 +86,21 @@ describe('formLayout — ОФР, справочные строки', () => {
).toEqual(['2410', '2400 (итог)', '2900', '2910']);
});
});
+
+describe('formLayout — строки «БАЛАНС»', () => {
+ it('1600 и 1700 у баланса помечены grand, остальные итоги — нет', () => {
+ const layout = formLayout(
+ 'BALANCE',
+ ['1100', '1150', '1600', '1300', '1310', '1700'].map((c) => row(c)),
+ );
+ const grand = layout.filter((r) => r.kind === 'line' && r.grand).map((r) => (r.kind === 'line' ? r.row.code : ''));
+ expect(grand).toEqual(['1600', '1700']);
+ });
+ it('у ОФР grand нет', () => {
+ const layout = formLayout(
+ 'INCOME',
+ ['2110', '2100', '2400'].map((c) => row(c)),
+ );
+ expect(layout.some((r) => r.kind === 'line' && r.grand)).toBe(false);
+ });
+});
diff --git a/web/src/widgets/DocumentReview/formLayout.ts b/web/src/widgets/DocumentReview/formLayout.ts
index 4415df3..fe277e3 100644
--- a/web/src/widgets/DocumentReview/formLayout.ts
+++ b/web/src/widgets/DocumentReview/formLayout.ts
@@ -7,7 +7,7 @@ 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; displayName: string; fullName: string | null };
+ | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null };
type Section = { title: string; hundred: number };
@@ -50,9 +50,19 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa
return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name };
}
-function lineRow(row: GridRow, prefixes: string[]): LayoutRow {
+// строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026)
+const GRAND_TOTALS = new Set(['1600', '1700']);
+
+function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow {
const code = codeNum(row);
- return { kind: 'line', key: row.key, row, total: code !== null && isTotal(code), ...stripSectionPrefixes(row.name, prefixes) };
+ return {
+ kind: 'line',
+ key: row.key,
+ row,
+ total: code !== null && isTotal(code),
+ grand: balance && GRAND_TOTALS.has(row.code ?? ''),
+ ...stripSectionPrefixes(row.name, prefixes),
+ };
}
function balanceLayout(rows: GridRow[]): LayoutRow[] {
@@ -72,12 +82,12 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] {
if (lines.length === 0) continue;
out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 });
for (const r of lines) {
- out.push(lineRow(r, BALANCE_PREFIXES));
+ out.push(lineRow(r, BALANCE_PREFIXES, true));
used.add(r.key);
}
}
for (const r of total) {
- out.push(lineRow(r, BALANCE_PREFIXES));
+ out.push(lineRow(r, BALANCE_PREFIXES, true));
used.add(r.key);
}
};
@@ -86,7 +96,7 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] {
const rest = rows.filter((r) => !used.has(r.key));
if (rest.length > 0) {
out.push({ kind: 'section', key: 's-rest', title: 'Прочие строки', level: 2 });
- for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES));
+ for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES, true));
}
return out;
}
@@ -98,7 +108,7 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
const hb = Math.floor((codeNum(b) as number) / 100);
return ha - hb || byFormOrder(a, b);
});
- return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, []));
+ return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false));
}
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
diff --git a/web/src/widgets/DocumentReview/statementGrid.test.ts b/web/src/widgets/DocumentReview/statementGrid.test.ts
index a0fb130..6a84abe 100644
--- a/web/src/widgets/DocumentReview/statementGrid.test.ts
+++ b/web/src/widgets/DocumentReview/statementGrid.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
-const v = (id: string, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({
+const v = (id: string | null, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({
id,
column_id,
column_index,
@@ -20,17 +20,34 @@ const statement = {
{ id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false },
{ id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true },
],
- lines: [{ id: 'l1', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }],
+ lines: [{ id: 'l1', origin: 'source', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }],
} as unknown as StatementOut;
describe('buildStatementGrid', () => {
it('колонки по column_index, ячейки по column_id', () => {
const grid = buildStatementGrid(statement);
expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']);
- expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс' });
+ expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс', origin: 'source' });
expect(grid.rows[0].cells.c1?.id).toBe('v1');
expect(grid.rows[0].cells.c2?.id).toBe('v2');
});
+ it('достроенная строка: ключ по коду, lineId = null', () => {
+ const withTemplate = {
+ ...statement,
+ lines: [
+ ...statement.lines,
+ {
+ id: null,
+ origin: 'template',
+ line_code: '1700',
+ line_name: 'БАЛАНС',
+ values: [v(null, 'c1', 0, { amount: null, confidence: null, is_recognized: null })],
+ },
+ ],
+ } as unknown as StatementOut;
+ const row = buildStatementGrid(withTemplate).rows[1];
+ expect(row).toMatchObject({ key: 'code-1700', lineId: null, code: '1700', origin: 'template' });
+ });
});
describe('cellFlags', () => {
@@ -40,6 +57,12 @@ describe('cellFlags', () => {
expect(cellFlags(v('a', 'c', 0)).attention).toBe(false);
expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false);
});
+ it('пустая достроенная ячейка не требует внимания', () => {
+ expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({
+ attention: false,
+ modified: false,
+ });
+ });
it('исправлено', () => {
expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true);
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false });
@@ -47,12 +70,19 @@ describe('cellFlags', () => {
});
describe('draftToPatch', () => {
- it('черновик → values', () => {
- expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([
+ const rows = buildStatementGrid({
+ ...statement,
+ lines: [...statement.lines, { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [] }],
+ } as unknown as StatementOut).rows;
+ it('строка из БД — line_id, достроенная — line_code', () => {
+ expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('code-1700', 'c1')]: null }, rows)).toEqual([
{ line_id: 'l1', column_id: 'c1', amount: '10.5' },
- { line_id: 'l2', column_id: 'c1', amount: null },
+ { line_code: '1700', column_id: 'c1', amount: null },
]);
});
+ it('неизвестная строка — ошибка, а не тихий пропуск', () => {
+ expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow();
+ });
});
describe('applyDraftInput', () => {
diff --git a/web/src/widgets/DocumentReview/statementGrid.ts b/web/src/widgets/DocumentReview/statementGrid.ts
index ac12d30..50c5905 100644
--- a/web/src/widgets/DocumentReview/statementGrid.ts
+++ b/web/src/widgets/DocumentReview/statementGrid.ts
@@ -1,13 +1,17 @@
-import type { ColumnOut, StatementOut, StatementPatch, ValueOut } from '../../types/api';
+import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api';
export type GridRow = {
key: string;
- lineId: string;
+ lineId: string | null;
code: string | null;
name: string | null;
+ origin: 'source' | 'template';
cells: Record;
};
+/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */
+const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`;
+
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
const rows = statement.lines.map((line) => {
@@ -15,7 +19,14 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu
for (const value of line.values) {
cells[value.column_id] = value;
}
- return { key: line.id, lineId: line.id, code: line.line_code, name: line.line_name, cells };
+ return {
+ key: rowKey(line.id, line.line_code),
+ lineId: line.id,
+ code: line.line_code,
+ name: line.line_name,
+ origin: line.origin,
+ cells,
+ };
});
return { columns, rows };
}
@@ -29,12 +40,19 @@ export function cellFlags(value: ValueOut | undefined): { attention: boolean; mo
return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified };
}
-export const cellKey = (lineId: string, columnId: string) => `${lineId}:${columnId}`;
+export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`;
-export function draftToPatch(draft: Record): NonNullable {
+/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */
+export function draftToPatch(draft: Record, rows: GridRow[]): CellPatch[] {
+ const byKey = new Map(rows.map((row) => [row.key, row]));
return Object.entries(draft).map(([key, amount]) => {
- const [line_id, column_id] = key.split(':');
- return { line_id, column_id, amount };
+ const sep = key.lastIndexOf(':');
+ const row = byKey.get(key.slice(0, sep));
+ const column_id = key.slice(sep + 1);
+ if (!row) {
+ throw new Error(`строка черновика ${key} не найдена в форме`);
+ }
+ return row.lineId ? { line_id: row.lineId, column_id, amount } : { line_code: row.code ?? '', column_id, amount };
});
}