diff --git a/openspec/changes/demo-prototype/design.md b/openspec/changes/demo-prototype/design.md index 23e1fac..fbf99ab 100644 --- a/openspec/changes/demo-prototype/design.md +++ b/openspec/changes/demo-prototype/design.md @@ -145,3 +145,9 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд признак контрагента — в фильтре «Контрагенты». Цена: низкая. - 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный список нарушений — с прокруткой (120 px). Цена: низкая. +- 08.10.2026 — R-13 (согласовано с владельцем в чате): форма ББ/ОФР [41] раскладывается «как на бумаге» на фронте — + порядок строк и заголовки разделов восстанавливаются по соотношениям §7 (`formLayout.ts`), итоги XX00 жирные (кроме 2900), + строки вне формы — «Прочие строки»; приклеенные OCR заголовки разделов срезаются только при показе (полное имя — в + подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина: + сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются; + при появлении порядка строк в API (issue arm-core) раскладку можно упростить. diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index d4dee03..95dc1d2 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -1,6 +1,7 @@ -import { Input } from 'antd'; +import { Input, Tooltip, Typography } from 'antd'; import type { ReactNode } from 'react'; import type { DocumentType, StatementHeaderOut } from '../../types/api'; +import { okudCode, reportingPeriodTitle } from './formLayout'; import { formTitle, formatReportDate } from './reviewView'; export type HeaderDraft = { inn?: string; report_date?: string; units?: string }; @@ -33,58 +34,66 @@ function Row({ label, children }: { label: string; children: ReactNode }) { ); } -/** Шапка формы «как бумажная»: заголовок, реквизиты организации, коды (макет [41]). */ +const boxCell = { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '4px 8px' }; + +/** Шапка формы «как бумажная» (макет [41]): реквизиты слева, коды в рамке справа, затем заголовок формы и её реквизиты. */ export function StatementHeaderBlock({ header: h, documentType, editing, draft, onDraftChange }: Props) { - const input = (field: HeaderField, placeholder: string, current: string | null) => ( + const input = (field: HeaderField, placeholder: string, current: string | null, width = 200) => ( onDraftChange(field, e.target.value)} - style={{ width: 200, textAlign: 'right' }} + style={{ width, textAlign: 'right' }} /> ); const reportDate = formatReportDate(h.report_date.value); - const codes: [string, string | null][] = [ - ['по ОКПО', h.okpo], - ['ИНН', h.inn.value], - ['по ОКОПФ', h.okopf], - ['по ОКФС', h.okfs], + const period = reportingPeriodTitle(h.report_date.value); + // ИНН — только в рамке кодов, как в макете; поле правки — там же + const codes: [string, ReactNode][] = [ + ['по ОКПО', h.okpo ?? '—'], + ['ИНН', editing ? input('inn', 'ИНН', h.inn.value, 130) : (h.inn.value ?? '—')], + ['по ОКОПФ', h.okopf ?? '—'], + ['по ОКФС', h.okfs ?? '—'], ]; - const shownCodes = codes.filter(([, v]) => v); + const organization = h.organization_name ? ( + // мусорный OCR-текст не должен съедать экран: две строки, полный текст — в подсказке + + + {h.organization_name} + + + ) : ( + '—' + ); return (
-
{formTitle(documentType)}
- {reportDate &&
на {reportDate}
} +
Бухгалтерская отчётность
+ {period &&
{period}
}
- {h.organization_name ?? '-'} - {editing ? input('inn', 'ИНН', h.inn.value) : (h.inn.value ?? '-')} - {h.okud ?? '-'} - {editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '-')} - {editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '-')} - {h.address && {h.address}} + {organization} + {h.address && {h.address}}
- {shownCodes.length > 0 && ( -
- {shownCodes.map(([label, value], i) => ( -
0 ? '1px solid #667085' : undefined, - }}> - {label} - {value} -
- ))} -
- )} +
+ {codes.map(([label, value], i) => ( +
0 ? '1px solid #667085' : undefined }}> + {label} + {value} +
+ ))} +
+
+
+
{formTitle(documentType)}
+ {reportDate &&
на {reportDate}
} +
+
+ {okudCode(documentType, h.okud) ?? '—'} + {editing ? input('report_date', 'ГГГГ-ММ-ДД', h.report_date.value) : (reportDate ?? '—')} + {editing ? input('units', 'тыс. руб.', h.units.value) : (h.units.value ?? '—')}
); diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index e237f67..bbdafec 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -192,6 +192,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut /> )} = [ - { key: 'name', title: 'Наименование показателя', dataIndex: 'name' }, - { key: 'code', title: 'Код', dataIndex: 'code', width: 70, align: 'center' as const }, +const SECTION_BG = '#f2f4f7'; + +export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) { + const layout = formLayout(documentType, rows); + const span = columns.length + 2; + // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты + const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); + const tableColumns: ColumnsType = [ + { + key: 'name', + title: 'Наименование показателя', + onCell: (r) => ({ + ...sectionSpan(r, true), + style: r.kind === 'section' ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } : undefined, + }), + render: (_: unknown, r: LayoutRow) => { + if (r.kind === 'section') return r.title; + const text = {r.displayName || '—'}; + return r.fullName ? {text} : text; + }, + }, + { + key: 'code', + title: 'Код', + width: 60, + align: 'center' as const, + onCell: (r) => sectionSpan(r, false), + render: (_: unknown, r: LayoutRow) => + r.kind === 'line' ? {r.row.code} : null, + }, ...columns.map((col) => ({ key: col.id, title: columnTitle(col), - width: 140, + width: 112, align: 'right' as const, - onCell: (row: GridRow) => { - return { style: { background: cellBackground(row.cells[col.id]) } }; - }, - render: (_: unknown, row: GridRow) => { + onCell: (r: LayoutRow) => + r.kind === 'section' + ? sectionSpan(r, false) + : { style: { background: cellBackground(r.row.cells[col.id]), fontWeight: r.total ? 600 : undefined } }, + render: (_: unknown, r: LayoutRow) => { + if (r.kind === 'section') return null; + const row = r.row; const value = row.cells[col.id]; if (!value) { return null; @@ -69,5 +100,5 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange } }, })), ]; - return size='small' bordered rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} />; + return size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />; } diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts new file mode 100644 index 0000000..5cce65f --- /dev/null +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { formLayout, okudCode, reportingPeriodTitle } from './formLayout'; +import type { GridRow } from './statementGrid'; + +const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({ + key: code ?? 'x', + lineId: code ?? 'x', + code, + name, + cells: {}, +}); +const shape = (rows: ReturnType) => + rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`)); + +describe('formLayout — бухгалтерский баланс (0710001)', () => { + it('порядок формы: статьи раздела, затем итог; 1600 после раздела II; 1700 в конце', () => { + const rows = ['1100', '1105', '1150', '1200', '1210', '1300', '1310', '1400', '1410', '1500', '1510', '1600', '1700'].map((c) => + row(c), + ); + expect(shape(formLayout('BALANCE', rows))).toEqual([ + '# АКТИВ', + '# I. Внеоборотные активы', + '1105', + '1150', + '1100 (итог)', + '# II. Оборотные активы', + '1210', + '1200 (итог)', + '1600 (итог)', + '# ПАССИВ', + '# III. Капитал и резервы', + '1310', + '1300 (итог)', + '# IV. Долгосрочные обязательства', + '1410', + '1400 (итог)', + '# V. Краткосрочные обязательства', + '1510', + '1500 (итог)', + '1700 (итог)', + ]); + }); + it('пустой раздел не выводится; строки вне формы — в конце под «Прочие строки»', () => { + const rows = [row('1150'), row('9999'), row(null, 'без кода')]; + expect(shape(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '# Прочие строки', '9999', 'null']); + }); + it('срезает приклеенные OCR заголовки разделов из названия, полное — в fullName', () => { + const [, , line] = formLayout('BALANCE', [row('1105', 'АКТИВ I. Внеоборотные активы Гудвил')]); + expect(line.kind === 'line' && line.displayName).toBe('Гудвил'); + expect(line.kind === 'line' && line.fullName).toBe('АКТИВ I. Внеоборотные активы Гудвил'); + }); + it('если после среза ничего не осталось — название не трогаем', () => { + const [, , line] = formLayout('BALANCE', [row('1110', 'I. Внеоборотные активы')]); + expect(line.kind === 'line' && line.displayName).toBe('I. Внеоборотные активы'); + expect(line.kind === 'line' && line.fullName).toBeNull(); + }); +}); + +describe('formLayout — ОФР (0710002)', () => { + it('итог XX00 — после статей своей сотни, без заголовков разделов', () => { + const rows = ['2100', '2110', '2120', '2200', '2210', '2300', '2310', '2400', '2410'].map((c) => row(c)); + expect(shape(formLayout('INCOME', rows))).toEqual([ + '2110', + '2120', + '2100 (итог)', + '2210', + '2200 (итог)', + '2310', + '2300 (итог)', + '2410', + '2400 (итог)', + ]); + }); +}); + +describe('formLayout — ОФР, справочные строки', () => { + it('2900 «Базовая прибыль на акцию» — не итог и стоит перед 2910', () => { + expect( + shape( + formLayout( + 'INCOME', + ['2910', '2900', '2400', '2410'].map((c) => row(c)), + ), + ), + ).toEqual(['2410', '2400 (итог)', '2900', '2910']); + }); +}); + +describe('реквизиты формы', () => { + it('ОКУД по типу формы, если не распознан', () => { + expect(okudCode('BALANCE', null)).toBe('0710001'); + expect(okudCode('INCOME', null)).toBe('0710002'); + expect(okudCode('BALANCE', '0710099')).toBe('0710099'); + }); + it('заголовок отчётности за период по дате отчёта', () => { + expect(reportingPeriodTitle('2025-09-30')).toBe('за январь – сентябрь 2025 г.'); + expect(reportingPeriodTitle('2024-12-31')).toBe('за январь – декабрь 2024 г.'); + expect(reportingPeriodTitle(null)).toBeNull(); + }); +}); diff --git a/src/widgets/DocumentReview/formLayout.ts b/src/widgets/DocumentReview/formLayout.ts new file mode 100644 index 0000000..ef511cd --- /dev/null +++ b/src/widgets/DocumentReview/formLayout.ts @@ -0,0 +1,121 @@ +// Раскладка формы ББ/ОФР «как на бумаге» (макет [41]). Сервер отдаёт только строки с кодом, отсортированные по коду, +// без заголовков разделов (05-domain-rules §12, OQ-36). Порядок и разделы восстанавливаем из структуры формы, которую +// задают соотношения §7: 1100 = Σ 11xx, 1200 = Σ 12xx, 1600 = 1100 + 1200, 1300…1500 = Σ своих строк, 1700 = 1300 + 1400 + 1500; +// у ОФР итог XX00 считается из статей своей сотни. Только показ: данные не меняются. +import type { DocumentType } from '../../types/api'; +import type { GridRow } from './statementGrid'; + +export type LayoutRow = + | { kind: 'section'; key: string; title: string; level: 1 | 2 } + | { kind: 'line'; key: string; row: GridRow; total: boolean; displayName: string; fullName: string | null }; + +type Section = { title: string; hundred: number }; + +const BALANCE_ASSETS: Section[] = [ + { title: 'I. Внеоборотные активы', hundred: 11 }, + { title: 'II. Оборотные активы', hundred: 12 }, +]; +const BALANCE_LIABILITIES: Section[] = [ + { title: 'III. Капитал и резервы', hundred: 13 }, + { title: 'IV. Долгосрочные обязательства', hundred: 14 }, + { title: 'V. Краткосрочные обязательства', hundred: 15 }, +]; +const BALANCE_PREFIXES = ['актив', 'пассив', ...[...BALANCE_ASSETS, ...BALANCE_LIABILITIES].map((s) => s.title.toLowerCase())]; + +const codeNum = (row: GridRow): number | null => (row.code && /^\d{4}$/.test(row.code) ? Number(row.code) : null); +// 2900 «Базовая прибыль (убыток) на акцию» — справочная строка ОФР, а не итог своей сотни +const isTotal = (code: number) => code % 100 === 0 && code !== 2900; +/** Статьи сотни по коду, итог XX00 — последним. */ +const byFormOrder = (a: GridRow, b: GridRow) => { + const ca = codeNum(a) as number; + const cb = codeNum(b) as number; + return Number(isTotal(ca)) - Number(isTotal(cb)) || ca - cb; +}; + +const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim(); + +/** Срезает приклеенные OCR заголовки разделов («АКТИВ I. Внеоборотные активы Гудвил» → «Гудвил»). */ +function stripSectionPrefixes(name: string | null, prefixes: string[]): { displayName: string; fullName: string | null } { + if (!name) return { displayName: '', fullName: null }; + let rest = name.replace(/\s+/g, ' ').trim(); + for (let changed = true; changed; ) { + changed = false; + for (const prefix of prefixes) { + if (normalize(rest).startsWith(prefix) && normalize(rest).length > prefix.length) { + rest = rest.slice(prefix.length).trim(); + changed = true; + } + } + } + return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name }; +} + +function lineRow(row: GridRow, prefixes: string[]): LayoutRow { + const code = codeNum(row); + return { kind: 'line', key: row.key, row, total: code !== null && isTotal(code), ...stripSectionPrefixes(row.name, prefixes) }; +} + +function balanceLayout(rows: GridRow[]): LayoutRow[] { + const out: LayoutRow[] = []; + const used = new Set(); + const take = (pred: (code: number) => boolean) => + rows.filter((r) => { + const c = codeNum(r); + return c !== null && pred(c); + }); + const block = (header: string, sections: Section[], grandTotal: number) => { + const parts = sections.map((s) => ({ s, lines: take((c) => Math.floor(c / 100) === s.hundred).sort(byFormOrder) })); + const total = take((c) => c === grandTotal); + if (parts.every((p) => p.lines.length === 0) && total.length === 0) return; + out.push({ kind: 'section', key: `h-${header}`, title: header, level: 1 }); + for (const { s, lines } of parts) { + if (lines.length === 0) continue; + out.push({ kind: 'section', key: `s-${s.hundred}`, title: s.title, level: 2 }); + for (const r of lines) { + out.push(lineRow(r, BALANCE_PREFIXES)); + used.add(r.key); + } + } + for (const r of total) { + out.push(lineRow(r, BALANCE_PREFIXES)); + used.add(r.key); + } + }; + block('АКТИВ', BALANCE_ASSETS, 1600); + block('ПАССИВ', BALANCE_LIABILITIES, 1700); + const rest = rows.filter((r) => !used.has(r.key)); + if (rest.length > 0) { + out.push({ kind: 'section', key: 's-rest', title: 'Прочие строки', level: 2 }); + for (const r of rest) out.push(lineRow(r, BALANCE_PREFIXES)); + } + return out; +} + +function incomeLayout(rows: GridRow[]): LayoutRow[] { + const coded = rows.filter((r) => codeNum(r) !== null); + const sorted = [...coded].sort((a, b) => { + const ha = Math.floor((codeNum(a) as number) / 100); + const hb = Math.floor((codeNum(b) as number) / 100); + return ha - hb || byFormOrder(a, b); + }); + return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [])); +} + +export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { + return type === 'BALANCE' ? balanceLayout(rows) : incomeLayout(rows); +} + +/** ОКУД формы: распознанный, иначе код формы по её типу (0710001 — ББ, 0710002 — ОФР; §12 п. 1). */ +export function okudCode(type: DocumentType, recognized: string | null): string | null { + if (recognized) return recognized; + const byType: Partial> = { BALANCE: '0710001', INCOME: '0710002' }; + return byType[type] ?? null; +} + +const MONTHS = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь']; + +/** «за январь – сентябрь 2025 г.» по дате отчёта (шапка «Бухгалтерская отчётность» макета). */ +export function reportingPeriodTitle(reportDate: string | null): string | null { + const m = reportDate ? /^(\d{4})-(\d{2})/.exec(reportDate) : null; + return m ? `за январь – ${MONTHS[Number(m[2]) - 1]} ${m[1]} г.` : null; +}