Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44

Merged
aep merged 22 commits from feat/43-statements-v2 into test 2026-10-18 19:09:18 +03:00
7 changed files with 58 additions and 12 deletions
Showing only changes of commit d965fdc064 - Show all commits

15
src/utils/percent.test.ts Normal file
View File

@ -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();
});
});

6
src/utils/percent.ts Normal file
View File

@ -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;
}

View File

@ -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) {

View File

@ -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('—');
});

View File

@ -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 };

View File

@ -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: 'Исходное: тыс. руб.' });

View File

@ -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<string, I
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;
const attention = (header.needs_review_fields ?? []).includes(field);
const src = header.source?.[field];
const recognized = src?.text != null && src.text.trim() !== '' ? `Распознано: «${src.text}»` : undefined;
let tip: string | undefined;
if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`;
else if (recognized) {
tip = src?.confidence != null ? `${recognized} · уверенность ${Number(src.confidence)}%` : recognized;
const confidence = formatPercent(src?.confidence);
tip = confidence !== null ? `${recognized} · уверенность ${confidence}` : recognized;
}
return {
field,