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:
parent
a779d2a04a
commit
5b11f2d00a
@ -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;
|
||||
|
||||
@ -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} />
|
||||
|
||||
34
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal file
34
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
12
src/widgets/DocumentReview/confirmBlockers.ts
Normal file
12
src/widgets/DocumentReview/confirmBlockers.ts
Normal 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}`;
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user