Merge pull request 'Шаблонные строки ББ/ОФР и выделение «БАЛАНС» (#19)' (#20) from feat/19-statement-template-lines into test
Reviewed-on: #20
This commit is contained in:
commit
77a7987f36
2
openspec/changes/statement-template-lines/.openspec.yaml
Normal file
2
openspec/changes/statement-template-lines/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-09
|
||||||
20
openspec/changes/statement-template-lines/design.md
Normal file
20
openspec/changes/statement-template-lines/design.md
Normal file
@ -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>`, а не `code:<code>` | ':' разделяет rowKey и columnId в cellKey | Нет |
|
||||||
18
openspec/changes/statement-template-lines/proposal.md
Normal file
18
openspec/changes/statement-template-lines/proposal.md
Normal file
@ -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` как раньше).
|
||||||
|
- Правка кода и наименования строки.
|
||||||
|
- Выделение итогов ОФР.
|
||||||
@ -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** запрос не отправляется, поле показывает ошибку формата
|
||||||
@ -121,7 +121,7 @@ export interface ColumnOut {
|
|||||||
is_current: boolean | null;
|
is_current: boolean | null;
|
||||||
}
|
}
|
||||||
export interface ValueOut {
|
export interface ValueOut {
|
||||||
id: string;
|
id: string | null; // null — ячейка достроена сервером, в БД её нет
|
||||||
column_id: string;
|
column_id: string;
|
||||||
column_index: number;
|
column_index: number;
|
||||||
amount: string | null;
|
amount: string | null;
|
||||||
@ -132,7 +132,8 @@ export interface ValueOut {
|
|||||||
original_amount: string | null;
|
original_amount: string | null;
|
||||||
}
|
}
|
||||||
export interface LineOut {
|
export interface LineOut {
|
||||||
id: string;
|
id: string | null; // null — строка достроена по справочнику редакции
|
||||||
|
origin: 'source' | 'template';
|
||||||
line_code: string | null;
|
line_code: string | null;
|
||||||
line_name: string | null;
|
line_name: string | null;
|
||||||
values: ValueOut[];
|
values: ValueOut[];
|
||||||
@ -167,9 +168,10 @@ export interface StatementOut {
|
|||||||
lines: LineOut[];
|
lines: LineOut[];
|
||||||
checks: ChecksOut;
|
checks: ChecksOut;
|
||||||
}
|
}
|
||||||
|
export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null };
|
||||||
export interface StatementPatch {
|
export interface StatementPatch {
|
||||||
header?: { inn?: string; report_date?: string; units?: string };
|
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';
|
export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL';
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
}
|
}
|
||||||
const body: StatementPatch = {};
|
const body: StatementPatch = {};
|
||||||
if (Object.keys(values).length > 0) {
|
if (Object.keys(values).length > 0) {
|
||||||
body.values = draftToPatch(values);
|
body.values = draftToPatch(values, grid?.rows ?? []);
|
||||||
}
|
}
|
||||||
const headerPatch: HeaderDraft = {};
|
const headerPatch: HeaderDraft = {};
|
||||||
for (const field of ['inn', 'report_date', 'units'] as const) {
|
for (const field of ['inn', 'report_date', 'units'] as const) {
|
||||||
|
|||||||
@ -34,6 +34,14 @@ function cellTip(value: ValueOut): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_BG = '#f2f4f7';
|
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) {
|
export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) {
|
||||||
const layout = formLayout(documentType, rows);
|
const layout = formLayout(documentType, rows);
|
||||||
@ -46,11 +54,14 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
|
|||||||
title: 'Наименование показателя',
|
title: 'Наименование показателя',
|
||||||
onCell: (r) => ({
|
onCell: (r) => ({
|
||||||
...sectionSpan(r, true),
|
...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) => {
|
render: (_: unknown, r: LayoutRow) => {
|
||||||
if (r.kind === 'section') return r.title;
|
if (r.kind === 'section') return r.title;
|
||||||
const text = <span style={{ fontWeight: r.total ? 600 : undefined }}>{r.displayName || '—'}</span>;
|
const text = <span style={{ fontWeight: lineWeight(r) }}>{r.displayName || '—'}</span>;
|
||||||
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -59,9 +70,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
|
|||||||
title: 'Код',
|
title: 'Код',
|
||||||
width: 60,
|
width: 60,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
onCell: (r) => sectionSpan(r, false),
|
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }),
|
||||||
render: (_: unknown, r: LayoutRow) =>
|
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <span style={{ fontWeight: lineWeight(r) }}>{r.row.code}</span> : null),
|
||||||
r.kind === 'line' ? <span style={{ fontWeight: r.total ? 600 : undefined }}>{r.row.code}</span> : null,
|
|
||||||
},
|
},
|
||||||
...columns.map((col) => ({
|
...columns.map((col) => ({
|
||||||
key: col.id,
|
key: col.id,
|
||||||
@ -71,7 +81,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
|
|||||||
onCell: (r: LayoutRow) =>
|
onCell: (r: LayoutRow) =>
|
||||||
r.kind === 'section'
|
r.kind === 'section'
|
||||||
? sectionSpan(r, false)
|
? 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) => {
|
render: (_: unknown, r: LayoutRow) => {
|
||||||
if (r.kind === 'section') return null;
|
if (r.kind === 'section') return null;
|
||||||
const row = r.row;
|
const row = r.row;
|
||||||
@ -79,7 +89,7 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const key = cellKey(row.lineId, col.id);
|
const key = cellKey(row.key, col.id);
|
||||||
const tip = cellTip(value);
|
const tip = cellTip(value);
|
||||||
if (!editable) {
|
if (!editable) {
|
||||||
return <Tooltip title={tip}>{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}</Tooltip>;
|
return <Tooltip title={tip}>{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}</Tooltip>;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { GridRow } from './statementGrid';
|
|||||||
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
|
const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({
|
||||||
key: code ?? 'x',
|
key: code ?? 'x',
|
||||||
lineId: code ?? 'x',
|
lineId: code ?? 'x',
|
||||||
|
origin: 'source',
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
cells: {},
|
cells: {},
|
||||||
@ -85,3 +86,21 @@ describe('formLayout — ОФР, справочные строки', () => {
|
|||||||
).toEqual(['2410', '2400 (итог)', '2900', '2910']);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import type { GridRow } from './statementGrid';
|
|||||||
|
|
||||||
export type LayoutRow =
|
export type LayoutRow =
|
||||||
| { kind: 'section'; key: string; title: string; level: 1 | 2 }
|
| { 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 };
|
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 };
|
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);
|
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[] {
|
function balanceLayout(rows: GridRow[]): LayoutRow[] {
|
||||||
@ -72,12 +82,12 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] {
|
|||||||
if (lines.length === 0) continue;
|
if (lines.length === 0) continue;
|
||||||
out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 });
|
out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 });
|
||||||
for (const r of lines) {
|
for (const r of lines) {
|
||||||
out.push(lineRow(r, BALANCE_PREFIXES));
|
out.push(lineRow(r, BALANCE_PREFIXES, true));
|
||||||
used.add(r.key);
|
used.add(r.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const r of total) {
|
for (const r of total) {
|
||||||
out.push(lineRow(r, BALANCE_PREFIXES));
|
out.push(lineRow(r, BALANCE_PREFIXES, true));
|
||||||
used.add(r.key);
|
used.add(r.key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -86,7 +96,7 @@ function balanceLayout(rows: GridRow[]): LayoutRow[] {
|
|||||||
const rest = rows.filter((r) => !used.has(r.key));
|
const rest = rows.filter((r) => !used.has(r.key));
|
||||||
if (rest.length > 0) {
|
if (rest.length > 0) {
|
||||||
out.push({ kind: 'section', key: 's-rest', title: 'Прочие строки', level: 2 });
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@ -98,7 +108,7 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
|
|||||||
const hb = Math.floor((codeNum(b) as number) / 100);
|
const hb = Math.floor((codeNum(b) as number) / 100);
|
||||||
return ha - hb || byFormOrder(a, b);
|
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[] {
|
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { StatementOut, ValueOut } from '../../types/api';
|
import type { StatementOut, ValueOut } from '../../types/api';
|
||||||
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
|
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
|
||||||
|
|
||||||
const v = (id: string, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
|
const v = (id: string | null, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
|
||||||
id,
|
id,
|
||||||
column_id,
|
column_id,
|
||||||
column_index,
|
column_index,
|
||||||
@ -20,17 +20,34 @@ const statement = {
|
|||||||
{ id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false },
|
{ 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 },
|
{ 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;
|
} as unknown as StatementOut;
|
||||||
|
|
||||||
describe('buildStatementGrid', () => {
|
describe('buildStatementGrid', () => {
|
||||||
it('колонки по column_index, ячейки по column_id', () => {
|
it('колонки по column_index, ячейки по column_id', () => {
|
||||||
const grid = buildStatementGrid(statement);
|
const grid = buildStatementGrid(statement);
|
||||||
expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']);
|
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.c1?.id).toBe('v1');
|
||||||
expect(grid.rows[0].cells.c2?.id).toBe('v2');
|
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', () => {
|
describe('cellFlags', () => {
|
||||||
@ -40,6 +57,12 @@ describe('cellFlags', () => {
|
|||||||
expect(cellFlags(v('a', 'c', 0)).attention).toBe(false);
|
expect(cellFlags(v('a', 'c', 0)).attention).toBe(false);
|
||||||
expect(cellFlags(v('a', 'c', 0, { confidence: null })).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('исправлено', () => {
|
it('исправлено', () => {
|
||||||
expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true);
|
expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true);
|
||||||
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false });
|
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false });
|
||||||
@ -47,12 +70,19 @@ describe('cellFlags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('draftToPatch', () => {
|
describe('draftToPatch', () => {
|
||||||
it('черновик → values', () => {
|
const rows = buildStatementGrid({
|
||||||
expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([
|
...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: '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', () => {
|
describe('applyDraftInput', () => {
|
||||||
|
|||||||
@ -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 = {
|
export type GridRow = {
|
||||||
key: string;
|
key: string;
|
||||||
lineId: string;
|
lineId: string | null;
|
||||||
code: string | null;
|
code: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
origin: 'source' | 'template';
|
||||||
cells: Record<string, ValueOut | undefined>;
|
cells: Record<string, ValueOut | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */
|
||||||
|
const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`;
|
||||||
|
|
||||||
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
|
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
|
||||||
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
|
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
|
||||||
const rows = statement.lines.map((line) => {
|
const rows = statement.lines.map((line) => {
|
||||||
@ -15,7 +19,14 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu
|
|||||||
for (const value of line.values) {
|
for (const value of line.values) {
|
||||||
cells[value.column_id] = value;
|
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 };
|
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 };
|
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<string, string | null>): NonNullable<StatementPatch['values']> {
|
/** Черновик → тело 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]));
|
||||||
return Object.entries(draft).map(([key, amount]) => {
|
return Object.entries(draft).map(([key, amount]) => {
|
||||||
const [line_id, column_id] = key.split(':');
|
const sep = key.lastIndexOf(':');
|
||||||
return { line_id, column_id, amount };
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user