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;
|
is_confirmed: boolean;
|
||||||
values: ValueOut[];
|
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 {
|
export interface FailedCheckOut {
|
||||||
column_index: number;
|
column_index: number;
|
||||||
relation: string;
|
relation: string;
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
|
import { Alert, Button, Space, Splitter, Switch, Tooltip, Typography, message } from 'antd';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
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 } from '../../types/api';
|
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } 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';
|
||||||
@ -12,6 +13,7 @@ import { SourcePreview } from './SourcePreview';
|
|||||||
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
||||||
import { StatementTable } from './StatementTable';
|
import { StatementTable } from './StatementTable';
|
||||||
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
|
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||||||
|
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
|
||||||
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';
|
||||||
@ -33,6 +35,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
||||||
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
|
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 [showEmptySublines, setShowEmptySublines] = useState(false);
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const [highlightKey, setHighlightKey] = useState<string | null>(null);
|
const [highlightKey, setHighlightKey] = useState<string | null>(null);
|
||||||
@ -102,6 +107,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
}, [pendingFocus]);
|
}, [pendingFocus]);
|
||||||
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
|
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
|
||||||
const details = grid ? detailCount(grid.rows) : 0;
|
const details = grid ? detailCount(grid.rows) : 0;
|
||||||
|
const unresolved = useMemo(() => (grid ? unresolvedLines(grid.rows) : []), [grid]);
|
||||||
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';
|
||||||
@ -127,6 +133,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
setConfirmFailures([]);
|
setConfirmFailures([]);
|
||||||
setConfirmMessage(null);
|
setConfirmMessage(null);
|
||||||
|
resetConfirmError();
|
||||||
message.success('Изменения сохранены');
|
message.success('Изменения сохранены');
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@ -149,23 +156,32 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
onChanged();
|
onChanged();
|
||||||
setConfirmFailures([]);
|
setConfirmFailures([]);
|
||||||
setConfirmMessage(null);
|
setConfirmMessage(null);
|
||||||
|
resetConfirmError();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(errorText(e, 'Не удалось принять нарушения'));
|
message.error(errorText(e, 'Не удалось принять нарушения'));
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetConfirmError = () => {
|
||||||
|
setConfirmLines([]);
|
||||||
|
setDuplicateId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
try {
|
try {
|
||||||
await confirmDocument(document.id);
|
await confirmDocument(document.id);
|
||||||
message.success('Документ подтверждён');
|
message.success('Документ подтверждён');
|
||||||
setConfirmFailures([]);
|
setConfirmFailures([]);
|
||||||
setConfirmMessage(null);
|
setConfirmMessage(null);
|
||||||
|
resetConfirmError();
|
||||||
onChanged();
|
onChanged();
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const info = confirmErrorInfo(e);
|
const info = confirmErrorInfo(e);
|
||||||
setConfirmFailures(info.failures);
|
setConfirmFailures(info.failures);
|
||||||
|
setConfirmLines(info.lines);
|
||||||
|
setDuplicateId(info.code === 'DUPLICATE_FORM' ? info.documentId : null);
|
||||||
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
|
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -177,6 +193,19 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
return null;
|
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 ? (
|
const cardActions = editing ? (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
@ -195,14 +224,21 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
|
<Button icon={<EditOutlined />} disabled={!actions.canEdit} onClick={() => setEditing(true)}>
|
||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Tooltip
|
||||||
type='primary'
|
title={
|
||||||
icon={<CheckOutlined />}
|
unresolved.length > 0 ? (
|
||||||
style={actions.canConfirm ? confirmButtonStyle : undefined}
|
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
||||||
disabled={!actions.canConfirm}
|
) : undefined
|
||||||
onClick={confirm}>
|
}>
|
||||||
Подтвердить данные
|
<Button
|
||||||
</Button>
|
type='primary'
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||||
|
disabled={!confirmEnabled}
|
||||||
|
onClick={confirm}>
|
||||||
|
Подтвердить данные
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -229,18 +265,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
|||||||
/>
|
/>
|
||||||
<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} />
|
||||||
{confirmMessage && (
|
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
|
||||||
<Alert
|
|
||||||
type='error'
|
|
||||||
showIcon
|
|
||||||
title={confirmMessage}
|
|
||||||
description={
|
|
||||||
confirmFailures.length > 0
|
|
||||||
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{emptySublines.size > 0 && (
|
{emptySublines.size > 0 && (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
|
<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', 'не пройдены проверки', {
|
const e = new ApiError(409, 'CHECKS_NOT_PASSED', 'не пройдены проверки', {
|
||||||
detail: { code: 'CHECKS_NOT_PASSED', message: 'x', failures: [failure] },
|
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('прочие ошибки без нарушений', () => {
|
it('прочие ошибки без нарушений', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -17,7 +23,24 @@ describe('confirmErrorInfo', () => {
|
|||||||
code: 'PERIOD_REQUIRED',
|
code: 'PERIOD_REQUIRED',
|
||||||
message: 'нужна дата',
|
message: 'нужна дата',
|
||||||
failures: [],
|
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 { ApiError } from '../../api/client';
|
||||||
import type { FailedCheckOut } from '../../types/api';
|
import type { FailedCheckOut, UnresolvedLineOut } from '../../types/api';
|
||||||
|
|
||||||
/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */
|
/** Разбор ошибки подтверждения: код, текст, нарушения проверок (CHECKS_NOT_PASSED), строки без кода (LINES_UNRESOLVED) и документ-дубль (DUPLICATE_FORM). */
|
||||||
export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } {
|
export function confirmErrorInfo(e: unknown): {
|
||||||
|
code: string | undefined;
|
||||||
|
message: string;
|
||||||
|
failures: FailedCheckOut[];
|
||||||
|
lines: UnresolvedLineOut[];
|
||||||
|
documentId: string | null;
|
||||||
|
} {
|
||||||
if (!(e instanceof ApiError)) {
|
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[]) : [];
|
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