diff --git a/src/types/api.ts b/src/types/api.ts index 380fc5b..922088f 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -73,12 +73,16 @@ export interface DocumentOut extends DocumentBrief { confirmed_at: string | null; warnings: string[]; } +/** Уровень строки ОСВ (DBML `osv_group_type`). */ +export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER'; + export interface EntryOut { id: string; row_number: number | null; level: number | null; parent_entry_id: string | null; row_value: string | null; + group_type: OsvGroupType | null; group_name: string | null; account_code: string | null; counterparty_name: string | null; diff --git a/src/utils/money.test.ts b/src/utils/money.test.ts index a50cce1..274647c 100644 --- a/src/utils/money.test.ts +++ b/src/utils/money.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, parseAmountInput } from './money'; +import { formatAmount, parseAmountInput, sumAmounts } from './money'; const NBSP = ' '; @@ -55,3 +55,23 @@ describe('parseAmountInput', () => { expect(parseAmountInput('abc')).toEqual({ ok: false }); }); }); + +describe('sumAmounts', () => { + it('складывает точно, без float', () => { + expect(sumAmounts(['0.1', '0.2'])).toBe('0.30'); + expect(sumAmounts(['613926948.12', '2555438965.10'])).toBe('3169365913.22'); + }); + it('отрицательные и разное число знаков', () => { + expect(sumAmounts(['-8213.34', '100', '0.5'])).toBe('-8112.84'); + expect(sumAmounts(['-0.5', '0.2'])).toBe('-0.30'); + }); + it('пустые значения пропускает; все пустые — null', () => { + expect(sumAmounts([null, '5', undefined, ''])).toBe('5.00'); + expect(sumAmounts([null, null])).toBeNull(); + expect(sumAmounts([])).toBeNull(); + }); + it('нераспознанная сумма — null (итог не выдумываем)', () => { + expect(sumAmounts(['1', 'abc'])).toBeNull(); + expect(sumAmounts(['1.234'])).toBeNull(); + }); +}); diff --git a/src/utils/money.ts b/src/utils/money.ts index 954b116..6c42aaf 100644 --- a/src/utils/money.ts +++ b/src/utils/money.ts @@ -1,6 +1,7 @@ const NBSP = ' '; const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/; const INPUT = /^-?\d+(\.\d{1,2})?$/; +const MONEY = /^(-?)(\d+)(?:\.(\d{1,2}))?$/; export type AmountFormat = { /** Ноль показывать прочерком (свод, ОСВ — как в макете [30]). */ @@ -45,3 +46,23 @@ export function parseAmountInput(input: string): { ok: true; value: string | nul } return INPUT.test(normalized) ? { ok: true, value: normalized } : { ok: false }; } + +/** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null. + * Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */ +export function sumAmounts(values: ReadonlyArray): string | null { + let cents = 0n; + let any = false; + for (const value of values) { + if (value === null || value === undefined || value === '') continue; + const match = MONEY.exec(value); + if (!match) return null; + const [, sign, intPart, frac = ''] = match; + const amount = BigInt(intPart) * 100n + BigInt(frac.padEnd(2, '0')); + cents += sign ? -amount : amount; + any = true; + } + if (!any) return null; + const negative = cents < 0n; + const abs = negative ? -cents : cents; + return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; +} diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index 4e0964f..bb0636b 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -1,5 +1,5 @@ -import { CheckOutlined } from '@ant-design/icons'; -import { Alert, Button, Table, message } from 'antd'; +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'; @@ -8,9 +8,9 @@ import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api'; import { formatAmount } from '../../utils/money'; import { confirmButtonStyle } from './ReviewCard'; import { confirmErrorInfo } from './confirmError'; -import { type EntryNode, buildEntryTree } from './entryTree'; +import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows'; -const money = (key: keyof EntryOut, title: string) => ({ +const money = (key: AmountKey, title: string) => ({ key, title, dataIndex: key, @@ -19,21 +19,28 @@ const money = (key: keyof EntryOut, title: string) => ({ render: (v: string | null) => formatAmount(v, { zeroDash: true }), }); -const columns: ColumnsType = [ - { - 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', 'Сальдо кон. Кт'), -]; +const group = (title: string, children: ColumnsType): ColumnsType[number] => ({ title, align: 'center', children }); + +function buildColumns(hasDepartments: boolean): ColumnsType { + 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; @@ -59,7 +66,13 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on }; }, [document.id]); - const tree = useMemo(() => buildEntryTree(entries), [entries]); + const [query, setQuery] = useState(''); + const [account, setAccount] = useState(); + 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 () => { @@ -90,6 +103,25 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on Суммы в рублях; строки ОСВ только для чтения +
+ } + placeholder='Контрагент или ИНН' + style={{ width: 320 }} + value={query} + onChange={(e) => setQuery(e.target.value)} + /> +