refactor(statement): один черновик правок и сборка единственного PATCH (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:31:51 +04:00
parent 8ab116fdba
commit ef4d32592e
10 changed files with 339 additions and 79 deletions

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatMoscowDate } from './dates';
import { formatMoscowDate, isIsoDate } from './dates';
describe('formatMoscowDate', () => {
it('момент времени из API (UTC) — дата по Москве', () => {
@ -11,3 +11,15 @@ describe('formatMoscowDate', () => {
expect(formatMoscowDate('не дата')).toBeNull();
});
});
describe('isIsoDate', () => {
it('строго ГГГГ-ММ-ДД и существующая дата', () => {
expect(isIsoDate('2024-12-31')).toBe(true);
expect(isIsoDate('2024-02-29')).toBe(true);
});
it('иной формат и несуществующая дата — нет', () => {
for (const text of ['2024-2-1', '2024-02-30', '2023-02-29', '31.12.2024', '', ' 2024-12-31', '2024-13-01']) {
expect(isIsoDate(text), text).toBe(false);
}
});
});

View File

@ -6,3 +6,12 @@ export function formatMoscowDate(iso: string | null | undefined): string | null
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : MOSCOW.format(date);
}
/** Строго «ГГГГ-ММ-ДД» и существующая календарная дата (30 февраля — нет). */
export function isIsoDate(text: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
if (!match) return false;
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}

View File

@ -4,8 +4,7 @@ import { SeverityIcon } from './IssuesPanel';
import { type HeaderFieldKey, headerFields } from './headerFields';
import { worstSeverity } from './issuePlaces';
import { formTitle, formatReportDate } from './reviewView';
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
import type { HeaderDraft } from './statementDraft';
type Props = {
header: StatementHeaderOut;
@ -15,7 +14,9 @@ type Props = {
onDraftChange: (field: HeaderFieldKey, text: string) => void;
/** предупреждения по полям шапки (IssuePlaces.byField) */
byField?: Map<string, IssueOut[]>;
/** «Подтвердить дату»; черновик подтверждения подключает T6 */
/** ключи полей шапки с ошибкой черновика (красная рамка) */
errorKeys?: Set<string>;
/** «Подтвердить дату»: кладёт в черновик confirm_report_date */
onConfirmDate?: () => void;
};
@ -39,7 +40,7 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
*/
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, onConfirmDate }: Props) {
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) {
const reportDate = formatReportDate(header.report_date.value);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@ -66,6 +67,7 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
<>
<Input
size='small'
status={errorKeys?.has(r.field) ? 'error' : undefined}
placeholder={PLACEHOLDER[r.field]}
value={draft[r.field] ?? r.raw ?? ''}
onChange={(e) => onDraftChange(r.field, e.target.value)}

View File

@ -3,28 +3,30 @@ import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
import type { DocumentOut, FailedCheckOut, StatementOut } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { IssuesPanel } from './IssuesPanel';
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
import { SourcePreview } from './SourcePreview';
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces';
import { reviewActions } from './reviewView';
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [statement, setStatement] = useState<StatementOut | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const [header, setHeader] = useState<HeaderDraft>({});
const [draft, setDraftState] = useState<StatementDraft>(emptyDraft);
// ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик
const [draftErrors, setDraftErrors] = useState<DraftError[]>([]);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
@ -35,6 +37,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
const highlightTimer = useRef<number | undefined>(undefined);
const setDraft = (update: (prev: StatementDraft) => StatementDraft) => {
setDraftState(update);
setDraftErrors([]);
};
const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]);
const load = useCallback(async () => {
try {
setStatement(await getStatement(document.id));
@ -50,7 +58,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set<string>()), [grid, draft]);
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts) : new Set<string>()), [grid, draft]);
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
const layout = useMemo(
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
@ -82,42 +90,25 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0;
const dirty = !isDraftEmpty(draft);
const save = async () => {
const values: Record<string, string | null> = {};
for (const [key, text] of Object.entries(draft)) {
const parsed = parseAmountInput(text);
if (!parsed.ok) {
message.error('Исправьте суммы с неверным форматом');
return;
}
values[key] = parsed.value;
if (!statement || !grid) return;
const built = buildStatementPatch(draft, { rows: grid.rows, header: statement.header });
if (!built.ok) {
setDraftErrors(built.errors);
message.error(built.errors[0].message);
return;
}
const body: StatementPatch = {};
if (Object.keys(values).length > 0) {
body.values = draftToPatch(values, grid?.rows ?? []);
}
const headerPatch: HeaderDraft = {};
for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) {
const text = header[field]?.trim();
if (text) {
headerPatch[field] = text;
}
}
if (Object.keys(headerPatch).length > 0) {
body.header = headerPatch;
}
if (!body.values && !body.header) {
if (built.body === null) {
cancel();
return;
}
setSaving(true);
try {
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
setStatement(await patchStatement(document.id, body));
setDraft({});
setHeader({});
setStatement(await patchStatement(document.id, built.body));
setDraft(() => emptyDraft());
setEditing(false);
setConfirmFailures([]);
setConfirmMessage(null);
@ -131,8 +122,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
};
const cancel = () => {
setDraft({});
setHeader({});
setDraft(() => emptyDraft());
setEditing(false);
};
@ -214,9 +204,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
header={statement.header}
documentType={statement.document.document_type}
editing={editing}
draft={header}
draft={draft.header}
byField={places.byField}
onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))}
errorKeys={errorKeys}
onDraftChange={(field, text) => setDraft((prev) => ({ ...prev, header: { ...prev.header, [field]: text } }))}
onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))}
/>
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
<IssuesPanel items={issueItems} onShow={focusRow} />
@ -254,8 +246,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
places={places}
highlightKey={highlightKey}
editable={editing && actions.canSave}
draft={draft}
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
draft={draft.amounts}
errorKeys={errorKeys}
onDraftChange={(key, text, original) =>
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
}
/>
</Space>
</ReviewCard>

View File

@ -16,6 +16,8 @@ type Props = {
editable: boolean;
draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
/** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */
errorKeys?: Set<string>;
/** предупреждения и нарушения у мест таблицы */
places: IssuePlaces;
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
@ -49,7 +51,7 @@ function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
);
}
export function StatementTable({ columns, layout, editable, draft, onDraftChange, places, highlightKey = null }: Props) {
export function StatementTable({ columns, layout, editable, draft, onDraftChange, errorKeys, places, highlightKey = null }: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
@ -131,6 +133,7 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
draftKey={draftKey}
draft={draft}
onDraftChange={onDraftChange}
hasError={errorKeys?.has(draftKey)}
issues={places.byCell.get(draftKey)}
/>
);

View File

@ -15,10 +15,12 @@ type Props = {
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
/** записи issues, привязанные к этой ячейке */
issues?: IssueOut[];
/** ошибка черновика, найденная при сохранении */
hasError?: boolean;
};
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) {
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [], hasError = false }: Props) {
const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
@ -36,7 +38,7 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
);
}
const text = draft[draftKey] ?? value.amount ?? '';
const invalid = draftKey in draft && !parseAmountInput(text).ok;
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<span>

View File

@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as StatementOut;
const { rows } = buildStatementGrid(statement);
const ctx = { rows, header: statement.header };
const draftOf = (part: Partial<StatementDraft>): StatementDraft => ({ ...emptyDraft(), ...part });
const build = (part: Partial<StatementDraft>) => buildStatementPatch(draftOf(part), ctx);
const bodyOf = (part: Partial<StatementDraft>) => {
const result = build(part);
if (!result.ok) throw new Error(`ожидали тело, получили ошибки: ${JSON.stringify(result.errors)}`);
return result.body;
};
const errorsOf = (part: Partial<StatementDraft>) => {
const result = build(part);
if (result.ok) throw new Error('ожидали ошибки');
return result.errors;
};
describe('суммы как раньше', () => {
it('строка из БД — line_id, шаблонная — line_code', () => {
expect(bodyOf({ amounts: { [cellKey('l-1210', 'c1')]: '120' } })?.values).toEqual([
{ line_id: 'l-1210', column_id: 'c1', amount: '120' },
]);
expect(bodyOf({ amounts: { [cellKey('code-1250', 'c1')]: '5' } })?.values).toEqual([
{ line_code: '1250', column_id: 'c1', amount: '5' },
]);
});
it('пустой текст — очистка суммы (null)', () => {
expect(bodyOf({ amounts: { [cellKey('l-1210', 'c1')]: '' } })?.values).toEqual([{ line_id: 'l-1210', column_id: 'c1', amount: null }]);
});
it('неверный формат — ошибка amount с ключом ячейки', () => {
const key = cellKey('l-1210', 'c1');
expect(errorsOf({ amounts: { [key]: '12,3,4' } })).toEqual([expect.objectContaining({ kind: 'amount', key })]);
});
it('неизвестная строка — ошибка, а не тихий пропуск', () => {
expect(errorsOf({ amounts: { [cellKey('ghost', 'c1')]: '1' } })[0].kind).toBe('amount');
});
});
describe('подтверждение ячейки', () => {
it('confirms → элемент confirmed', () => {
expect(bodyOf({ confirms: { [cellKey('l-u7', 'c1')]: true } })?.values).toEqual([
{ line_id: 'l-u7', column_id: 'c1', confirmed: true },
]);
});
it('сумма той же ячейки отменяет подтверждение (в элементе ровно одно из amount / confirmed)', () => {
const key = cellKey('l-u7', 'c1');
expect(bodyOf({ amounts: { [key]: '7' }, confirms: { [key]: true } })?.values).toEqual([
{ line_id: 'l-u7', column_id: 'c1', amount: '7' },
]);
});
});
describe('действия строки', () => {
it('код, расшифровка, подтверждение кода', () => {
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]);
expect(bodyOf({ lines: { 'l-m8': { kind: 'detail' } } })?.lines).toEqual([{ line_id: 'l-m8', line_status: 'detail' }]);
expect(bodyOf({ lines: { 'l-1230': { kind: 'confirm', confirmed: true } } })?.lines).toEqual([{ line_id: 'l-1230', confirmed: true }]);
});
it('пустой код и код, равный текущему, — не действие', () => {
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '' } } })).toBeNull();
expect(bodyOf({ lines: { 'l-1230': { kind: 'code', code: '1230' } } })).toBeNull();
});
it('код — 4 или 5 цифр', () => {
for (const code of ['12a4', '123']) {
expect(errorsOf({ lines: { 'l-u7': { kind: 'code', code } } })).toEqual([
{ kind: 'code', key: 'l-u7', message: 'Код — 4 или 5 цифр' },
]);
}
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '11501' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '11501' }]);
});
it('действие строки и сумма той же строки — оба в теле', () => {
const body = bodyOf({ lines: { 'l-m8': { kind: 'detail' } }, amounts: { [cellKey('l-m8', 'c1')]: '8' } });
expect(body?.lines).toEqual([{ line_id: 'l-m8', line_status: 'detail' }]);
expect(body?.values).toEqual([{ line_id: 'l-m8', column_id: 'c1', amount: '8' }]);
});
it('сумма в шаблонную строку 1250 и назначение 1250 другой строке — конфликт', () => {
expect(errorsOf({ amounts: { [cellKey('code-1250', 'c1')]: '5' }, lines: { 'l-u7': { kind: 'code', code: '1250' } } })).toEqual([
{
kind: 'conflict',
key: 'l-u7',
message: 'Код 1250 назначается строке, а в шаблонную строку 1250 введены суммы — сохраните по очереди',
},
]);
});
});
describe('колонки', () => {
it('дата колонки ГГГГ-ММ-ДД', () => {
expect(bodyOf({ columns: { c2: '2024-12-31' } })?.columns).toEqual([{ column_id: 'c2', period_date: '2024-12-31' }]);
});
it('другой формат и несуществующая дата — ошибка date; пусто — не отправляется', () => {
for (const text of ['31.12.2024', '2024-02-30']) {
expect(errorsOf({ columns: { c2: text } })).toEqual([{ kind: 'date', key: 'c2', message: 'Дата в формате ГГГГ-ММ-ДД' }]);
}
expect(bodyOf({ columns: { c2: '' } })).toBeNull();
});
});
describe('шапка', () => {
it('organization_name обрезается', () => {
expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })?.header).toEqual({ organization_name: 'ООО Ромашка' });
});
it('длиннее 1000 символов — ошибка header', () => {
expect(errorsOf({ header: { organization_name: 'я'.repeat(1001) } })[0]).toMatchObject({ kind: 'header', key: 'organization_name' });
expect(bodyOf({ header: { organization_name: 'я'.repeat(1000) } })).not.toBeNull();
});
it('подтверждение даты отчёта отправляет текущее значение', () => {
expect(bodyOf({ header: { confirm_report_date: true } })?.header).toEqual({ report_date: '2025-09-30' });
expect(bodyOf({ header: { confirm_report_date: true, report_date: '2025-06-30' } })?.header).toEqual({ report_date: '2025-06-30' });
});
it('пустое поле не отправляется', () => {
expect(bodyOf({ header: { inn: '' } })).toBeNull();
expect(bodyOf({ header: { inn: ' 7700000001 ', units: '', report_date: '2025-09-30' } })?.header).toEqual({
inn: '7700000001',
report_date: '2025-09-30',
});
});
});
describe('пустой черновик', () => {
it('isDraftEmpty и body null', () => {
expect(isDraftEmpty(emptyDraft())).toBe(true);
expect(buildStatementPatch(emptyDraft(), ctx)).toEqual({ ok: true, body: null });
});
it('любая часть делает черновик непустым', () => {
expect(isDraftEmpty(draftOf({ header: { inn: '1' } }))).toBe(false);
expect(isDraftEmpty(draftOf({ lines: { 'l-m8': { kind: 'detail' } } }))).toBe(false);
});
});

View File

@ -0,0 +1,133 @@
import type { CellPatch, ColumnPatch, LinePatch, StatementHeaderOut, StatementPatch } from '../../types/api';
import { isIsoDate } from '../../utils/dates';
import { parseAmountInput } from '../../utils/money';
import type { HeaderFieldKey } from './headerFields';
import type { GridRow } from './statementGrid';
/** Действие над строкой (одно на строку, новое заменяет прежнее — P6-7). */
export type LineAction = { kind: 'code'; code: string } | { kind: 'detail' } | { kind: 'confirm'; confirmed: boolean };
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>> & { confirm_report_date?: boolean };
/** Все несохранённые правки аналитика; уходят одним PATCH. */
export interface StatementDraft {
/** cellKey → введённый текст суммы */
amounts: Record<string, string>;
/** cellKey → подтверждена ли ячейка */
confirms: Record<string, boolean>;
/** lineId → действие над строкой */
lines: Record<string, LineAction>;
/** columnId → введённая дата колонки */
columns: Record<string, string>;
header: HeaderDraft;
}
export const emptyDraft = (): StatementDraft => ({ amounts: {}, confirms: {}, lines: {}, columns: {}, header: {} });
export function isDraftEmpty(draft: StatementDraft): boolean {
return [draft.amounts, draft.confirms, draft.lines, draft.columns, draft.header].every((part) => Object.keys(part).length === 0);
}
/** `key` — по виду: amount — cellKey, code/conflict — lineId, date — columnId, header — имя поля шапки. */
export type DraftError = { kind: 'amount' | 'code' | 'date' | 'header' | 'conflict'; key: string; message: string };
const CODE_RE = /^\d{4,5}$/;
const ORG_NAME_MAX = 1000;
const splitKey = (key: string) => {
const sep = key.lastIndexOf(':');
return { rowKey: key.slice(0, sep), columnId: key.slice(sep + 1) };
};
/**
* Черновик → тело единственного PATCH. Строка из БД адресуется line_id, достроенная — line_code (сервер создаст её).
* Пустое и «без изменений» не отправляется; нет ни одной части — `body: null`.
*/
export function buildStatementPatch(
draft: StatementDraft,
ctx: { rows: GridRow[]; header: StatementHeaderOut },
): { ok: true; body: StatementPatch | null } | { ok: false; errors: DraftError[] } {
const errors: DraftError[] = [];
const byKey = new Map(ctx.rows.map((row) => [row.key, row]));
// values: суммы, затем подтверждения ячеек без суммы в черновике (P6-6)
const values: CellPatch[] = [];
const templateAmountCodes = new Set<string>();
for (const [key, text] of Object.entries(draft.amounts)) {
const { rowKey, columnId } = splitKey(key);
const row = byKey.get(rowKey);
const parsed = parseAmountInput(text);
if (!row) {
errors.push({ kind: 'amount', key, message: 'Строка не найдена в форме' });
} else if (!parsed.ok) {
errors.push({ kind: 'amount', key, message: 'Исправьте суммы с неверным форматом' });
} else if (row.lineId) {
values.push({ line_id: row.lineId, column_id: columnId, amount: parsed.value });
} else {
values.push({ line_code: row.code ?? '', column_id: columnId, amount: parsed.value });
if (row.code) templateAmountCodes.add(row.code);
}
}
for (const [key, confirmed] of Object.entries(draft.confirms)) {
if (key in draft.amounts) continue;
const { rowKey, columnId } = splitKey(key);
const row = byKey.get(rowKey);
if (row?.lineId) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
}
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)
const lines: LinePatch[] = [];
for (const [lineId, action] of Object.entries(draft.lines)) {
const row = byKey.get(lineId);
if (!row?.lineId) continue;
if (action.kind === 'detail') {
lines.push({ line_id: lineId, line_status: 'detail' });
} else if (action.kind === 'confirm') {
lines.push({ line_id: lineId, confirmed: action.confirmed });
} else {
const code = action.code.trim();
if (code === '' || code === row.code) continue;
if (!CODE_RE.test(code)) {
errors.push({ kind: 'code', key: lineId, message: 'Код — 4 или 5 цифр' });
} else if (templateAmountCodes.has(code)) {
errors.push({
kind: 'conflict',
key: lineId,
message: `Код ${code} назначается строке, а в шаблонную строку ${code} введены суммы — сохраните по очереди`,
});
} else {
lines.push({ line_id: lineId, line_code: code });
}
}
}
const columns: ColumnPatch[] = [];
for (const [columnId, raw] of Object.entries(draft.columns)) {
const text = raw.trim();
if (text === '') continue;
if (isIsoDate(text)) {
columns.push({ column_id: columnId, period_date: text });
} else {
errors.push({ kind: 'date', key: columnId, message: 'Дата в формате ГГГГ-ММ-ДД' });
}
}
const header: NonNullable<StatementPatch['header']> = {};
for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) {
const text = draft.header[field]?.trim();
if (text) header[field] = text;
}
if (draft.header.confirm_report_date && !header.report_date && ctx.header.report_date.value) {
header.report_date = ctx.header.report_date.value;
}
if ((header.organization_name?.length ?? 0) > ORG_NAME_MAX) {
errors.push({ kind: 'header', key: 'organization_name', message: `Наименование организации — не длиннее ${ORG_NAME_MAX} символов` });
}
if (errors.length > 0) return { ok: false, errors };
const body: StatementPatch = {};
if (values.length > 0) body.values = values;
if (lines.length > 0) body.lines = lines;
if (columns.length > 0) body.columns = columns;
if (Object.keys(header).length > 0) body.header = header;
return { ok: true, body: Object.keys(body).length > 0 ? body : null };
}

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import type { GridRow } from './statementGrid';
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid';
import { applyDraftInput, buildStatementGrid, cellFlags, emptySublineKeys } from './statementGrid';
const v = (id: string | null, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
id,
@ -109,22 +109,6 @@ describe('cellFlags — внимание только по needs_review', () =>
});
});
describe('draftToPatch', () => {
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_code: '1700', column_id: 'c1', amount: null },
]);
});
it('неизвестная строка — ошибка, а не тихий пропуск', () => {
expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow();
});
});
describe('applyDraftInput', () => {
const parse = (s: string): { ok: true; value: string | null } | { ok: false } => {
const n = s.replace(/\s/g, '').replace(',', '.');

View File

@ -1,4 +1,4 @@
import type { CellPatch, ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
export type GridRow = {
key: string;
@ -83,20 +83,6 @@ export function emptySublineKeys(rows: GridRow[], draft: Record<string, string>)
);
}
/** Черновик → тело 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]) => {
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 };
});
}
/** Каноничный вид суммы для сравнения строк: без хвостовых нулей дроби и лишних ведущих нулей. */
function normalizeAmount(value: string | null): string | null {
if (value === null) {