Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
134
src/widgets/DocumentReview/statementDraft.test.ts
Normal file
134
src/widgets/DocumentReview/statementDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
src/widgets/DocumentReview/statementDraft.ts
Normal file
133
src/widgets/DocumentReview/statementDraft.ts
Normal 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 };
|
||||
}
|
||||
@ -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(',', '.');
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user