fix(arm-web): нераспознанный текст суммы, пустые подстроки без расшифровок, свёрнутый список предупреждений (#43)

T11.5 valueCellText, T11.10 emptySublineKeys пропускает detail, T11.11 collapseIssues (3 записи + «Показать все»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:06:02 +04:00
parent a3199d636e
commit 88a4920d7d
7 changed files with 69 additions and 4 deletions

View File

@ -1,8 +1,11 @@
import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Alert, Button, Tag } from 'antd';
import { useState } from 'react';
import type { Severity } from '../../types/api';
import { severityLabel } from '../../utils/labels';
import type { IssueListItem } from './issuePlaces';
import { type IssueListItem, collapseIssues } from './issuePlaces';
const COLLAPSED_LIMIT = 3; // сколько записей видно в свёрнутом списке (T11.11)
/** Значок уровня (P6-15): error — красный, info — синий. */
export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) {
@ -21,7 +24,9 @@ type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?:
*/
export function IssuesPanel({ items, mode, onShow }: Props) {
const plain = mode === 'document';
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;
const { shown, hidden } = collapseIssues(items, expanded, COLLAPSED_LIMIT);
return (
<Alert
type={items.some((i) => i.severity === 'error') ? 'error' : 'info'}
@ -29,7 +34,7 @@ export function IssuesPanel({ items, mode, onShow }: Props) {
title={`Предупреждения (${items.length})`}
description={
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{items.map((item) => (
{shown.map((item) => (
<div key={item.id}>
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
{item.message}
@ -41,6 +46,11 @@ export function IssuesPanel({ items, mode, onShow }: Props) {
)}
</div>
))}
{(hidden > 0 || expanded) && (
<Button type='link' size='small' style={{ paddingInline: 0 }} onClick={() => setExpanded(!expanded)}>
{expanded ? 'Свернуть' : `Показать все (${items.length})`}
</Button>
)}
</div>
}
/>

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ValueOut } from '../../types/api';
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView';
import { formatAmount } from '../../utils/money';
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground, valueCellText } from './cellView';
import type { GridRow } from './statementGrid';
const val = (extra: Partial<ValueOut> = {}): ValueOut => ({
@ -138,3 +139,24 @@ describe('фон', () => {
expect(rowBackground(row())).toBeUndefined();
});
});
describe('valueCellText (T11.5)', () => {
it('сумма есть — обычный текст', () => {
expect(valueCellText(val({ amount: '1500.00' }))).toEqual({
text: formatAmount('1500.00', { fraction: 'auto', empty: '-' }),
muted: false,
});
});
it('суммы нет, есть string_value — он серым', () => {
expect(valueCellText(val({ amount: null, string_value: '211(1 |' }))).toEqual({ text: '211(1 |', muted: true });
});
it('ничего нет — прочерк', () => {
expect(valueCellText(val({ amount: null }))).toEqual({ text: '-', muted: false });
});
});
describe('cellTipLines и string_value (T11.5)', () => {
it('без суммы показывает нераспознанный string_value', () => {
expect(cellTipLines(val({ amount: null, string_value: 'abc' }))).toContain('Не распознано как сумма: «abc»');
});
});

View File

@ -34,11 +34,22 @@ export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[]
if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`);
const confidence = formatPercent(value.confidence);
if (confidence !== null) lines.push(`Уверенность: ${confidence}`);
if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') {
lines.push(`Не распознано как сумма: «${value.string_value}»`);
}
if (value.is_confirmed) lines.push('Подтверждено аналитиком');
for (const issue of issues) lines.push(issue.message);
return lines;
}
/** Текст ячейки суммы (T11.5): сумма; без неё нераспознанный string_value серым; иначе прочерк. */
export function valueCellText(value: ValueOut): { text: string; muted: boolean } {
if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') {
return { text: value.string_value, muted: true };
}
return { text: formatAmount(value.amount, { fraction: 'auto', empty: '-' }), muted: false };
}
/** Ячейка кода (P6-16): текст, серый ли он, подсказка. */
export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } {
switch (row.lineStatus) {

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { IssueOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces';
import { collapseIssues, issueListItems, issuePlaces, worstSeverity } from './issuePlaces';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as unknown as StatementOut;
@ -82,3 +82,14 @@ describe('worstSeverity', () => {
expect(worstSeverity([])).toBeNull();
});
});
describe('collapseIssues (T11.11)', () => {
const items = Array.from({ length: 6 }, (_, i) => ({ id: String(i) })) as never[];
it('свёрнуто: первые 3, скрыто 3', () => {
expect(collapseIssues(items, false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 3 });
});
it('развёрнуто или короткий список: всё', () => {
expect(collapseIssues(items, true, 3)).toEqual({ shown: items, hidden: 0 });
expect(collapseIssues(items.slice(0, 3), false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 0 });
});
});

View File

@ -118,3 +118,9 @@ export function worstSeverity(issues: IssueOut[]): Severity | null {
if (issues.length === 0) return null;
return issues.some((i) => i.severity === 'error') ? 'error' : 'info';
}
/** Свёрнутый список предупреждений (T11.11): первые `limit` (порядок уже «ошибки первыми») и сколько скрыто. */
export function collapseIssues<T>(items: T[], expanded: boolean, limit: number): { shown: T[]; hidden: number } {
if (expanded || items.length <= limit) return { shown: items, hidden: 0 };
return { shown: items.slice(0, limit), hidden: items.length - limit };
}

View File

@ -173,6 +173,10 @@ describe('parent_code и пустые строки детализации', () =
];
expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']);
});
it('расшифровка (lineStatus detail) с parent_code не считается пустой подстрокой (T11.10)', () => {
const detail = { ...gridRow('d1', '1110', { c1: cell({}) }), lineStatus: 'detail' as const };
expect([...emptySublineKeys([detail], {})]).toEqual([]);
});
it('подстрока с правкой в черновике не считается пустой', () => {
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']);

View File

@ -88,6 +88,7 @@ export function emptySublineKeys(
.filter(
(row) =>
row.parentCode !== null &&
row.lineStatus !== 'detail' &&
!edited.has(row.key) &&
!(row.lineId !== null && row.lineId in lineActions) &&
!Object.values(row.cells).some(hasValue),