From 8ab116fdba8f3bb545716f8450a1c0a1cddd3bad Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:28:41 +0400 Subject: [PATCH] =?UTF-8?q?feat(statement):=20=D1=88=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20(#4?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../DocumentReview/StatementHeaderBlock.tsx | 49 +++++++++---- .../DocumentReview/StatementReview.tsx | 3 +- .../DocumentReview/headerFields.test.ts | 72 ++++++++++++++----- src/widgets/DocumentReview/headerFields.ts | 43 +++++++---- 4 files changed, 123 insertions(+), 44 deletions(-) diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 90c2b2f..25aab59 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -1,6 +1,8 @@ -import { Input, Tooltip } from 'antd'; -import type { DocumentType, StatementHeaderOut } from '../../types/api'; +import { Button, Input, Tooltip } from 'antd'; +import type { DocumentType, IssueOut, StatementHeaderOut } from '../../types/api'; +import { SeverityIcon } from './IssuesPanel'; import { type HeaderFieldKey, headerFields } from './headerFields'; +import { worstSeverity } from './issuePlaces'; import { formTitle, formatReportDate } from './reviewView'; export type HeaderDraft = Partial>; @@ -11,11 +13,20 @@ type Props = { editing: boolean; draft: HeaderDraft; onDraftChange: (field: HeaderFieldKey, text: string) => void; + /** предупреждения по полям шапки (IssuePlaces.byField) */ + byField?: Map; + /** «Подтвердить дату»; черновик подтверждения подключает T6 */ + onConfirmDate?: () => void; }; const ATTENTION_BG = '#fff7e6'; const MODIFIED_BG = '#e6f4ff'; -const PLACEHOLDER: Record = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' }; +const PLACEHOLDER: Record = { + organization_name: 'Наименование организации', + inn: 'ИНН', + report_date: 'ГГГГ-ММ-ДД', + units: 'тыс. руб.', +}; const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' }; function fieldBackground(modified: boolean, attention: boolean): string | undefined { @@ -28,29 +39,43 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. */ -export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) { +export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, onConfirmDate }: Props) { const reportDate = formatReportDate(header.report_date.value); return (
- {headerFields(header).map((r) => ( + {headerFields(header, byField).map((r) => (
{r.label} + {r.issues.length > 0 && ( + i.message).join('; ')}> + + + + + )} {editing ? ( - onDraftChange(r.field, e.target.value)} - /> + <> + onDraftChange(r.field, e.target.value)} + /> + {r.canConfirm && onConfirmDate && ( + + )} + ) : ( {r.text} )} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 608abc6..466965f 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -99,7 +99,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut body.values = draftToPatch(values, grid?.rows ?? []); } const headerPatch: HeaderDraft = {}; - for (const field of ['inn', 'report_date', 'units'] as const) { + for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) { const text = header[field]?.trim(); if (text) { headerPatch[field] = text; @@ -215,6 +215,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut documentType={statement.document.document_type} editing={editing} draft={header} + byField={places.byField} onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} /> diff --git a/src/widgets/DocumentReview/headerFields.test.ts b/src/widgets/DocumentReview/headerFields.test.ts index 0da2da1..ee5cb15 100644 --- a/src/widgets/DocumentReview/headerFields.test.ts +++ b/src/widgets/DocumentReview/headerFields.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import type { HeaderFieldOut, IssueOut, StatementHeaderOut, StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; import { headerFields } from './headerFields'; const f = (value: string | null, extra: Partial = {}): HeaderFieldOut => ({ @@ -15,7 +16,11 @@ const header = (over: Partial = {}) => inn: f('3528104476'), report_date: f('2024-12-31'), units: f('тыс. руб.'), - organization_name: 'мусор', + organization_name: 'ООО Ромашка', + organization_name_modified: false, + okei: null, + needs_review_fields: [], + source: {}, okud: null, okpo: null, okopf: null, @@ -24,24 +29,53 @@ const header = (over: Partial = {}) => ...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', 'Единица измерения', 'тыс. руб.'], +const fixtureHeader = (statementV2Json as unknown as StatementOut).header; +const fixtureIssue = (statementV2Json as unknown as StatementOut).issues.find((i) => i.id === 'i3') as IssueOut; + +describe('headerFields — шапка: организация, поля на проверку, подсказки источника', () => { + it('четыре поля по порядку', () => { + const rows = headerFields(fixtureHeader); + expect(rows.map((r) => [r.field, r.label])).toEqual([ + ['organization_name', 'Организация'], + ['inn', 'ИНН'], + ['report_date', 'Дата отчёта'], + ['units', 'Единица измерения'], ]); + expect(rows[2].text).toBe('30.09.2025'); }); - 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: 'Исходное: тыс. руб.' }); + it('внимание только по needs_review_fields', () => { + const rows = headerFields(fixtureHeader); + expect(rows[2]).toMatchObject({ attention: true, canConfirm: true }); + expect(rows[0]).toMatchObject({ attention: false, canConfirm: false }); + const low = headerFields(header({ inn: f('312120035', { confidence: '50' }) })); + expect(low[1].attention).toBe(false); + const empty = headerFields(header({ inn: f(null) })); + expect(empty[1]).toMatchObject({ attention: false, text: '—' }); + }); + it('подтвердить можно только дату с известным значением', () => { + const rows = headerFields(header({ needs_review_fields: ['report_date'], report_date: f(null) })); + expect(rows[2].canConfirm).toBe(false); + }); + it('подсказка источника', () => { + expect(headerFields(fixtureHeader)[0].tip).toBe('Распознано: «ООО «Ромашкa»» · уверенность 67%'); + const h = header({ source: { organization_name: { text: 'ООО Р', confidence: '67.00' } } }); + expect(headerFields(h)[0].tip).toBe('Распознано: «ООО Р» · уверенность 67%'); + expect(headerFields(header())[0].tip).toBeUndefined(); + }); + it('исправленное поле — исходное в подсказке', () => { + const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) })); + expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); + }); + it('исправленная организация', () => { + const h = header({ + organization_name_modified: true, + source: { organization_name: { text: 'ООО Р', confidence: '67.00' } }, + }); + expect(headerFields(h)[0]).toMatchObject({ modified: true, tip: 'Распознано: «ООО Р»' }); + }); + it('предупреждения поля', () => { + const rows = headerFields(fixtureHeader, new Map([['organization_name', [fixtureIssue]]])); + expect(rows[0].issues).toEqual([fixtureIssue]); + expect(rows[1].issues).toEqual([]); }); }); diff --git a/src/widgets/DocumentReview/headerFields.ts b/src/widgets/DocumentReview/headerFields.ts index 1e96d9a..cd66223 100644 --- a/src/widgets/DocumentReview/headerFields.ts +++ b/src/widgets/DocumentReview/headerFields.ts @@ -1,7 +1,7 @@ -import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api'; import { formatReportDate } from './reviewView'; -export type HeaderFieldKey = 'inn' | 'report_date' | 'units'; +export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units'; export type HeaderFieldRow = { field: HeaderFieldKey; @@ -11,27 +11,46 @@ export type HeaderFieldRow = { attention: boolean; modified: boolean; tip: string | undefined; + issues: IssueOut[]; + canConfirm: boolean; }; const FIELDS: [HeaderFieldKey, string][] = [ + ['organization_name', 'Организация'], ['inn', 'ИНН'], ['report_date', 'Дата отчёта'], ['units', 'Единица измерения'], ]; /** - * Шапка формы — только поля, которые распознавание извлекает надёжно (как в старом АРМ, PDFResultViewer): - * ИНН, дата отчёта, единица измерения. Пустое или с уверенностью < 100 — «требует внимания», правка — исходное в подсказке. + * Шапка формы: организация, ИНН, дата отчёта, единица измерения. «Требует внимания» — только поля из + * `needs_review_fields` (решает ядро), подсказка — что именно распознано в файле (`source`) или исходное до правки. */ -export function headerFields(header: StatementHeaderOut): HeaderFieldRow[] { +export function headerFields(header: StatementHeaderOut, byField?: Map): 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; + const isOrg = field === 'organization_name'; + const h: HeaderFieldOut | null = isOrg ? null : header[field]; + const raw = isOrg ? header.organization_name : (h?.value ?? null); + const text = (field === 'report_date' ? formatReportDate(raw) : raw) ?? '—'; + const modified = isOrg ? header.organization_name_modified : h?.modified === true; + const attention = header.needs_review_fields.includes(field); + const src = header.source[field]; + const recognized = src?.text != null ? `Распознано: «${src.text}»` : undefined; 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 }; + if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`; + else if (recognized) { + tip = src?.confidence != null ? `${recognized} · уверенность ${Number(src.confidence)}%` : recognized; + } + return { + field, + label, + text, + raw, + attention, + modified, + tip, + issues: byField?.get(field) ?? [], + canConfirm: field === 'report_date' && attention && raw !== null, + }; }); }