feat: экран документа и просмотр ОСВ-документа с подтверждением (#3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 01:16:37 +04:00
parent 64d64d3b0a
commit 5d1c28d0de
12 changed files with 309 additions and 2 deletions

View File

@ -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() {
<Route path='/catalogue/groups' element={<CatalogueGroup />} />
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
<Route path='/docstorage' element={<DocStorage />} />
<Route path='/documents/:document_id' element={<DocumentPage />} />
<Route path='/logging' element={<AuditLogging />} />
</Routes>
</Layout.Content>

View File

@ -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) {

View File

@ -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<DocumentOut | null>(null);
const [entityName, setEntityName] = useState('');
const [error, setError] = useState<string | null>(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 <Result status='warning' title={error} extra={<Button onClick={() => navigate(-1)}>Назад</Button>} />;
}
if (!doc) {
return <Spin style={{ margin: 48 }} />;
}
const kind = documentViewKind(doc);
// Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран.
const broken = doc.status === 'ERROR' && kind !== 'none';
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={`Проверка документа${entityName ? ` › ${entityName}` : ''}`}
header={`${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}`}
description={<Tag>{documentStatusLabel(doc.status)}</Tag>}
/>
<main style={{ flex: '1 1 auto', minHeight: 0, background: 'white', borderRadius: 12, padding: 12, overflow: 'auto' }}>
{broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />}
{!broken && kind === 'osv' && <OsvDocument document={doc} onChanged={load} />}
{!broken && kind === 'statement' && <Empty description='Проверка формы — в следующей задаче' />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main>
</div>
);
}

View File

@ -8,3 +8,4 @@ export { CatalogueGroup } from './CatalogueGroups';
export { GroupsLayout } from './Groups';
export { AuditLogging } from './AuditLogging';
export { Admin } from './Admin';
export { DocumentPage } from './DocumentPage';

View File

@ -34,3 +34,5 @@ export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntit
}
}
}
export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`);

View File

@ -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<EntryNode> = [
{
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<EntryOut[]>([]);
const [loading, setLoading] = useState(true);
const [confirming, setConfirming] = useState(false);
const [problem, setProblem] = useState<ConfirmProblem | null>(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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0 }}>
<div>
<Button type='primary' disabled={!canConfirm} loading={confirming} onClick={confirm}>
Подтвердить
</Button>
<span style={{ marginLeft: 12, color: '#667085' }}>Суммы в рублях; строки ОСВ только для чтения</span>
</div>
{problem && (
<Alert
type='error'
showIcon
closable
onClose={() => setProblem(null)}
message={problem.code ? `${problem.message} (${problem.code})` : problem.message}
description={
problem.failures.length > 0 ? (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{problem.failures.map((f) => (
<li key={`${f.column_index}-${f.relation}`}>
{f.relation}: ожидалось {formatAmount(f.expected)}, получено {formatAmount(f.actual)}
</li>
))}
</ul>
) : undefined
}
/>
)}
<Table<EntryNode>
size='small'
rowKey='key'
loading={loading}
columns={columns}
dataSource={tree}
pagination={false}
scroll={{ y: 'calc(100vh - 260px)', x: 1300 }}
/>
</div>
);
}

View File

@ -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: [] });
});
});

View File

@ -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 };
}

View File

@ -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']);
});
});

View File

@ -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<string, EntryNode>();
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;
}

View File

@ -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');
});
});

View File

@ -0,0 +1,7 @@
import type { DocumentOut } from '../../types/api';
export function documentViewKind(doc: Pick<DocumentOut, 'document_type'>): 'statement' | 'osv' | 'none' {
if (doc.document_type === 'BALANCE' || doc.document_type === 'INCOME') return 'statement';
if (doc.document_type === 'OSV') return 'osv';
return 'none';
}