From 94cb0a4d7472ec7000eb2d39f8a58596aea51633 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 12:04:17 +0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(DocumentReview):=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B2=D0=B8=D0=B4=D1=83,=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=85=20(#39,=20AURORA-1666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заголовок колонки считается по видам строк-листьев (osvRows.analyticsHeading): «Контрагент», название вида из файла или «Аналитика» с подсказкой. Колонка ИНН и подсказка поиска — только при контрагентах; labelSpan и ширина scroll.x зависят от числа колонок. Требование — в specs. Co-Authored-By: Claude Opus 5.5 --- openspec/specs/document-review-ui/spec.md | 9 ++++ src/widgets/DocumentReview/OsvDocument.tsx | 47 ++++++++++++++++----- src/widgets/DocumentReview/osvRows.test.ts | 49 ++++++++++++++++++++++ src/widgets/DocumentReview/osvRows.ts | 37 +++++++++++++++- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/openspec/specs/document-review-ui/spec.md b/openspec/specs/document-review-ui/spec.md index 4191a47..7f5475f 100644 --- a/openspec/specs/document-review-ui/spec.md +++ b/openspec/specs/document-review-ui/spec.md @@ -75,6 +75,15 @@ column_id, amount}`, ячейка достроенной строки (`id = nul Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) деревом по `parent_entry_id` с колонками: счёт/субсчёт, контрагент, ИНН, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм. +Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения не +учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name` +(«Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке +заголовка. Колонка «ИНН» и подсказка поиска «Контрагент или ИНН» SHALL показываться только при наличии контрагентов +(`COUNTERPARTY`); иначе колонки ИНН нет, а поиск подписан «Поиск по аналитике». + +#### Scenario: ОСВ по основным средствам +- **WHEN** листья документа — аналитика с `group_name` «Основные средства», контрагентов нет +- **THEN** колонка называется «Основные средства», колонки ИНН нет, поиск подписан «Поиск по аналитике» #### Scenario: Подтверждение ОСВ - **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK` diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index bb0636b..2b4df86 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -1,5 +1,5 @@ import { CheckOutlined, SearchOutlined } from '@ant-design/icons'; -import { Alert, Button, Input, Select, Table, message } from 'antd'; +import { Alert, Button, Input, Select, Table, Tooltip, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useEffect, useMemo, useState } from 'react'; import { ApiError } from '../../api/client'; @@ -21,19 +21,37 @@ const money = (key: AmountKey, title: string) => ({ const group = (title: string, children: ColumnsType): ColumnsType[number] => ({ title, align: 'center', children }); -function buildColumns(hasDepartments: boolean): ColumnsType { +interface ColumnsOptions { + hasDepartments: boolean; + hasCounterparties: boolean; + analyticsTitle: string; + analyticsHint: string | null; +} + +/** Ширина колонок до сумм (Субсчёт, [Подразделение], аналитика, [ИНН]) и всей таблицы с шестью суммами по 140. */ +const widthBeforeAmounts = ({ hasDepartments, hasCounterparties }: ColumnsOptions) => + 100 + (hasDepartments ? 160 : 0) + 320 + (hasCounterparties ? 120 : 0); + +function buildColumns(options: ColumnsOptions): ColumnsType { + const { hasDepartments, hasCounterparties, analyticsTitle, analyticsHint } = options; 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 }, + { + key: 'analytics', + title: analyticsHint ? {analyticsTitle} : analyticsTitle, + dataIndex: 'analytics', + width: 320, + render: (v: string | null) => v ?? '—', + }, + ...(hasCounterparties ? [{ 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( @@ -69,10 +87,19 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on const [query, setQuery] = useState(''); const [account, setAccount] = useState(); const table = useMemo(() => flattenEntries(entries), [entries]); - const columns = useMemo(() => buildColumns(table.hasDepartments), [table.hasDepartments]); + const columnsOptions = useMemo( + () => ({ + hasDepartments: table.hasDepartments, + hasCounterparties: table.hasCounterparties, + analyticsTitle: table.analyticsTitle, + analyticsHint: table.analyticsHint, + }), + [table.hasDepartments, table.hasCounterparties, table.analyticsTitle, table.analyticsHint], + ); + const columns = useMemo(() => buildColumns(columnsOptions), [columnsOptions]); const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]); - // колонки до сумм: Субсчёт, [Подразделение], Контрагент, ИНН — под ними подпись «Итого по документу» - const labelSpan = table.hasDepartments ? 4 : 3; + // колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу» + const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0); const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK'; const confirm = async () => { @@ -107,7 +134,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on } - placeholder='Контрагент или ИНН' + placeholder={table.hasCounterparties ? 'Контрагент или ИНН' : 'Поиск по аналитике'} style={{ width: 320 }} value={query} onChange={(e) => setQuery(e.target.value)} @@ -151,7 +178,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on dataSource={rows} pagination={false} locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }} - scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }} + scroll={{ y: 'calc(100vh - 340px)', x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * 140 }} summary={() => entries.length > 0 && ( diff --git a/src/widgets/DocumentReview/osvRows.test.ts b/src/widgets/DocumentReview/osvRows.test.ts index b88b608..08e50ae 100644 --- a/src/widgets/DocumentReview/osvRows.test.ts +++ b/src/widgets/DocumentReview/osvRows.test.ts @@ -113,6 +113,55 @@ describe('flattenEntries', () => { total: expect.objectContaining({ opening_debit: null }), accounts: [], hasDepartments: false, + analyticsTitle: 'Аналитика', + analyticsHint: null, + hasCounterparties: false, }); }); }); + +describe('flattenEntries: вид аналитики', () => { + const acc = e('60', null, 'ACCOUNT', '60', {}, { account_code: '60' }); + const cp = (id: string) => e(id, '60', 'COUNTERPARTY', `ООО ${id}`, { opening_debit: '1' }, { group_name: 'Контрагенты' }); + const os = (id: string, name: string | null = 'Основные средства') => + e(id, '60', 'OTHER', `Объект ${id}`, { opening_debit: '1' }, { group_name: name }); + + it('только контрагенты — «Контрагент», ИНН и поиск по ИНН нужны', () => { + const t = flattenEntries([acc, cp('a'), cp('b')]); + expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Контрагент', null, true]); + }); + + it('договор под контрагентом вид не меняет', () => { + const t = flattenEntries([acc, cp('a'), e('d', 'a', 'CONTRACT', 'Договор 1', { opening_debit: '1' }, { group_name: 'Договоры' })]); + expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Контрагент', true]); + }); + + it('только основные средства — название вида из файла, без ИНН', () => { + const t = flattenEntries([acc, os('a'), os('b')]); + expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Основные средства', null, false]); + }); + + it('смесь видов — «Аналитика» и перечень видов в подсказке', () => { + const t = flattenEntries([acc, cp('a'), os('b')]); + expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual([ + 'Аналитика', + 'Виды аналитики: Контрагенты, Основные средства', + true, + ]); + }); + + it('два разных вида не-контрагентов — тоже «Аналитика»', () => { + const t = flattenEntries([acc, os('a'), os('b', 'Номенклатура')]); + expect([t.analyticsTitle, t.analyticsHint]).toEqual(['Аналитика', 'Виды аналитики: Основные средства, Номенклатура']); + }); + + it('вид без названия — «Аналитика»', () => { + const t = flattenEntries([acc, os('a', null)]); + expect([t.analyticsTitle, t.hasCounterparties]).toEqual(['Аналитика', false]); + }); + + it('структурные строки вид не определяют; без листьев аналитики — «Аналитика» без ИНН', () => { + const t = flattenEntries([e('60', null, 'ACCOUNT', '60', { opening_debit: '5' }, { account_code: '60' })]); + expect([t.analyticsTitle, t.analyticsHint, t.hasCounterparties]).toEqual(['Аналитика', null, false]); + }); +}); diff --git a/src/widgets/DocumentReview/osvRows.ts b/src/widgets/DocumentReview/osvRows.ts index 0ed847d..570461f 100644 --- a/src/widgets/DocumentReview/osvRows.ts +++ b/src/widgets/DocumentReview/osvRows.ts @@ -29,8 +29,41 @@ export type OsvTable = { accounts: string[]; /** В файле есть уровень «Подразделение» — показать колонку. */ hasDepartments: boolean; + /** Заголовок колонки аналитики по виду строк: «Контрагент», название вида из файла («Основные средства») или «Аналитика». */ + analyticsTitle: string; + /** Перечень видов аналитики для подсказки у заголовка — только когда заголовок «Аналитика» из-за смеси видов. */ + analyticsHint: string | null; + /** Среди строк есть контрагенты (COUNTERPARTY): только тогда нужны колонка ИНН и поиск по ИНН. */ + hasCounterparties: boolean; }; +const MIXED_TITLE = 'Аналитика'; + +/** Заголовок колонки аналитики по видам строк-листьев. Вид — COUNTERPARTY или название группировки из шапки файла + * (`group_name`, у остальных типов — OTHER). Один вид: контрагенты → «Контрагент», иначе его название; смесь или + * вид без названия → «Аналитика», а при смеси перечень видов уходит в подсказку. */ +export function analyticsHeading(leaves: EntryOut[]): Pick { + const kinds = new Map(); + for (const leaf of leaves) { + const name = leaf.group_name?.trim() || null; + const key = leaf.group_type === 'COUNTERPARTY' ? 'COUNTERPARTY' : `name:${name ?? ''}`; + const label = leaf.group_type === 'COUNTERPARTY' ? (name ?? 'Контрагенты') : name; + if (!kinds.has(key) || kinds.get(key) === null) kinds.set(key, label); + } + const hasCounterparties = kinds.has('COUNTERPARTY'); + if (kinds.size === 1) { + const [[key, label]] = [...kinds]; + if (key === 'COUNTERPARTY') return { analyticsTitle: 'Контрагент', analyticsHint: null, hasCounterparties }; + return { analyticsTitle: label ?? MIXED_TITLE, analyticsHint: null, hasCounterparties }; + } + const labels = [...kinds.values()].map((label) => label ?? 'без названия'); + return { + analyticsTitle: MIXED_TITLE, + analyticsHint: labels.length > 0 ? `Виды аналитики: ${labels.join(', ')}` : null, + hasCounterparties, + }; +} + const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']); const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined); @@ -54,10 +87,12 @@ export function flattenEntries(entries: EntryOut[]): OsvTable { }; const rows: OsvRow[] = []; + const leaves: EntryOut[] = []; for (const entry of entries) { if (entry.group_type === 'CONTRACT' || withChildren.has(entry.id)) continue; const structural = STRUCTURE.has(entry.group_type ?? ''); if (structural && !hasAmount(entry)) continue; + if (!structural) leaves.push(entry); const chain = [entry, ...ancestors(entry)]; const department = chain.find((item) => item.group_type === 'DEPARTMENT'); rows.push({ @@ -73,5 +108,5 @@ export function flattenEntries(entries: EntryOut[]): OsvTable { const roots = entries.filter((entry) => !entry.parent_entry_id || !byId.has(entry.parent_entry_id)); const total = Object.fromEntries(AMOUNT_KEYS.map((key) => [key, sumAmounts(roots.map((root) => root[key]))])) as Amounts; const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true })); - return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT') }; + return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) }; } From bbb1b598adccb55840867383a984ef5b689844b1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 12:06:14 +0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec):=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=20=D0=9E=D0=A1=D0=92=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=81=D0=BA=D0=B0=D1=8F=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0,=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20?= =?UTF-8?q?(#39,=20AURORA-1666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Требование «ОСВ-документ» описывало дерево с колонками «контрагент, ИНН», что расходилось с плоской таблицей (#12) и с новым правилом колонок (#39). Co-Authored-By: Claude Opus 5.5 --- openspec/specs/document-review-ui/spec.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openspec/specs/document-review-ui/spec.md b/openspec/specs/document-review-ui/spec.md index 7f5475f..2e39a89 100644 --- a/openspec/specs/document-review-ui/spec.md +++ b/openspec/specs/document-review-ui/spec.md @@ -1,7 +1,7 @@ # document-review-ui Specification ## Purpose -Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — деревом строк только для чтения. +Аналитик проверяет распознанный документ и подтверждает его: форма ББ/ОФР рядом со сканом PDF (правка значений и шапки, проверки и их акцепт) [41]/[41-02], ОСВ-документ — плоской таблицей строк только для чтения. ## Requirements @@ -72,9 +72,10 @@ column_id, amount}`, ячейка достроенной строки (`id = nul - **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием» ### Requirement: ОСВ-документ -Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) деревом по -`parent_entry_id` с колонками: счёт/субсчёт, контрагент, ИНН, сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец -Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм. +Для `OSV` экран SHALL показывать строки `GET /documents/{id}/entries` (все страницы, `limit` ≤ 2000) плоской +таблицей строк нижнего уровня (договоры отдельными строками не идут — их сумма в строке контрагента) с колонками: +субсчёт, подразделение (если в файле есть этот уровень), аналитика, ИНН (если есть контрагенты), сальдо на начало Дт/Кт, +обороты Дт/Кт, сальдо на конец Дт/Кт (суммы — строками с разделителями разрядов, рубли), только чтение. Кнопка «Подтвердить» — как для форм. Заголовок колонки аналитики SHALL зависеть от вида строк-листьев (структурные строки субсчёта и подразделения не учитываются, договоры строками не идут): только `COUNTERPARTY` — «Контрагент»; один другой вид — его `group_name` («Основные средства»); смесь видов или вид без `group_name` — «Аналитика», а перечень видов — во всплывающей подсказке