From 5d1c28d0de598edd7b477003fdc175d8f3064d2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 01:16:37 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=9E=D0=A1?= =?UTF-8?q?=D0=92-=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20(#3)?= 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_01EAKRDNzXtRrwmWz9z6PZu3 --- src/App.tsx | 14 ++- src/components/PageTitle.tsx | 3 +- src/layouts/DocumentPage.tsx | 63 ++++++++++ src/layouts/index.ts | 1 + .../api/directories/company_groups.ts | 2 + src/widgets/DocumentReview/OsvDocument.tsx | 116 ++++++++++++++++++ .../DocumentReview/confirmError.test.ts | 23 ++++ src/widgets/DocumentReview/confirmError.ts | 12 ++ src/widgets/DocumentReview/entryTree.test.ts | 34 +++++ src/widgets/DocumentReview/entryTree.ts | 20 +++ src/widgets/DocumentReview/viewKind.test.ts | 16 +++ src/widgets/DocumentReview/viewKind.ts | 7 ++ 12 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/layouts/DocumentPage.tsx create mode 100644 src/widgets/DocumentReview/OsvDocument.tsx create mode 100644 src/widgets/DocumentReview/confirmError.test.ts create mode 100644 src/widgets/DocumentReview/confirmError.ts create mode 100644 src/widgets/DocumentReview/entryTree.test.ts create mode 100644 src/widgets/DocumentReview/entryTree.ts create mode 100644 src/widgets/DocumentReview/viewKind.test.ts create mode 100644 src/widgets/DocumentReview/viewKind.ts diff --git a/src/App.tsx b/src/App.tsx index 86a8efd..5e89264 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,18 @@ import { Provider } from 'react-redux'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { AuthGate } from './auth/AuthGate'; import { Config } from './conf/config'; -import { Admin, AuditLogging, CatalogueGroup, CatalogueJur, ConsolidationPage, DocStorage, GroupsLayout, Main, Mono } from './layouts/'; +import { + Admin, + AuditLogging, + CatalogueGroup, + CatalogueJur, + ConsolidationPage, + DocStorage, + DocumentPage, + GroupsLayout, + Main, + Mono, +} from './layouts/'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -28,6 +39,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx index 25b8dc0..523b860 100644 --- a/src/components/PageTitle.tsx +++ b/src/components/PageTitle.tsx @@ -1,11 +1,12 @@ import { ArrowLeftOutlined } from '@ant-design/icons'; import { Button } from 'antd'; +import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; interface PageTitleProps { location: string; header: string; - description?: string; + description?: ReactNode; backable?: boolean; } export default function PageTitle({ location, header, description, backable = false }: PageTitleProps) { diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx new file mode 100644 index 0000000..edf618d --- /dev/null +++ b/src/layouts/DocumentPage.tsx @@ -0,0 +1,63 @@ +import { Button, Empty, Result, Spin, Tag } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ApiError } from '../api/client'; +import PageTitle from '../components/PageTitle'; +import { fetchLegalEntity } from '../services/api/directories/company_groups'; +import { getDocument } from '../services/api/documents'; +import type { DocumentOut } from '../types/api'; +import { documentStatusLabel, documentTypeLabel, periodLabel } from '../utils/labels'; +import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; +import { documentViewKind } from '../widgets/DocumentReview/viewKind'; + +export function DocumentPage() { + const { document_id } = useParams(); + const navigate = useNavigate(); + const [doc, setDoc] = useState(null); + const [entityName, setEntityName] = useState(''); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!document_id) return; + try { + const d = await getDocument(document_id); + setDoc(d); + setError(null); + fetchLegalEntity(d.legal_entity_id) + .then((le) => setEntityName(le.name)) + .catch(() => setEntityName('')); + } catch (e: unknown) { + setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); + } + }, [document_id]); + + useEffect(() => { + void load(); + }, [load]); + + if (error) { + return navigate(-1)}>Назад} />; + } + if (!doc) { + return ; + } + const kind = documentViewKind(doc); + // Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран. + const broken = doc.status === 'ERROR' && kind !== 'none'; + return ( +
+ {documentStatusLabel(doc.status)}} + /> +
+ {broken && } + {!broken && kind === 'osv' && } + {!broken && kind === 'statement' && } + {kind === 'none' && } +
+
+ ); +} diff --git a/src/layouts/index.ts b/src/layouts/index.ts index 56668c4..26f05ce 100644 --- a/src/layouts/index.ts +++ b/src/layouts/index.ts @@ -8,3 +8,4 @@ export { CatalogueGroup } from './CatalogueGroups'; export { GroupsLayout } from './Groups'; export { AuditLogging } from './AuditLogging'; export { Admin } from './Admin'; +export { DocumentPage } from './DocumentPage'; diff --git a/src/services/api/directories/company_groups.ts b/src/services/api/directories/company_groups.ts index 54feb28..0dd5c51 100644 --- a/src/services/api/directories/company_groups.ts +++ b/src/services/api/directories/company_groups.ts @@ -34,3 +34,5 @@ export async function fetchAllLegalEntities(groupId: string): Promise api.get(`/directories/legal-entities/${id}`); diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx new file mode 100644 index 0000000..5fa3ab1 --- /dev/null +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -0,0 +1,116 @@ +import { Alert, Button, Table, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { confirmDocument, getAllEntries } from '../../services/api/documents'; +import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api'; +import { formatAmount } from '../../utils/money'; +import { confirmErrorInfo } from './confirmError'; +import { type EntryNode, buildEntryTree } from './entryTree'; + +const money = (key: keyof EntryOut, title: string) => ({ + key, + title, + dataIndex: key, + align: 'right' as const, + width: 140, + render: (v: string | null) => formatAmount(v), +}); + +const columns: ColumnsType = [ + { + key: 'row_value', + title: 'Счёт / контрагент', + dataIndex: 'row_value', + render: (v: string | null, r) => v ?? r.counterparty_name ?? r.account_code ?? '', + }, + { key: 'counterparty_inn', title: 'ИНН', dataIndex: 'counterparty_inn', width: 120 }, + money('opening_debit', 'Сальдо нач. Дт'), + money('opening_credit', 'Сальдо нач. Кт'), + money('turnover_debit', 'Обороты Дт'), + money('turnover_credit', 'Обороты Кт'), + money('closing_debit', 'Сальдо кон. Дт'), + money('closing_credit', 'Сальдо кон. Кт'), +]; + +interface ConfirmProblem { + code: string | undefined; + message: string; + failures: FailedCheckOut[]; +} + +export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [confirming, setConfirming] = useState(false); + const [problem, setProblem] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getAllEntries(document.id) + .then((items) => !cancelled && setEntries(items)) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось получить строки ОСВ')) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [document.id]); + + const tree = useMemo(() => buildEntryTree(entries), [entries]); + const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK'; + + const confirm = async () => { + setConfirming(true); + setProblem(null); + try { + await confirmDocument(document.id); + message.success('Документ подтверждён'); + onChanged(); + } catch (e: unknown) { + setProblem(confirmErrorInfo(e)); + } finally { + setConfirming(false); + } + }; + + return ( +
+
+ + Суммы в рублях; строки ОСВ только для чтения +
+ {problem && ( + setProblem(null)} + message={problem.code ? `${problem.message} (${problem.code})` : problem.message} + description={ + problem.failures.length > 0 ? ( +
    + {problem.failures.map((f) => ( +
  • + {f.relation}: ожидалось {formatAmount(f.expected)}, получено {formatAmount(f.actual)} +
  • + ))} +
+ ) : undefined + } + /> + )} + + size='small' + rowKey='key' + loading={loading} + columns={columns} + dataSource={tree} + pagination={false} + scroll={{ y: 'calc(100vh - 260px)', x: 1300 }} + /> +
+ ); +} diff --git a/src/widgets/DocumentReview/confirmError.test.ts b/src/widgets/DocumentReview/confirmError.test.ts new file mode 100644 index 0000000..2d8957e --- /dev/null +++ b/src/widgets/DocumentReview/confirmError.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '../../api/client'; +import { confirmErrorInfo } from './confirmError'; + +describe('confirmErrorInfo', () => { + it('CHECKS_NOT_PASSED с нарушениями', () => { + const failure = { column_index: 0, relation: '1600 = 1700', expected: '10.00', actual: '9.00' }; + 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] }); + }); + it('прочие ошибки без нарушений', () => { + expect( + confirmErrorInfo(new ApiError(409, 'PERIOD_REQUIRED', 'нужна дата', { detail: { code: 'PERIOD_REQUIRED', message: 'нужна дата' } })), + ).toEqual({ + code: 'PERIOD_REQUIRED', + message: 'нужна дата', + failures: [], + }); + expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] }); + }); +}); diff --git a/src/widgets/DocumentReview/confirmError.ts b/src/widgets/DocumentReview/confirmError.ts new file mode 100644 index 0000000..138c9fb --- /dev/null +++ b/src/widgets/DocumentReview/confirmError.ts @@ -0,0 +1,12 @@ +import { ApiError } from '../../api/client'; +import type { FailedCheckOut } from '../../types/api'; + +/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */ +export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } { + if (!(e instanceof ApiError)) { + return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] }; + } + const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail; + const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : []; + return { code: e.code, message: e.message, failures }; +} diff --git a/src/widgets/DocumentReview/entryTree.test.ts b/src/widgets/DocumentReview/entryTree.test.ts new file mode 100644 index 0000000..a55e89a --- /dev/null +++ b/src/widgets/DocumentReview/entryTree.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import type { EntryOut } from '../../types/api'; +import { buildEntryTree } from './entryTree'; + +const e = (id: string, parent: string | null, row: number): EntryOut => ({ + id, + row_number: row, + level: null, + parent_entry_id: parent, + row_value: id, + group_name: null, + account_code: null, + counterparty_name: null, + counterparty_inn: null, + opening_debit: null, + opening_credit: null, + turnover_debit: null, + turnover_credit: null, + closing_debit: null, + closing_credit: null, +}); + +describe('buildEntryTree', () => { + it('вкладывает детей и сохраняет порядок', () => { + const tree = buildEntryTree([e('60', null, 1), e('60.01', '60', 2), e('ООО А', '60.01', 3), e('62', null, 4)]); + expect(tree.map((n) => n.id)).toEqual(['60', '62']); + expect(tree[0].children?.[0].id).toBe('60.01'); + expect(tree[0].children?.[0].children?.[0].id).toBe('ООО А'); + expect(tree[1].children).toBeUndefined(); + }); + it('ребёнок с неизвестным родителем — в корень', () => { + expect(buildEntryTree([e('x', 'missing', 1)]).map((n) => n.id)).toEqual(['x']); + }); +}); diff --git a/src/widgets/DocumentReview/entryTree.ts b/src/widgets/DocumentReview/entryTree.ts new file mode 100644 index 0000000..5e3c68e --- /dev/null +++ b/src/widgets/DocumentReview/entryTree.ts @@ -0,0 +1,20 @@ +import type { EntryOut } from '../../types/api'; + +export type EntryNode = EntryOut & { key: string; children?: EntryNode[] }; + +/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number). */ +export function buildEntryTree(entries: EntryOut[]): EntryNode[] { + const nodes = new Map(); + for (const entry of entries) nodes.set(entry.id, { ...entry, key: entry.id }); + const roots: EntryNode[] = []; + for (const entry of entries) { + const node = nodes.get(entry.id) as EntryNode; + const parent = entry.parent_entry_id ? nodes.get(entry.parent_entry_id) : undefined; + if (parent) { + parent.children = [...(parent.children ?? []), node]; + } else { + roots.push(node); + } + } + return roots; +} diff --git a/src/widgets/DocumentReview/viewKind.test.ts b/src/widgets/DocumentReview/viewKind.test.ts new file mode 100644 index 0000000..24656d4 --- /dev/null +++ b/src/widgets/DocumentReview/viewKind.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { documentViewKind } from './viewKind'; + +describe('documentViewKind', () => { + it('формы', () => { + expect(documentViewKind({ document_type: 'BALANCE' })).toBe('statement'); + expect(documentViewKind({ document_type: 'INCOME' })).toBe('statement'); + }); + it('ОСВ', () => { + expect(documentViewKind({ document_type: 'OSV' })).toBe('osv'); + }); + it('без данных', () => { + expect(documentViewKind({ document_type: 'DISCLOSURE' })).toBe('none'); + expect(documentViewKind({ document_type: 'OTHER' })).toBe('none'); + }); +}); diff --git a/src/widgets/DocumentReview/viewKind.ts b/src/widgets/DocumentReview/viewKind.ts new file mode 100644 index 0000000..1b61805 --- /dev/null +++ b/src/widgets/DocumentReview/viewKind.ts @@ -0,0 +1,7 @@ +import type { DocumentOut } from '../../types/api'; + +export function documentViewKind(doc: Pick): 'statement' | 'osv' | 'none' { + if (doc.document_type === 'BALANCE' || doc.document_type === 'INCOME') return 'statement'; + if (doc.document_type === 'OSV') return 'osv'; + return 'none'; +}