From 5b11f2d00aca1bb572a8981a654008486009014f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:42:32 +0400 Subject: [PATCH] =?UTF-8?q?feat(statement):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D1=85=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0,=20LINES=5FUNRESO?= =?UTF-8?q?LVED=20=D0=B8=20DUPLICATE=5FFORM=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/types/api.ts | 13 ++++ .../DocumentReview/StatementReview.tsx | 71 +++++++++++++------ .../DocumentReview/confirmBlockers.test.ts | 34 +++++++++ src/widgets/DocumentReview/confirmBlockers.ts | 12 ++++ .../DocumentReview/confirmError.test.ts | 27 ++++++- src/widgets/DocumentReview/confirmError.ts | 20 ++++-- 6 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 src/widgets/DocumentReview/confirmBlockers.test.ts create mode 100644 src/widgets/DocumentReview/confirmBlockers.ts diff --git a/src/types/api.ts b/src/types/api.ts index 55026d8..e068d0c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index cb21607..d0b2fdc 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -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([]); const [confirmMessage, setConfirmMessage] = useState(null); + const [confirmLines, setConfirmLines] = useState([]); + const [duplicateId, setDuplicateId] = useState(null); + const navigate = useNavigate(); const [showEmptySublines, setShowEmptySublines] = useState(false); const [showDetails, setShowDetails] = useState(false); const [highlightKey, setHighlightKey] = useState(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) =>
{failureText(f)}
); + } else if (confirmLines.length > 0) { + confirmDescription = ( + focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)} + ); + } else if (duplicateId) { + confirmDescription = ( + navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму + ); + } + const confirmEnabled = actions.canConfirm && unresolved.length === 0; const cardActions = editing ? ( - + 0 ? ( + focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} + ) : undefined + }> + + ); @@ -229,18 +265,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut /> - {confirmMessage && ( - 0 - ? confirmFailures.map((f) =>
{failureText(f)}
) - : undefined - } - /> - )} + {confirmMessage && } {emptySublines.size > 0 && ( diff --git a/src/widgets/DocumentReview/confirmBlockers.test.ts b/src/widgets/DocumentReview/confirmBlockers.test.ts new file mode 100644 index 0000000..6e7692a --- /dev/null +++ b/src/widgets/DocumentReview/confirmBlockers.test.ts @@ -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 => ({ ...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'); + }); +}); diff --git a/src/widgets/DocumentReview/confirmBlockers.ts b/src/widgets/DocumentReview/confirmBlockers.ts new file mode 100644 index 0000000..f27e4e2 --- /dev/null +++ b/src/widgets/DocumentReview/confirmBlockers.ts @@ -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}`; diff --git a/src/widgets/DocumentReview/confirmError.test.ts b/src/widgets/DocumentReview/confirmError.test.ts index 2d8957e..11828b4 100644 --- a/src/widgets/DocumentReview/confirmError.test.ts +++ b/src/widgets/DocumentReview/confirmError.test.ts @@ -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'); }); }); diff --git a/src/widgets/DocumentReview/confirmError.ts b/src/widgets/DocumentReview/confirmError.ts index 138c9fb..8caac83 100644 --- a/src/widgets/DocumentReview/confirmError.ts +++ b/src/widgets/DocumentReview/confirmError.ts @@ -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 }; }