feat: проверка формы ББ/ОФР — скан PDF, правка ячеек и шапки, проверки, подтверждение (#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:23:57 +04:00
parent edaa2b6d58
commit 615201c925
8 changed files with 519 additions and 14 deletions

View File

@ -8,6 +8,7 @@ import { getDocument } from '../services/api/documents';
import type { DocumentOut } from '../types/api'; import type { DocumentOut } from '../types/api';
import { documentStatusLabel, documentTypeLabel, periodLabel } from '../utils/labels'; import { documentStatusLabel, documentTypeLabel, periodLabel } from '../utils/labels';
import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument';
import { StatementReview } from '../widgets/DocumentReview/StatementReview';
import { documentViewKind } from '../widgets/DocumentReview/viewKind'; import { documentViewKind } from '../widgets/DocumentReview/viewKind';
export function DocumentPage() { export function DocumentPage() {
@ -55,7 +56,7 @@ export function DocumentPage() {
<main style={{ flex: '1 1 auto', minHeight: 0, background: 'white', borderRadius: 12, padding: 12, overflow: 'auto' }}> <main style={{ flex: '1 1 auto', minHeight: 0, background: 'white', borderRadius: 12, padding: 12, overflow: 'auto' }}>
{broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />} {broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />}
{!broken && kind === 'osv' && <OsvDocument key={doc.id} document={doc} onChanged={load} />} {!broken && kind === 'osv' && <OsvDocument key={doc.id} document={doc} onChanged={load} />}
{!broken && kind === 'statement' && <Empty description='Проверка формы — в следующей задаче' />} {!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />} {kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main> </main>
</div> </div>

View File

@ -0,0 +1,74 @@
import { Alert, Button, Input, Modal, Space, Typography } from 'antd';
import { useState } from 'react';
import type { ChecksOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
export const failureText = (f: FailedCheckOut) =>
`Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`;
type Props = { checks: ChecksOut; editable: boolean; onAccept: (comment: string) => Promise<void> };
const failureList = (list: FailedCheckOut[]) => list.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
export function ChecksPanel({ checks, editable, onAccept }: Props) {
const [open, setOpen] = useState(false);
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const canAccept = editable && checks.failed.length > 0 && checks.accepted === null;
const submit = async () => {
setSaving(true);
try {
await onAccept(comment.trim());
setOpen(false);
setComment('');
} catch {
// ошибку уже показал вызывающий; окно остаётся открытым, чтобы не потерять комментарий
} finally {
setSaving(false);
}
};
return (
<Space direction='vertical' style={{ width: '100%' }}>
{checks.failed.length === 0 && checks.soft_failed.length === 0 && !checks.cross_form && (
<Alert type='success' showIcon message='Проверки пройдены' />
)}
{checks.failed.length > 0 && (
<Alert type='error' showIcon message={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed)} />
)}
{checks.soft_failed.length > 0 && (
<Alert type='warning' showIcon message='Расхождения в пределах округления' description={failureList(checks.soft_failed)} />
)}
{checks.cross_form && (
<Alert
type='warning'
showIcon
message='Межформенная проверка ББ↔ОФР'
description={`${checks.cross_form.relation}: ожидалось ${formatAmount(checks.cross_form.expected)}, получено ${formatAmount(checks.cross_form.actual)}`}
/>
)}
{checks.accepted && (
<Alert
type='info'
showIcon
message='Нарушения приняты'
description={`${new Date(checks.accepted.at).toLocaleString('ru-RU')}: ${checks.accepted.comment}`}
/>
)}
{canAccept && <Button onClick={() => setOpen(true)}>Принять с комментарием</Button>}
<Modal
open={open}
title='Принять нарушения'
okText='Принять'
cancelText='Отмена'
confirmLoading={saving}
okButtonProps={{ disabled: comment.trim().length === 0 }}
onOk={submit}
onCancel={() => setOpen(false)}>
<Typography.Paragraph>Комментарий обязателен (до 2000 символов).</Typography.Paragraph>
<Input.TextArea rows={4} maxLength={2000} value={comment} onChange={(e) => setComment(e.target.value)} />
</Modal>
</Space>
);
}

View File

@ -1,15 +1,17 @@
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs?url', import.meta.url).toString();
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Config } from '../../conf/config';
// worker собирается вместе с приложением (без CDN)
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const MIN_ZOOM = 0.5; const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3; const MAX_ZOOM = 3;
const ZOOM_STEP = 0.25; const ZOOM_STEP = 0.25;
const PAGE_MARGIN = 10; // left + right margin of each page wrapper const PAGE_MARGIN = 10; // left + right margin of each page wrapper
export default function PdfJsBlock({ fileId }: { fileId: string }) { export default function PdfPages({ data }: { data: ArrayBuffer }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null); const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
@ -17,14 +19,14 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
const [zoom, setZoom] = useState(1); // 1 = fit wrapper width const [zoom, setZoom] = useState(1); // 1 = fit wrapper width
const [baseScale, setBaseScale] = useState(1); const [baseScale, setBaseScale] = useState(1);
const [pageWidthPt, setPageWidthPt] = useState(0); const [pageWidthPt, setPageWidthPt] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let pdf: PDFDocumentProxy | null = null; let pdf: PDFDocumentProxy | null = null;
let cancelled = false; let cancelled = false;
async function load() { async function load() {
const res = await fetch(`${Config.API_URL}/files/${fileId}`); // pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
const buffer = await res.arrayBuffer(); pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise;
pdf = await pdfjsLib.getDocument({ data: buffer }).promise;
if (cancelled) { if (cancelled) {
pdf.destroy(); pdf.destroy();
return; return;
@ -35,12 +37,16 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
setPdfDoc(pdf); setPdfDoc(pdf);
setNumPages(pdf.numPages); setNumPages(pdf.numPages);
} }
load(); load().catch(() => {
if (!cancelled) {
setError('Не удалось открыть PDF');
}
});
return () => { return () => {
cancelled = true; cancelled = true;
if (pdf) pdf.destroy(); if (pdf) pdf.destroy();
}; };
}, [fileId]); }, [data]);
// fit scale = wrapper inner width / page width. // fit scale = wrapper inner width / page width.
// wrapper width = container client width - container padding - page margins // wrapper width = container client width - container padding - page margins
@ -108,6 +114,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
const zoomOut = useCallback(() => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2))), []); const zoomOut = useCallback(() => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2))), []);
const resetZoom = useCallback(() => setZoom(1), []); const resetZoom = useCallback(() => setZoom(1), []);
if (error) return <div>{error}</div>;
if (!pdfDoc) return <div>Загрузка PDF...</div>; if (!pdfDoc) return <div>Загрузка PDF...</div>;
return ( return (
@ -119,7 +126,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
minWidth: 0, minWidth: 0,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>Source FILE — {numPages} pages</span> <span style={{ fontWeight: 600 }}>Исходный файл — страниц: {numPages}</span>
<span <span
style={{ style={{
marginLeft: 'auto', marginLeft: 'auto',
@ -127,14 +134,16 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
gap: 8, gap: 8,
alignItems: 'center', alignItems: 'center',
}}> }}>
<button onClick={zoomOut} disabled={zoom <= MIN_ZOOM}> <button type='button' onClick={zoomOut} disabled={zoom <= MIN_ZOOM}>
- -
</button> </button>
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span> <span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
<button onClick={zoomIn} disabled={zoom >= MAX_ZOOM}> <button type='button' onClick={zoomIn} disabled={zoom >= MAX_ZOOM}>
+ +
</button> </button>
<button onClick={resetZoom}>Fit</button> <button type='button' onClick={resetZoom}>
По ширине
</button>
</span> </span>
</div> </div>
@ -182,7 +191,7 @@ export default function PdfJsBlock({ fileId }: { fileId: string }) {
background: '#f1f1f1', background: '#f1f1f1',
borderBottom: '1px solid #ddd', borderBottom: '1px solid #ddd',
}}> }}>
Page {i + 1} of {numPages} Страница {i + 1} из {numPages}
</div> </div>
<canvas <canvas
ref={(el) => { ref={(el) => {

View File

@ -0,0 +1,66 @@
import { Button, Empty, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchFileContent } from '../../services/api/files';
import { saveBlob } from '../../utils/download';
import PdfPages from './PdfPages';
type Loaded = { kind: 'pdf'; data: ArrayBuffer } | { kind: 'image'; url: string } | { kind: 'other'; blob: Blob; filename: string };
export function SourcePreview({ fileId }: { fileId: string }) {
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
setLoaded(null);
setError(null);
fetchFileContent(fileId)
.then(async ({ blob, filename }) => {
if (cancelled) {
return;
}
if (blob.type === 'application/pdf') {
const data = await blob.arrayBuffer();
if (!cancelled) {
setLoaded({ kind: 'pdf', data });
}
} else if (blob.type.startsWith('image/')) {
objectUrl = URL.createObjectURL(blob);
setLoaded({ kind: 'image', url: objectUrl });
} else {
setLoaded({ kind: 'other', blob, filename: filename ?? 'исходный_файл' });
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiError ? e.message : 'Не удалось получить исходный файл');
}
});
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [fileId]);
if (error) {
return <Empty description={error} />;
}
if (!loaded) {
return <Spin style={{ margin: 48 }} />;
}
if (loaded.kind === 'pdf') {
return <PdfPages data={loaded.data} />;
}
if (loaded.kind === 'image') {
return <img src={loaded.url} alt='Исходный документ' style={{ width: '100%' }} />;
}
return (
<Empty description='Превью недоступно для этого формата'>
<Button onClick={() => saveBlob(loaded.blob, loaded.filename)}>Скачать исходник</Button>
</Empty>
);
}

View File

@ -0,0 +1,179 @@
import { Alert, Button, Descriptions, Input, Space, Splitter, message } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { SourcePreview } from './SourcePreview';
import { StatementTable } from './StatementTable';
import { confirmErrorInfo } from './confirmError';
import { buildStatementGrid, draftToPatch } from './statementGrid';
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
type HeaderDraft = { inn?: string; report_date?: string; units?: string };
export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [statement, setStatement] = useState<StatementOut | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const [header, setHeader] = useState<HeaderDraft>({});
const [saving, setSaving] = useState(false);
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setStatement(await getStatement(document.id));
setLoadError(null);
} catch (e: unknown) {
setLoadError(errorText(e, 'Не удалось получить форму'));
}
}, [document.id]);
useEffect(() => {
void load();
}, [load]);
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
const editable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0;
const save = async () => {
const values: Record<string, string | null> = {};
for (const [key, text] of Object.entries(draft)) {
const parsed = parseAmountInput(text);
if (!parsed.ok) {
message.error('Исправьте суммы с неверным форматом');
return;
}
values[key] = parsed.value;
}
const body: StatementPatch = {};
if (Object.keys(values).length > 0) {
body.values = draftToPatch(values);
}
const headerPatch: HeaderDraft = {};
for (const field of ['inn', 'report_date', 'units'] as const) {
const text = header[field]?.trim();
if (text) {
headerPatch[field] = text;
}
}
if (Object.keys(headerPatch).length > 0) {
body.header = headerPatch;
}
if (!body.values && !body.header) {
setDraft({});
setHeader({});
return;
}
setSaving(true);
try {
// ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт
setStatement(await patchStatement(document.id, body));
setDraft({});
setHeader({});
setConfirmFailures([]);
setConfirmMessage(null);
message.success('Изменения сохранены');
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось сохранить изменения'));
} finally {
setSaving(false);
}
};
const accept = async (comment: string) => {
try {
setStatement(await acceptChecks(document.id, comment));
setConfirmFailures([]);
setConfirmMessage(null);
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось принять нарушения'));
throw e;
}
};
const confirm = async () => {
try {
await confirmDocument(document.id);
message.success('Документ подтверждён');
setConfirmFailures([]);
setConfirmMessage(null);
onChanged();
await load();
} catch (e: unknown) {
const info = confirmErrorInfo(e);
setConfirmFailures(info.failures);
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
}
};
if (loadError) {
return <Alert type='error' showIcon message={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />;
}
if (!statement || !grid) {
return null;
}
const h = statement.header;
const headerInput = (field: 'inn' | 'report_date' | 'units', placeholder: string) => (
<Input
size='small'
disabled={!editable}
placeholder={placeholder}
value={header[field] ?? h[field].value ?? ''}
onChange={(e) => setHeader((prev) => ({ ...prev, [field]: e.target.value }))}
/>
);
return (
<Splitter style={{ height: '100%' }}>
<Splitter.Panel defaultSize='45%' min='25%'>
<SourcePreview fileId={statement.document.file_id} />
</Splitter.Panel>
<Splitter.Panel>
<Space direction='vertical' style={{ width: '100%', padding: '0 12px' }}>
<Descriptions size='small' column={2} bordered>
<Descriptions.Item label='Организация'>{h.organization_name ?? '—'}</Descriptions.Item>
<Descriptions.Item label='ИНН'>{headerInput('inn', 'ИНН')}</Descriptions.Item>
<Descriptions.Item label='Дата отчёта'>{headerInput('report_date', 'ГГГГ-ММ-ДД')}</Descriptions.Item>
<Descriptions.Item label='Единицы'>{headerInput('units', 'тыс. руб.')}</Descriptions.Item>
<Descriptions.Item label='ОКУД'>{h.okud ?? '—'}</Descriptions.Item>
<Descriptions.Item label='ОКПО'>{h.okpo ?? '—'}</Descriptions.Item>
</Descriptions>
<ChecksPanel checks={statement.checks} editable={editable} onAccept={accept} />
{confirmMessage && (
<Alert
type='error'
showIcon
message={confirmMessage}
description={
confirmFailures.length > 0
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
: undefined
}
/>
)}
<Space>
<Button disabled={!editable || !dirty} loading={saving} onClick={save}>
Сохранить
</Button>
<Button type='primary' disabled={!editable || dirty} onClick={confirm}>
Подтвердить данные
</Button>
<span style={{ color: '#667085' }}>Суммы в: {h.units.value ?? 'единицы не указаны'}</span>
</Space>
<StatementTable
columns={grid.columns}
rows={grid.rows}
editable={editable}
draft={draft}
onDraftChange={(key, text) => setDraft((prev) => ({ ...prev, [key]: text }))}
/>
</Space>
</Splitter.Panel>
</Splitter>
);
}

View File

@ -0,0 +1,81 @@
import { Input, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { type GridRow, cellFlags, cellKey } from './statementGrid';
type Props = {
columns: ColumnOut[];
rows: GridRow[];
editable: boolean;
draft: Record<string, string>;
onDraftChange: (key: string, text: string) => void;
};
const ATTENTION_BG = '#fff7e6';
const MODIFIED_BG = '#e6f4ff';
function cellBackground(value: ValueOut | undefined): string | undefined {
const flags = cellFlags(value);
if (flags.modified) {
return MODIFIED_BG;
}
return flags.attention ? ATTENTION_BG : undefined;
}
function cellTip(value: ValueOut): string | undefined {
if (value.is_modified) {
return `Исходное: ${formatAmount(value.original_amount)}`;
}
return value.confidence ? `Уверенность: ${value.confidence}%` : undefined;
}
export function StatementTable({ columns, rows, editable, draft, onDraftChange }: Props) {
const tableColumns: ColumnsType<GridRow> = [
{ key: 'name', title: 'Показатель', dataIndex: 'name', width: 320 },
{ key: 'code', title: 'Код', dataIndex: 'code', width: 70 },
...columns.map((col) => ({
key: col.id,
title: col.label ?? `Колонка ${col.column_index + 1}`,
width: 160,
align: 'right' as const,
onCell: (row: GridRow) => {
return { style: { background: cellBackground(row.cells[col.id]) } };
},
render: (_: unknown, row: GridRow) => {
const value = row.cells[col.id];
if (!value) {
return null;
}
const key = cellKey(row.lineId, col.id);
const tip = cellTip(value);
if (!editable) {
return <Tooltip title={tip}>{formatAmount(value.amount)}</Tooltip>;
}
const text = draft[key] ?? value.amount ?? '';
const invalid = key in draft && !parseAmountInput(text).ok;
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<Input
size='small'
status={invalid ? 'error' : undefined}
value={text}
onChange={(e) => onDraftChange(key, e.target.value)}
style={{ textAlign: 'right' }}
/>
</Tooltip>
);
},
})),
];
return (
<Table<GridRow>
size='small'
rowKey='key'
columns={tableColumns}
dataSource={rows}
pagination={false}
scroll={{ y: 'calc(100vh - 360px)' }}
/>
);
}

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import { buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
const v = (id: string, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
id,
column_id,
column_index,
amount: '1.00',
string_value: null,
confidence: '100',
is_recognized: true,
is_modified: false,
original_amount: null,
...extra,
});
const statement = {
columns: [
{ id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false },
{ id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true },
],
lines: [{ id: 'l1', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }],
} as unknown as StatementOut;
describe('buildStatementGrid', () => {
it('колонки по column_index, ячейки по column_id', () => {
const grid = buildStatementGrid(statement);
expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']);
expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс' });
expect(grid.rows[0].cells.c1?.id).toBe('v1');
expect(grid.rows[0].cells.c2?.id).toBe('v2');
});
});
describe('cellFlags', () => {
it('внимание: уверенность < 100 или не распознано', () => {
expect(cellFlags(v('a', 'c', 0, { confidence: '87.5' })).attention).toBe(true);
expect(cellFlags(v('a', 'c', 0, { is_recognized: false })).attention).toBe(true);
expect(cellFlags(v('a', 'c', 0)).attention).toBe(false);
expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false);
});
it('исправлено', () => {
expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true);
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false });
});
});
describe('draftToPatch', () => {
it('черновик → values', () => {
expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('l2', 'c1')]: null })).toEqual([
{ line_id: 'l1', column_id: 'c1', amount: '10.5' },
{ line_id: 'l2', column_id: 'c1', amount: null },
]);
});
});

View File

@ -0,0 +1,39 @@
import type { ColumnOut, StatementOut, StatementPatch, ValueOut } from '../../types/api';
export type GridRow = {
key: string;
lineId: string;
code: string | null;
name: string | null;
cells: Record<string, ValueOut | undefined>;
};
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
const rows = statement.lines.map((line) => {
const cells: Record<string, ValueOut | undefined> = {};
for (const value of line.values) {
cells[value.column_id] = value;
}
return { key: line.id, lineId: line.id, code: line.line_code, name: line.line_name, cells };
});
return { columns, rows };
}
/** Уверенность — строка Decimal «0..100»; сравнение с 100 — это не сумма, float здесь безопасен. */
export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } {
if (!value) {
return { attention: false, modified: false };
}
const lowConfidence = value.confidence !== null && Number(value.confidence) < 100;
return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified };
}
export const cellKey = (lineId: string, columnId: string) => `${lineId}:${columnId}`;
export function draftToPatch(draft: Record<string, string | null>): NonNullable<StatementPatch['values']> {
return Object.entries(draft).map(([key, amount]) => {
const [line_id, column_id] = key.split(':');
return { line_id, column_id, amount };
});
}