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:
parent
8ab116fdba
commit
ef4d32592e
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatMoscowDate } from './dates';
|
import { formatMoscowDate, isIsoDate } from './dates';
|
||||||
|
|
||||||
describe('formatMoscowDate', () => {
|
describe('formatMoscowDate', () => {
|
||||||
it('момент времени из API (UTC) — дата по Москве', () => {
|
it('момент времени из API (UTC) — дата по Москве', () => {
|
||||||
@ -11,3 +11,15 @@ describe('formatMoscowDate', () => {
|
|||||||
expect(formatMoscowDate('не дата')).toBeNull();
|
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);
|
const date = new Date(iso);
|
||||||
return Number.isNaN(date.getTime()) ? null : MOSCOW.format(date);
|
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 { type HeaderFieldKey, headerFields } from './headerFields';
|
||||||
import { worstSeverity } from './issuePlaces';
|
import { worstSeverity } from './issuePlaces';
|
||||||
import { formTitle, formatReportDate } from './reviewView';
|
import { formTitle, formatReportDate } from './reviewView';
|
||||||
|
import type { HeaderDraft } from './statementDraft';
|
||||||
export type HeaderDraft = Partial<Record<HeaderFieldKey, string>>;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
header: StatementHeaderOut;
|
header: StatementHeaderOut;
|
||||||
@ -15,7 +14,9 @@ type Props = {
|
|||||||
onDraftChange: (field: HeaderFieldKey, text: string) => void;
|
onDraftChange: (field: HeaderFieldKey, text: string) => void;
|
||||||
/** предупреждения по полям шапки (IssuePlaces.byField) */
|
/** предупреждения по полям шапки (IssuePlaces.byField) */
|
||||||
byField?: Map<string, IssueOut[]>;
|
byField?: Map<string, IssueOut[]>;
|
||||||
/** «Подтвердить дату»; черновик подтверждения подключает T6 */
|
/** ключи полей шапки с ошибкой черновика (красная рамка) */
|
||||||
|
errorKeys?: Set<string>;
|
||||||
|
/** «Подтвердить дату»: кладёт в черновик confirm_report_date */
|
||||||
onConfirmDate?: () => void;
|
onConfirmDate?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
|
|||||||
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||||
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
|
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (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);
|
const reportDate = formatReportDate(header.report_date.value);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@ -66,6 +67,7 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
|
|||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
size='small'
|
size='small'
|
||||||
|
status={errorKeys?.has(r.field) ? 'error' : undefined}
|
||||||
placeholder={PLACEHOLDER[r.field]}
|
placeholder={PLACEHOLDER[r.field]}
|
||||||
value={draft[r.field] ?? r.raw ?? ''}
|
value={draft[r.field] ?? r.raw ?? ''}
|
||||||
onChange={(e) => onDraftChange(r.field, e.target.value)}
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
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 { parseAmountInput } from '../../utils/money';
|
||||||
import { ChecksPanel, failureText } from './ChecksPanel';
|
import { ChecksPanel, failureText } from './ChecksPanel';
|
||||||
import { IssuesPanel } from './IssuesPanel';
|
import { IssuesPanel } from './IssuesPanel';
|
||||||
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
||||||
import { SourcePreview } from './SourcePreview';
|
import { SourcePreview } from './SourcePreview';
|
||||||
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
|
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
||||||
import { StatementTable } from './StatementTable';
|
import { StatementTable } from './StatementTable';
|
||||||
import { confirmErrorInfo } from './confirmError';
|
import { confirmErrorInfo } from './confirmError';
|
||||||
import { switchesFor } from './focusTarget';
|
import { switchesFor } from './focusTarget';
|
||||||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||||||
import { issueListItems, issuePlaces } from './issuePlaces';
|
import { issueListItems, issuePlaces } from './issuePlaces';
|
||||||
import { reviewActions } from './reviewView';
|
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);
|
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
|
||||||
|
|
||||||
export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
|
export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
|
||||||
const [statement, setStatement] = useState<StatementOut | null>(null);
|
const [statement, setStatement] = useState<StatementOut | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
const [draft, setDraftState] = useState<StatementDraft>(emptyDraft);
|
||||||
const [header, setHeader] = useState<HeaderDraft>({});
|
// ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик
|
||||||
|
const [draftErrors, setDraftErrors] = useState<DraftError[]>([]);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
||||||
@ -35,6 +37,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
|
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
|
||||||
const highlightTimer = useRef<number | undefined>(undefined);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setStatement(await getStatement(document.id));
|
setStatement(await getStatement(document.id));
|
||||||
@ -50,7 +58,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
|
|
||||||
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
|
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
|
||||||
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
|
// пустые строки детализации скрыты по умолчанию (только показ: 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 fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
|
||||||
const layout = useMemo(
|
const layout = useMemo(
|
||||||
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
|
() => 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 actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
|
||||||
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
||||||
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
|
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 save = async () => {
|
||||||
const values: Record<string, string | null> = {};
|
if (!statement || !grid) return;
|
||||||
for (const [key, text] of Object.entries(draft)) {
|
const built = buildStatementPatch(draft, { rows: grid.rows, header: statement.header });
|
||||||
const parsed = parseAmountInput(text);
|
if (!built.ok) {
|
||||||
if (!parsed.ok) {
|
setDraftErrors(built.errors);
|
||||||
message.error('Исправьте суммы с неверным форматом');
|
message.error(built.errors[0].message);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
values[key] = parsed.value;
|
|
||||||
}
|
}
|
||||||
const body: StatementPatch = {};
|
if (built.body === null) {
|
||||||
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) {
|
|
||||||
cancel();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
|
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
|
||||||
setStatement(await patchStatement(document.id, body));
|
setStatement(await patchStatement(document.id, built.body));
|
||||||
setDraft({});
|
setDraft(() => emptyDraft());
|
||||||
setHeader({});
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setConfirmFailures([]);
|
setConfirmFailures([]);
|
||||||
setConfirmMessage(null);
|
setConfirmMessage(null);
|
||||||
@ -131,8 +122,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
setDraft({});
|
setDraft(() => emptyDraft());
|
||||||
setHeader({});
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -214,9 +204,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
header={statement.header}
|
header={statement.header}
|
||||||
documentType={statement.document.document_type}
|
documentType={statement.document.document_type}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
draft={header}
|
draft={draft.header}
|
||||||
byField={places.byField}
|
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} />
|
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
|
||||||
<IssuesPanel items={issueItems} onShow={focusRow} />
|
<IssuesPanel items={issueItems} onShow={focusRow} />
|
||||||
@ -254,8 +246,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
places={places}
|
places={places}
|
||||||
highlightKey={highlightKey}
|
highlightKey={highlightKey}
|
||||||
editable={editing && actions.canSave}
|
editable={editing && actions.canSave}
|
||||||
draft={draft}
|
draft={draft.amounts}
|
||||||
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
|
errorKeys={errorKeys}
|
||||||
|
onDraftChange={(key, text, original) =>
|
||||||
|
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</ReviewCard>
|
</ReviewCard>
|
||||||
|
|||||||
@ -16,6 +16,8 @@ type Props = {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
draft: Record<string, string>;
|
draft: Record<string, string>;
|
||||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||||
|
/** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */
|
||||||
|
errorKeys?: Set<string>;
|
||||||
/** предупреждения и нарушения у мест таблицы */
|
/** предупреждения и нарушения у мест таблицы */
|
||||||
places: IssuePlaces;
|
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;
|
const span = columns.length + 2;
|
||||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||||
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
|
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}
|
draftKey={draftKey}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onDraftChange={onDraftChange}
|
onDraftChange={onDraftChange}
|
||||||
|
hasError={errorKeys?.has(draftKey)}
|
||||||
issues={places.byCell.get(draftKey)}
|
issues={places.byCell.get(draftKey)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,10 +15,12 @@ type Props = {
|
|||||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||||
/** записи issues, привязанные к этой ячейке */
|
/** записи issues, привязанные к этой ячейке */
|
||||||
issues?: IssueOut[];
|
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 lines = cellTipLines(value, issues);
|
||||||
const worst = worstSeverity(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 text = draft[draftKey] ?? value.amount ?? '';
|
||||||
const invalid = draftKey in draft && !parseAmountInput(text).ok;
|
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
|
||||||
return (
|
return (
|
||||||
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
||||||
<span>
|
<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 type { StatementOut, ValueOut } from '../../types/api';
|
||||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||||
import type { GridRow } from './statementGrid';
|
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 => ({
|
const v = (id: string | null, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
|
||||||
id,
|
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', () => {
|
describe('applyDraftInput', () => {
|
||||||
const parse = (s: string): { ok: true; value: string | null } | { ok: false } => {
|
const parse = (s: string): { ok: true; value: string | null } | { ok: false } => {
|
||||||
const n = s.replace(/\s/g, '').replace(',', '.');
|
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 = {
|
export type GridRow = {
|
||||||
key: string;
|
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 {
|
function normalizeAmount(value: string | null): string | null {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user