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:
parent
64d64d3b0a
commit
5d1c28d0de
14
src/App.tsx
14
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() {
|
||||
<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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
63
src/layouts/DocumentPage.tsx
Normal file
63
src/layouts/DocumentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -8,3 +8,4 @@ export { CatalogueGroup } from './CatalogueGroups';
|
||||
export { GroupsLayout } from './Groups';
|
||||
export { AuditLogging } from './AuditLogging';
|
||||
export { Admin } from './Admin';
|
||||
export { DocumentPage } from './DocumentPage';
|
||||
|
||||
@ -34,3 +34,5 @@ export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`);
|
||||
|
||||
116
src/widgets/DocumentReview/OsvDocument.tsx
Normal file
116
src/widgets/DocumentReview/OsvDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/widgets/DocumentReview/confirmError.test.ts
Normal file
23
src/widgets/DocumentReview/confirmError.test.ts
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
12
src/widgets/DocumentReview/confirmError.ts
Normal file
12
src/widgets/DocumentReview/confirmError.ts
Normal 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 };
|
||||
}
|
||||
34
src/widgets/DocumentReview/entryTree.test.ts
Normal file
34
src/widgets/DocumentReview/entryTree.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
20
src/widgets/DocumentReview/entryTree.ts
Normal file
20
src/widgets/DocumentReview/entryTree.ts
Normal 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;
|
||||
}
|
||||
16
src/widgets/DocumentReview/viewKind.test.ts
Normal file
16
src/widgets/DocumentReview/viewKind.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
7
src/widgets/DocumentReview/viewKind.ts
Normal file
7
src/widgets/DocumentReview/viewKind.ts
Normal 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';
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user