feat(statement): блокировка подтверждения при строках без кода, LINES_UNRESOLVED и DUPLICATE_FORM (#43)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:42:32 +04:00
parent a779d2a04a
commit 5b11f2d00a
6 changed files with 146 additions and 31 deletions

View File

@ -214,6 +214,19 @@ export interface LineOut {
is_confirmed: boolean;
values: ValueOut[];
}
/** Элемент detail 422/409 по строке: LINE_CODE_TAKEN, LINE_CODE_REQUIRED и др. */
export interface LineErrorDetail {
code: string;
message: string;
line_id: string;
line_code: string | null;
}
/** Строка без кода в detail.lines ответа 409 LINES_UNRESOLVED. */
export interface UnresolvedLineOut {
line_id: string;
row_index: number | null;
line_code_text: string | null;
}
export interface FailedCheckOut {
column_index: number;
relation: string;

View File

@ -1,9 +1,10 @@
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Space, Splitter, Switch, Tooltip, Typography, message } from 'antd';
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut } from '../../types/api';
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { IssuesPanel } from './IssuesPanel';
@ -12,6 +13,7 @@ import { SourcePreview } from './SourcePreview';
import { StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
import { confirmErrorInfo } from './confirmError';
import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout';
@ -33,6 +35,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [saving, setSaving] = useState(false);
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const [confirmLines, setConfirmLines] = useState<UnresolvedLineOut[]>([]);
const [duplicateId, setDuplicateId] = useState<string | null>(null);
const navigate = useNavigate();
const [showEmptySublines, setShowEmptySublines] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [highlightKey, setHighlightKey] = useState<string | null>(null);
@ -102,6 +107,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}, [pendingFocus]);
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
const details = grid ? detailCount(grid.rows) : 0;
const unresolved = useMemo(() => (grid ? unresolvedLines(grid.rows) : []), [grid]);
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
@ -127,6 +133,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
setEditing(false);
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
message.success('Изменения сохранены');
onChanged();
} catch (e: unknown) {
@ -149,23 +156,32 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
onChanged();
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось принять нарушения'));
throw e;
}
};
const resetConfirmError = () => {
setConfirmLines([]);
setDuplicateId(null);
};
const confirm = async () => {
try {
await confirmDocument(document.id);
message.success('Документ подтверждён');
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
onChanged();
await load();
} catch (e: unknown) {
const info = confirmErrorInfo(e);
setConfirmFailures(info.failures);
setConfirmLines(info.lines);
setDuplicateId(info.code === 'DUPLICATE_FORM' ? info.documentId : null);
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
}
};
@ -177,6 +193,19 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
return null;
}
let confirmDescription: ReactNode;
if (confirmFailures.length > 0) {
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
} else if (confirmLines.length > 0) {
confirmDescription = (
<Typography.Link onClick={() => focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)}</Typography.Link>
);
} else if (duplicateId) {
confirmDescription = (
<Typography.Link onClick={() => navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму</Typography.Link>
);
}
const confirmEnabled = actions.canConfirm && unresolved.length === 0;
const cardActions = editing ? (
<Space>
<Button
@ -195,14 +224,21 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
Редактировать
</Button>
<Button
type='primary'
icon={<CheckOutlined />}
style={actions.canConfirm ? confirmButtonStyle : undefined}
disabled={!actions.canConfirm}
onClick={confirm}>
Подтвердить данные
</Button>
<Tooltip
title={
unresolved.length > 0 ? (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
) : undefined
}>
<Button
type='primary'
icon={<CheckOutlined />}
style={confirmEnabled ? confirmButtonStyle : undefined}
disabled={!confirmEnabled}
onClick={confirm}>
Подтвердить данные
</Button>
</Tooltip>
</Space>
);
@ -229,18 +265,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
/>
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
<IssuesPanel items={issueItems} onShow={focusRow} />
{confirmMessage && (
<Alert
type='error'
showIcon
title={confirmMessage}
description={
confirmFailures.length > 0
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
: undefined
}
/>
)}
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
{emptySublines.size > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
import { type GridRow, buildStatementGrid } from './statementGrid';
const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut);
const emptyCell = { amount: null, string_value: null } as ValueOut;
const row = (patch: Partial<GridRow>): GridRow => ({ ...rows[0], cells: {}, ...patch });
describe('unresolvedLines', () => {
it('фикстура: строки без кода со значениями', () => {
expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']);
});
it('missing без значений не блокирует', () => {
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
});
it('unreadable только со string_value блокирует', () => {
const r = row({ lineStatus: 'unreadable', cells: { a: { ...emptyCell, string_value: '211(1 |' } } });
expect(unresolvedLines([r])).toEqual([r]);
});
it('detail с суммой не блокирует', () => {
expect(unresolvedLines([row({ lineStatus: 'detail', cells: { a: { ...emptyCell, amount: '1.00' } } })])).toEqual([]);
});
it('форма 1.x: все ok — пусто', () => {
expect(unresolvedLines(rows.filter((r) => r.lineStatus === 'ok'))).toEqual([]);
});
});
describe('unresolvedHint', () => {
it('текст', () => {
expect(unresolvedHint(2)).toBe('Разберите строки без кода: 2');
});
});

View File

@ -0,0 +1,12 @@
import type { GridRow } from './statementGrid';
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */
export function unresolvedLines(rows: GridRow[]): GridRow[] {
return rows.filter(
(row) =>
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)),
);
}
export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`;

View File

@ -8,7 +8,13 @@ describe('confirmErrorInfo', () => {
const e = new ApiError(409, 'CHECKS_NOT_PASSED', 'не пройдены проверки', {
detail: { code: 'CHECKS_NOT_PASSED', message: 'x', failures: [failure] },
});
expect(confirmErrorInfo(e)).toEqual({ code: 'CHECKS_NOT_PASSED', message: 'не пройдены проверки', failures: [failure] });
expect(confirmErrorInfo(e)).toEqual({
code: 'CHECKS_NOT_PASSED',
message: 'не пройдены проверки',
failures: [failure],
lines: [],
documentId: null,
});
});
it('прочие ошибки без нарушений', () => {
expect(
@ -17,7 +23,24 @@ describe('confirmErrorInfo', () => {
code: 'PERIOD_REQUIRED',
message: 'нужна дата',
failures: [],
lines: [],
documentId: null,
});
expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] });
expect(confirmErrorInfo(new Error('boom'))).toEqual({
code: undefined,
message: 'Не удалось подтвердить документ',
failures: [],
lines: [],
documentId: null,
});
});
it('LINES_UNRESOLVED со строками', () => {
const line = { line_id: 'l-m8', row_index: 8, line_code_text: null };
const e = new ApiError(409, 'LINES_UNRESOLVED', 'есть строки без кода', { detail: { lines: [line] } });
expect(confirmErrorInfo(e).lines).toEqual([line]);
});
it('DUPLICATE_FORM с document_id', () => {
const e = new ApiError(409, 'DUPLICATE_FORM', 'форма уже есть', { detail: { document_id: 'doc-0' } });
expect(confirmErrorInfo(e).documentId).toBe('doc-0');
});
});

View File

@ -1,12 +1,20 @@
import { ApiError } from '../../api/client';
import type { FailedCheckOut } from '../../types/api';
import type { FailedCheckOut, UnresolvedLineOut } from '../../types/api';
/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */
export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } {
/** Разбор ошибки подтверждения: код, текст, нарушения проверок (CHECKS_NOT_PASSED), строки без кода (LINES_UNRESOLVED) и документ-дубль (DUPLICATE_FORM). */
export function confirmErrorInfo(e: unknown): {
code: string | undefined;
message: string;
failures: FailedCheckOut[];
lines: UnresolvedLineOut[];
documentId: string | null;
} {
if (!(e instanceof ApiError)) {
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] };
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [], lines: [], documentId: null };
}
const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail;
const detail = (e.body as { detail?: { failures?: unknown; lines?: unknown; document_id?: unknown } } | undefined)?.detail;
const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : [];
return { code: e.code, message: e.message, failures };
const lines = Array.isArray(detail?.lines) ? (detail.lines as UnresolvedLineOut[]) : [];
const documentId = typeof detail?.document_id === 'string' ? detail.document_id : null;
return { code: e.code, message: e.message, failures, lines, documentId };
}