+
+
+ Суммы в рублях; строки ОСВ только для чтения
+
+ {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';
+}