From 34ad6ef08161f3c26cdbaa79db59b3f19d3cdcf3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 10:49:17 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=90=D0=A0=D0=9C=20=E2=80=94=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=98=D0=9D=D0=9D=20/=20=D0=94=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20/=20?= =?UTF-8?q?=D0=95=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=BE=D0=B9=20=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20(H-5,?= =?UTF-8?q?=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Макет [41] рисует бумажную шапку с ОКПО/ОКОПФ/ОКФС/адресом, которых распознавание не отдаёт (arm-scan-gateway#15) — вместо пустых рамок показываем то, что извлекается надёжно. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- openspec/changes/demo-prototype/design.md | 5 + .../DocumentReview/StatementHeaderBlock.tsx | 124 +++++++----------- src/widgets/DocumentReview/formLayout.test.ts | 15 +-- src/widgets/DocumentReview/formLayout.ts | 15 --- .../DocumentReview/headerFields.test.ts | 47 +++++++ src/widgets/DocumentReview/headerFields.ts | 37 ++++++ 6 files changed, 136 insertions(+), 107 deletions(-) create mode 100644 src/widgets/DocumentReview/headerFields.test.ts create mode 100644 src/widgets/DocumentReview/headerFields.ts diff --git a/openspec/changes/demo-prototype/design.md b/openspec/changes/demo-prototype/design.md index fbf99ab..e536333 100644 --- a/openspec/changes/demo-prototype/design.md +++ b/openspec/changes/demo-prototype/design.md @@ -151,3 +151,8 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд подсказке). ИНН — только в рамке кодов; ОКУД — по типу формы, если не распознан; длинное название — 2 строки. Причина: сервер отдаёт строки по коду и без строк-разделов (§12, OQ-36). Цена ошибки: низкая — только показ, данные не меняются; при появлении порядка строк в API (issue arm-core) раскладку можно упростить. +- 08.10.2026 — R-14 (по замечанию владельца H-5): шапка формы [41] — как в старом АРМ (`reference-old-arm` + `PDFResultViewer`): компактная таблица ИНН / Дата отчёта / Единица измерения (подсветка уверенности и правки, правка в + режиме «Редактировать»), затем «Бухгалтерский баланс на …». Реквизиты бумажной шапки макета (ОКПО, ОКОПФ, ОКФС, адрес, + название организации) не показываются: распознавание их не отдаёт или отдаёт мусор (arm-scan-gateway#15). Заменяет + шапку из R-13. Цена ошибки: низкая — один компонент `StatementHeaderBlock`. diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 95dc1d2..90c2b2f 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -1,100 +1,68 @@ -import { Input, Tooltip, Typography } from 'antd'; -import type { ReactNode } from 'react'; +import { Input, Tooltip } from 'antd'; import type { DocumentType, StatementHeaderOut } from '../../types/api'; -import { okudCode, reportingPeriodTitle } from './formLayout'; +import { type HeaderFieldKey, headerFields } from './headerFields'; import { formTitle, formatReportDate } from './reviewView'; -export type HeaderDraft = { inn?: string; report_date?: string; units?: string }; -type HeaderField = keyof HeaderDraft; +export type HeaderDraft = Partial>; type Props = { header: StatementHeaderOut; documentType: DocumentType; editing: boolean; draft: HeaderDraft; - onDraftChange: (field: HeaderField, text: string) => void; + onDraftChange: (field: HeaderFieldKey, text: string) => void; }; -const rowStyle = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - gap: 16, - padding: '6px 0', - borderBottom: '1px solid #eaecf0', -}; -const valueStyle = { fontWeight: 600, textAlign: 'right' as const }; +const ATTENTION_BG = '#fff7e6'; +const MODIFIED_BG = '#e6f4ff'; +const PLACEHOLDER: Record = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' }; +const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' }; -function Row({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); +function fieldBackground(modified: boolean, attention: boolean): string | undefined { + if (modified) return MODIFIED_BG; + return attention ? ATTENTION_BG : undefined; } -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, width = 200) => ( - onDraftChange(field, e.target.value)} - style={{ width, textAlign: 'right' }} - /> - ); - const reportDate = formatReportDate(h.report_date.value); - 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 organization = h.organization_name ? ( - // мусорный OCR-текст не должен съедать экран: две строки, полный текст — в подсказке - - - {h.organization_name} - - - ) : ( - '—' - ); +/** + * Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения — + * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] + * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. + */ +export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) { + const reportDate = formatReportDate(header.report_date.value); return ( -
-
-
Бухгалтерская отчётность
- {period &&
{period}
} -
-
-
- {organization} - {h.address && {h.address}} -
-
- {codes.map(([label, value], i) => ( -
0 ? '1px solid #667085' : undefined }}> - {label} - {value} -
+
+ + + {headerFields(header).map((r) => ( + + + + ))} - - -
+
+
{r.label} + {editing ? ( + onDraftChange(r.field, e.target.value)} + /> + ) : ( + {r.text} + )} +
+
{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/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index 5cce65f..eb6127c 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formLayout, okudCode, reportingPeriodTitle } from './formLayout'; +import { formLayout } from './formLayout'; import type { GridRow } from './statementGrid'; const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({ @@ -85,16 +85,3 @@ describe('formLayout — ОФР, справочные строки', () => { ).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 index ef511cd..4415df3 100644 --- a/src/widgets/DocumentReview/formLayout.ts +++ b/src/widgets/DocumentReview/formLayout.ts @@ -104,18 +104,3 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] { 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; -} diff --git a/src/widgets/DocumentReview/headerFields.test.ts b/src/widgets/DocumentReview/headerFields.test.ts new file mode 100644 index 0000000..0da2da1 --- /dev/null +++ b/src/widgets/DocumentReview/headerFields.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import { headerFields } from './headerFields'; + +const f = (value: string | null, extra: Partial = {}): HeaderFieldOut => ({ + value, + original_value: value, + confidence: null, + verified: null, + modified: false, + ...extra, +}); +const header = (over: Partial = {}) => + ({ + inn: f('3528104476'), + report_date: f('2024-12-31'), + units: f('тыс. руб.'), + organization_name: 'мусор', + okud: null, + okpo: null, + okopf: null, + okfs: null, + address: null, + ...over, + }) as StatementHeaderOut; + +describe('headerFields — шапка как в старом АРМ: только надёжно извлекаемые поля', () => { + it('три поля по порядку, дата — dd.mm.yyyy', () => { + expect(headerFields(header()).map((r) => [r.field, r.label, r.text])).toEqual([ + ['inn', 'ИНН', '3528104476'], + ['report_date', 'Дата отчёта', '31.12.2024'], + ['units', 'Единица измерения', 'тыс. руб.'], + ]); + }); + it('низкая уверенность — внимание; правка — исходное значение в подсказке; пусто — прочерк', () => { + const rows = headerFields( + header({ + inn: f('312120035', { confidence: '61.54' }), + units: f('руб.', { modified: true, original_value: 'тыс. руб.' }), + report_date: f(null), + }), + ); + expect(rows[0]).toMatchObject({ attention: true, modified: false, tip: 'Уверенность: 61.54%' }); + expect(rows[1]).toMatchObject({ text: '—', attention: true }); + expect(rows[2]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); + }); +}); diff --git a/src/widgets/DocumentReview/headerFields.ts b/src/widgets/DocumentReview/headerFields.ts new file mode 100644 index 0000000..1e96d9a --- /dev/null +++ b/src/widgets/DocumentReview/headerFields.ts @@ -0,0 +1,37 @@ +import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import { formatReportDate } from './reviewView'; + +export type HeaderFieldKey = 'inn' | 'report_date' | 'units'; + +export type HeaderFieldRow = { + field: HeaderFieldKey; + label: string; + text: string; + raw: string | null; + attention: boolean; + modified: boolean; + tip: string | undefined; +}; + +const FIELDS: [HeaderFieldKey, string][] = [ + ['inn', 'ИНН'], + ['report_date', 'Дата отчёта'], + ['units', 'Единица измерения'], +]; + +/** + * Шапка формы — только поля, которые распознавание извлекает надёжно (как в старом АРМ, PDFResultViewer): + * ИНН, дата отчёта, единица измерения. Пустое или с уверенностью < 100 — «требует внимания», правка — исходное в подсказке. + */ +export function headerFields(header: StatementHeaderOut): HeaderFieldRow[] { + return FIELDS.map(([field, label]) => { + const h: HeaderFieldOut = header[field]; + const text = (field === 'report_date' ? formatReportDate(h.value) : h.value) ?? '—'; + const lowConfidence = h.confidence !== null && h.confidence !== undefined && Number(h.confidence) < 100; + const modified = h.modified === true; + let tip: string | undefined; + if (modified) tip = `Исходное: ${h.original_value ?? '—'}`; + else if (lowConfidence) tip = `Уверенность: ${h.confidence}%`; + return { field, label, text, raw: h.value, attention: !modified && (h.value === null || lowConfidence), modified, tip }; + }); +}