From d965fdc0647adcc0a791d004704b3a82a0b1fa44 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:03:33 +0400 Subject: [PATCH] =?UTF-8?q?fix(statement):=20=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B3=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=D1=85=20=D1=88?= =?UTF-8?q?=D0=B0=D0=BF=D0=BA=D0=B8,=20=D0=BF=D1=83=D1=81=D1=82=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=D1=85,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20(T5.1,=20T5.2,=20M-1,?= =?UTF-8?q?=20T3.1,=20#43)?= 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 --- src/utils/percent.test.ts | 15 +++++++++++++++ src/utils/percent.ts | 6 ++++++ .../DocumentReview/StatementHeaderBlock.tsx | 5 +++-- src/widgets/DocumentReview/cellView.test.ts | 14 ++++++++++++++ src/widgets/DocumentReview/cellView.ts | 13 +++++++------ src/widgets/DocumentReview/headerFields.test.ts | 7 +++++++ src/widgets/DocumentReview/headerFields.ts | 10 ++++++---- 7 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 src/utils/percent.test.ts create mode 100644 src/utils/percent.ts diff --git a/src/utils/percent.test.ts b/src/utils/percent.test.ts new file mode 100644 index 0000000..010c207 --- /dev/null +++ b/src/utils/percent.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { formatPercent } from './percent'; + +describe('formatPercent', () => { + it('округляет до целого', () => { + expect(formatPercent('61.54')).toBe('62%'); + expect(formatPercent('67.00')).toBe('67%'); + expect(formatPercent(100)).toBe('100%'); + }); + it('не число — null', () => { + expect(formatPercent('abc')).toBeNull(); + expect(formatPercent('')).toBeNull(); + expect(formatPercent(null)).toBeNull(); + }); +}); diff --git a/src/utils/percent.ts b/src/utils/percent.ts new file mode 100644 index 0000000..5567b55 --- /dev/null +++ b/src/utils/percent.ts @@ -0,0 +1,6 @@ +/** Уверенность API (строка Decimal) → «62%» (округление до целого); не число — null, чтобы не показывать «NaN%». */ +export function formatPercent(confidence: string | number | null | undefined): string | null { + if (confidence === null || confidence === undefined || confidence === '') return null; + const n = Number(confidence); + return Number.isFinite(n) ? `${Math.round(n)}%` : null; +} diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 950fb88..56d1094 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -36,8 +36,9 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi } /** - * Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения — - * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] + * Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица из четырёх полей — организация, ИНН, + * дата отчёта, единица измерения (в режиме правки поля редактируются; дату из `needs_review_fields` можно подтвердить + * кнопкой); дальше заголовок формы. Реквизиты бумажной формы из макета [41] * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. */ export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) { diff --git a/src/widgets/DocumentReview/cellView.test.ts b/src/widgets/DocumentReview/cellView.test.ts index 1e85ecb..0af48af 100644 --- a/src/widgets/DocumentReview/cellView.test.ts +++ b/src/widgets/DocumentReview/cellView.test.ts @@ -57,6 +57,14 @@ describe('cellTipLines', () => { it('нет текста и уверенности — пусто', () => { expect(cellTipLines(val({ text: null, confidence: null }))).toEqual([]); }); + it('пустой или пробельный текст — строки «Распознано» нет', () => { + expect(cellTipLines(val({ text: '', confidence: '100' }))).toEqual(['Уверенность: 100%']); + expect(cellTipLines(val({ text: ' ', confidence: null }))).toEqual([]); + }); + it('уверенность округляется, нечисловая — без строки', () => { + expect(cellTipLines(val({ text: null, confidence: '61.54' }))).toEqual(['Уверенность: 62%']); + expect(cellTipLines(val({ text: null, confidence: 'abc' }))).toEqual([]); + }); it('расшифровка без needs_review тоже получает подсказку', () => { expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([ 'Распознано: «70045»', @@ -85,6 +93,12 @@ describe('codeCellView', () => { tip: 'Код не распознан: «ИЧ,250»', }); }); + it('unreadable с пробельным текстом — тире и в подсказке', () => { + expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: ' ' })).tip).toBe('Код не распознан: «—»'); + }); + it('ok: нечисловая уверенность кода — без хвоста', () => { + expect(codeCellView(row({ codeConfidence: 'abc' })).tip).toBeUndefined(); + }); it('unreadable без текста — тире', () => { expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: null })).text).toBe('—'); }); diff --git a/src/widgets/DocumentReview/cellView.ts b/src/widgets/DocumentReview/cellView.ts index e63450b..747299d 100644 --- a/src/widgets/DocumentReview/cellView.ts +++ b/src/widgets/DocumentReview/cellView.ts @@ -1,6 +1,7 @@ // Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются. import type { IssueOut, ValueOut } from '../../types/api'; import { formatAmount } from '../../utils/money'; +import { formatPercent } from '../../utils/percent'; import { type GridRow, cellFlags } from './statementGrid'; export const COLORS = { @@ -24,16 +25,15 @@ export function rowBackground(row: GridRow): string | undefined { return row.lineStatus === 'ok' && row.needsReview ? COLORS.attention : undefined; } -const percent = (confidence: string): string => `${Math.round(Number(confidence))}%`; - /** Строки подсказки ячейки суммы; подсказка есть и без подсветки (Р-23). */ export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] { const lines: string[] = []; if (value.is_modified) { lines.push(`Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`); } - if (value.text !== null) lines.push(`Распознано: «${value.text}»`); - if (value.confidence !== null) lines.push(`Уверенность: ${percent(value.confidence)}`); + if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`); + const confidence = formatPercent(value.confidence); + if (confidence !== null) lines.push(`Уверенность: ${confidence}`); if (value.is_confirmed) lines.push('Подтверждено аналитиком'); for (const issue of issues) lines.push(issue.message); return lines; @@ -44,7 +44,7 @@ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: switch (row.lineStatus) { case 'unreadable': { const raw = row.codeText?.trim() ?? ''; - return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw}»` }; + return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw || '—'}»` }; } case 'missing': return { text: '—', muted: true, tip: 'Кода в строке нет' }; @@ -55,7 +55,8 @@ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: if (row.codeModified) { tips.push('Код назначен аналитиком'); } else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) { - tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`); + const confidence = formatPercent(row.codeConfidence); + if (confidence !== null) tips.push(`Уверенность кода: ${confidence}`); } if (row.isConfirmed) tips.push('Код подтверждён'); return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined }; diff --git a/src/widgets/DocumentReview/headerFields.test.ts b/src/widgets/DocumentReview/headerFields.test.ts index ee5cb15..8b9a886 100644 --- a/src/widgets/DocumentReview/headerFields.test.ts +++ b/src/widgets/DocumentReview/headerFields.test.ts @@ -62,6 +62,13 @@ describe('headerFields — шапка: организация, поля на п expect(headerFields(h)[0].tip).toBe('Распознано: «ООО Р» · уверенность 67%'); expect(headerFields(header())[0].tip).toBeUndefined(); }); + it('уверенность в подсказке округляется, нечисловая — без хвоста, пустой текст — без подсказки', () => { + const tip = (confidence: string | null, text = 'ООО Р') => + headerFields(header({ source: { organization_name: { text, confidence } } }))[0].tip; + expect(tip('61.54')).toBe('Распознано: «ООО Р» · уверенность 62%'); + expect(tip('abc')).toBe('Распознано: «ООО Р»'); + expect(tip('67', ' ')).toBeUndefined(); + }); it('исправленное поле — исходное в подсказке', () => { const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) })); expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); diff --git a/src/widgets/DocumentReview/headerFields.ts b/src/widgets/DocumentReview/headerFields.ts index cd66223..025da93 100644 --- a/src/widgets/DocumentReview/headerFields.ts +++ b/src/widgets/DocumentReview/headerFields.ts @@ -1,4 +1,5 @@ import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api'; +import { formatPercent } from '../../utils/percent'; import { formatReportDate } from './reviewView'; export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units'; @@ -33,13 +34,14 @@ export function headerFields(header: StatementHeaderOut, byField?: Map