diff --git a/openspec/changes/statement-template-lines/.openspec.yaml b/openspec/changes/statement-template-lines/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/statement-template-lines/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/statement-template-lines/design.md b/openspec/changes/statement-template-lines/design.md new file mode 100644 index 0000000..337879e --- /dev/null +++ b/openspec/changes/statement-template-lines/design.md @@ -0,0 +1,20 @@ +## Context + +Решения D-6 и R-7 взяты из design.md arm-core (change `statement-template-lines`, RSHB/arm-core#80). + +### D-6. Фронт (arm-web, PR в `test`) +`statementGrid`: ключ строки — `line.id ?? "code-" + line_code` (R-10), ключ ячейки — `rowKey:column_id`; `draftToPatch` +отправляет `line_id` или `line_code` по ключу. Достроенная пустая ячейка (`id = null`) без подсветки «требует проверки» +(`is_recognized = null`), введённое значение подсвечивается как правка. `formLayout` уже раскладывает строки по разделам — +меняется только то, что разделы теперь всегда полные. **Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются +цветом** (просьба владельца 09.10.2026): фон всей строки — светло-фиолетовый (`#f9f0ff`, purple-1 палитры antd): серый +`#f2f4f7` уже у заголовков разделов, светло-синий `#e6f4ff` — «исправлено», оранжевый `#fff7e6` — «требует проверки»; +шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — сигнал о +данных важнее оформления. Признак — `formLayout` (`LayoutRow.grand: true` для 1600/1700 у `BALANCE`), цвет — `StatementTable`. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Что стоит ошибка | +|---|---|---|---|---| +| R-7 | 09.10.2026 | Цвет строк «БАЛАНС» — светло-фиолетовый `#f9f0ff` + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется | Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; фиолетовый не занят: серый — разделы, синий — «исправлено», оранжевый — «требует проверки» | Цвет не понравится — правка одной константы | +| R-10 | 09.10.2026 | Ключ достроенной строки `code-`, а не `code:` | ':' разделяет rowKey и columnId в cellKey | Нет | diff --git a/openspec/changes/statement-template-lines/proposal.md b/openspec/changes/statement-template-lines/proposal.md new file mode 100644 index 0000000..9917ee9 --- /dev/null +++ b/openspec/changes/statement-template-lines/proposal.md @@ -0,0 +1,18 @@ +## Why + +RSHB/arm-core#80: сервер достраивает форму ББ/ОФР по справочнику редакции — строки и ячейки, которых не дало +распознавание, приходят с `id = null` (`origin = template`). Аналитик должен уметь заполнить их вручную. Кроме того, +итоговые строки «БАЛАНС» (1600 по активу, 1700 по пассиву) сливаются с обычными строками и «Итого по разделу». + +## What Changes + +- Строки и ячейки с `id = null` отображаются и редактируются как обычные. +- Правка достроенной строки уходит в `PATCH …/statement` по `line_code` (строки из БД — по `line_id`). +- Ключ строки на экране: `id`, а у достроенной — `code-<код>`. +- Строки 1600 и 1700 формы `BALANCE` выделяются фоном `#f9f0ff` и жирным шрифтом. + +## Non-goals + +- Порядок строк (раскладку по разделам делает `formLayout` как раньше). +- Правка кода и наименования строки. +- Выделение итогов ОФР. diff --git a/openspec/changes/statement-template-lines/specs/document-review-ui/spec.md b/openspec/changes/statement-template-lines/specs/document-review-ui/spec.md new file mode 100644 index 0000000..6291eaa --- /dev/null +++ b/openspec/changes/statement-template-lines/specs/document-review-ui/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Форма ББ/ОФР — просмотр и правка +Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при +правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая +строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `confidence < 100` или +`is_recognized = false` SHALL подсвечиваться как «требует внимания», с `is_modified = true` — как «исправлено» с +исходным значением `original_amount` в подсказке; пустая достроенная ячейка не подсвечивается. Строки «БАЛАНС» формы +`BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом; подсветка ячейки перекрывает фон строки. +Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не `CONFIRMED`, ячейка SHALL быть редактируемой: +ввод проверяется по формату суммы (до 2 знаков после точки, знак минус допустим, пустое — `null`), сохранение +отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой: ячейка строки из БД — `{line_id, +column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code, column_id, amount}`; ответ заменяет +состояние экрана. Правка шапки (ИНН, дата отчёта, единицы) SHALL отправляться тем же `PATCH` в `header`; +`inn: null` не отправляется. + +#### Scenario: Исправление ячейки +- **WHEN** аналитик меняет значение ячейки и сохраняет +- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят + +#### Scenario: Заполнение строки, которой не было в распознавании +- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет +- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено» + +#### Scenario: Строки «БАЛАНС» +- **WHEN** открыта форма `BALANCE` +- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет + +#### Scenario: Неверный формат суммы +- **WHEN** аналитик вводит «12,3,4» +- **THEN** запрос не отправляется, поле показывает ошибку формата diff --git a/src/types/api.ts b/src/types/api.ts index 2130d4c..91e2c7d 100644 --- a/src/types/api.ts +++ b/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/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index bbdafec..081509b 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/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/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 1d40494..f32f0f2 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/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/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index eb6127c..3b9f745 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/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/src/widgets/DocumentReview/formLayout.ts b/src/widgets/DocumentReview/formLayout.ts index 4415df3..fe277e3 100644 --- a/src/widgets/DocumentReview/formLayout.ts +++ b/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/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index a0fb130..6a84abe 100644 --- a/src/widgets/DocumentReview/statementGrid.test.ts +++ b/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/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index ac12d30..50c5905 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/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 }; }); }