diff --git a/src/utils/dates.test.ts b/src/utils/dates.test.ts index e77b4d6..ab6ad08 100644 --- a/src/utils/dates.test.ts +++ b/src/utils/dates.test.ts @@ -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); + } + }); +}); diff --git a/src/utils/dates.ts b/src/utils/dates.ts index eb48df4..21c345d 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -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; +} diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 25aab59..c4411a1 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -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>; +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; - /** «Подтвердить дату»; черновик подтверждения подключает T6 */ + /** ключи полей шапки с ошибкой черновика (красная рамка) */ + errorKeys?: Set; + /** «Подтвердить дату»: кладёт в черновик 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 (
@@ -66,6 +67,7 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD <> onDraftChange(r.field, e.target.value)} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 466965f..e07df9c 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -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(null); const [loadError, setLoadError] = useState(null); - const [draft, setDraft] = useState>({}); - const [header, setHeader] = useState({}); + const [draft, setDraftState] = useState(emptyDraft); + // ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик + const [draftErrors, setDraftErrors] = useState([]); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [confirmFailures, setConfirmFailures] = useState([]); @@ -35,6 +37,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [pendingFocus, setPendingFocus] = useState(null); const highlightTimer = useRef(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()), [grid, draft]); + const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts) : new Set()), [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() : 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 = {}; - 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 } }))} /> @@ -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) })) + } /> diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 45d7758..feb04f1 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -16,6 +16,8 @@ type Props = { editable: boolean; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */ + errorKeys?: Set; /** предупреждения и нарушения у мест таблицы */ 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)} /> ); diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index 6f3d315..e1e8ce4 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -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 ( diff --git a/src/widgets/DocumentReview/statementDraft.test.ts b/src/widgets/DocumentReview/statementDraft.test.ts new file mode 100644 index 0000000..93af6fc --- /dev/null +++ b/src/widgets/DocumentReview/statementDraft.test.ts @@ -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 => ({ ...emptyDraft(), ...part }); +const build = (part: Partial) => buildStatementPatch(draftOf(part), ctx); +const bodyOf = (part: Partial) => { + const result = build(part); + if (!result.ok) throw new Error(`ожидали тело, получили ошибки: ${JSON.stringify(result.errors)}`); + return result.body; +}; +const errorsOf = (part: Partial) => { + 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); + }); +}); diff --git a/src/widgets/DocumentReview/statementDraft.ts b/src/widgets/DocumentReview/statementDraft.ts new file mode 100644 index 0000000..e8edac9 --- /dev/null +++ b/src/widgets/DocumentReview/statementDraft.ts @@ -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> & { confirm_report_date?: boolean }; + +/** Все несохранённые правки аналитика; уходят одним PATCH. */ +export interface StatementDraft { + /** cellKey → введённый текст суммы */ + amounts: Record; + /** cellKey → подтверждена ли ячейка */ + confirms: Record; + /** lineId → действие над строкой */ + lines: Record; + /** columnId → введённая дата колонки */ + columns: Record; + 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(); + 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 = {}; + 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 }; +} diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index 943b52e..b08cf95 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 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 => ({ 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(',', '.'); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 0638112..a66d523 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -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) ); } -/** Черновик → тело 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 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) {