arm-web/src/widgets/DocumentReview/OsvDocument.tsx
Andrey Epifancev c3fcac2efd feat(osv): «Просмотр ОСВ» — плоская таблица вместо дерева (#12)
- Строка = аналитика нижнего уровня (контрагент, объект ОС); строки счетов и
  субсчетов не показываются, договоры (CONTRACT) — в строке контрагента, как в
  сводной ОСВ (§3); субсчёт/подразделение без нижних строк с суммами — строкой с
  пустой аналитикой.
- Колонки: Субсчёт · Подразделение (если уровень есть в файле) · Контрагент /
  аналитика · ИНН · 6 сумм; закреплённая строка «Итого по документу» = сумма
  корневых строк счёта (= «Итого» файла 1С).
- Поиск по контрагенту/ИНН, фильтр по субсчёту, «Строк: N» — на клиенте.
- flattenEntries (osvRows.ts) и точное sumAmounts (копейками, BigInt) с тестами;
  EntryOut.group_type — как в схеме arm-core; entryTree удалён.
- Пустой документ: «В документе нет строк» вместо «No data».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:22:16 +04:00

175 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CheckOutlined, SearchOutlined } from '@ant-design/icons';
import { Alert, Button, Input, Select, 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 { confirmButtonStyle } from './ReviewCard';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
const money = (key: AmountKey, title: string) => ({
key,
title,
dataIndex: key,
align: 'right' as const,
width: 140,
render: (v: string | null) => formatAmount(v, { zeroDash: true }),
});
const group = (title: string, children: ColumnsType<OsvRow>): ColumnsType<OsvRow>[number] => ({ title, align: 'center', children });
function buildColumns(hasDepartments: boolean): ColumnsType<OsvRow> {
return [
{ key: 'account', title: 'Субсчёт', dataIndex: 'account', width: 100, fixed: 'left' },
...(hasDepartments ? [{ key: 'department', title: 'Подразделение', dataIndex: 'department', width: 160 }] : []),
{ key: 'analytics', title: 'Контрагент / аналитика', dataIndex: 'analytics', width: 320, render: (v: string | null) => v ?? '—' },
{ key: 'inn', title: 'ИНН', dataIndex: 'inn', width: 120 },
group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]),
group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]),
];
}
/** Поиск по контрагенту/ИНН без учёта регистра и фильтр по субсчёту — на клиенте: строки документа уже загружены целиком. */
function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] {
const q = query.trim().toLowerCase();
return rows.filter(
(row) =>
(!account || row.account === account) && (!q || (row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)),
);
}
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 [query, setQuery] = useState('');
const [account, setAccount] = useState<string | undefined>();
const table = useMemo(() => flattenEntries(entries), [entries]);
const columns = useMemo(() => buildColumns(table.hasDepartments), [table.hasDepartments]);
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
// колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу»
const labelSpan = table.hasDepartments ? 4 : 3;
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'
icon={<CheckOutlined />}
disabled={!canConfirm}
loading={confirming}
onClick={confirm}
style={canConfirm ? confirmButtonStyle : undefined}>
Подтвердить
</Button>
<span style={{ marginLeft: 12, color: '#667085' }}>Суммы в рублях; строки ОСВ только для чтения</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder='Контрагент или ИНН'
style={{ width: 320 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Select
allowClear
placeholder='Субсчёт'
style={{ width: 160 }}
value={account}
onChange={setAccount}
options={table.accounts.map((code) => ({ value: code, label: code }))}
/>
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
</div>
{problem && (
<Alert
type='error'
showIcon
closable
onClose={() => setProblem(null)}
title={<span title={problem.code}>{problem.message}</span>}
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<OsvRow>
bordered
size='small'
rowKey='key'
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }}
summary={() =>
entries.length > 0 && (
<Table.Summary fixed>
<Table.Summary.Row style={{ fontWeight: 600, background: '#fafafa' }}>
<Table.Summary.Cell index={0} colSpan={labelSpan}>
Итого по документу
</Table.Summary.Cell>
{AMOUNT_KEYS.map((key, i) => (
<Table.Summary.Cell key={key} index={labelSpan + i} align='right'>
{formatAmount(table.total[key], { zeroDash: true })}
</Table.Summary.Cell>
))}
</Table.Summary.Row>
</Table.Summary>
)
}
/>
</div>
);
}