Нарушения проверок: expected/actual и индекс колонки nullable (#45) #46

Merged
aep merged 2 commits from feat/45-nullable-checks into test 2026-10-18 21:56:10 +03:00
6 changed files with 84 additions and 13 deletions

View File

@ -228,10 +228,13 @@ export interface UnresolvedLineOut {
line_code_text: string | null;
}
export interface FailedCheckOut {
column_index: number;
/** -1 в GET …/statement и null в теле 409 — нарушение без колонки (P3-60) */
column_index: number | null;
relation: string;
expected: string;
actual: string;
/** null — цель соотношения не заполнена при непустых членах (P3-30) */
expected: string | null;
/** допускает null по OpenAPI ядра (P3-30) */
actual: string | null;
codes: string[];
line_ids: string[];
}

View File

@ -1,10 +1,7 @@
import { Alert, Button, Input, Modal, Space, Tooltip, Typography } from 'antd';
import { useState } from 'react';
import type { ChecksOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
export const failureText = (f: FailedCheckOut) =>
`Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`;
import { expectedActualText, failureKey, failureText } from './checkText';
type Props = {
checks: ChecksOut;
@ -20,7 +17,7 @@ type Props = {
const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{list.map((f) => (
<div key={`${f.column_index}-${f.relation}`}>
<div key={failureKey(f)}>
{failureText(f)}
{onShow && (f.line_ids ?? []).length > 0 && (
<Button type='link' size='small' onClick={() => onShow(f.line_ids[0])}>
@ -72,7 +69,7 @@ export function ChecksPanel({ checks, editable, dirty, onAccept, onShow }: Props
type='warning'
showIcon
title='Межформенная проверка ББ↔ОФР'
description={`${checks.cross_form.relation}: ожидалось ${formatAmount(checks.cross_form.expected)}, получено ${formatAmount(checks.cross_form.actual)}`}
description={`${checks.cross_form.relation}: ${expectedActualText(checks.cross_form)}`}
/>
)}
{checks.accepted && (

View File

@ -8,6 +8,7 @@ import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard';
import { expectedActualText, failureKey } from './checkText';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
@ -183,8 +184,8 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
problem.failures.length > 0 ? (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{problem.failures.map((f) => (
<li key={`${f.column_index}-${f.relation}`}>
{f.relation}: ожидалось {formatAmount(f.expected)}, получено {formatAmount(f.actual)}
<li key={failureKey(f)}>
{f.relation}: {expectedActualText(f)}
</li>
))}
</ul>

View File

@ -6,12 +6,13 @@ import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { ChecksPanel } from './ChecksPanel';
import { IssuesPanel } from './IssuesPanel';
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
import { SourcePreview } from './SourcePreview';
import { StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { failureKey, failureText } from './checkText';
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
import { confirmErrorInfo } from './confirmError';
@ -205,7 +206,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
let confirmDescription: ReactNode;
if (confirmFailures.length > 0) {
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
confirmDescription = confirmFailures.map((f) => <div key={failureKey(f)}>{failureText(f)}</div>);
} else if (confirmLines.length > 0) {
confirmDescription = (
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(confirmLines[0].line_id)}>

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import type { FailedCheckOut } from '../../types/api';
import { failureKey, failureText, hasColumn } from './checkText';
const base: FailedCheckOut = { column_index: 0, relation: '1600 = 1700', expected: '10.00', actual: '9.00', codes: [], line_ids: [] };
describe('hasColumn', () => {
it('0 и больше — колонка есть; -1 и null — нарушение без колонки', () => {
expect(hasColumn(0)).toBe(true);
expect(hasColumn(3)).toBe(true);
expect(hasColumn(-1)).toBe(false);
expect(hasColumn(null)).toBe(false);
expect(hasColumn(undefined)).toBe(false);
});
});
describe('failureText', () => {
it('обычное нарушение с номером колонки', () => {
expect(failureText(base)).toBe('Колонка 1: 1600 = 1700 — ожидалось 10,00, получено 9,00');
});
it('expected = null: «цель не заполнена»', () => {
const text = failureText({ ...base, expected: null });
expect(text).toBe('Колонка 1: 1600 = 1700 — цель не заполнена, получено 9,00');
expect(text).not.toContain('NaN');
});
it('expected и actual оба null', () => {
expect(failureText({ ...base, expected: null, actual: null })).toContain('цель не заполнена, получено —');
});
it('actual = null показывается прочерком', () => {
expect(failureText({ ...base, actual: null })).toContain('получено —');
});
it('индекс -1 и null: без номера колонки', () => {
expect(failureText({ ...base, column_index: -1 })).toMatch(/^Без колонки: /);
expect(failureText({ ...base, column_index: null })).toMatch(/^Без колонки: /);
});
});
describe('failureKey', () => {
it('для нарушений без колонки ключ не содержит -1/null', () => {
expect(failureKey({ ...base, column_index: null })).toBe('none-1600 = 1700');
expect(failureKey({ ...base, column_index: -1 })).toBe('none-1600 = 1700');
expect(failureKey(base)).toBe('0-1600 = 1700');
});
});

View File

@ -0,0 +1,25 @@
import type { FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
/** Подпись пустой цели: expected = null — цель соотношения не заполнена при непустых членах (P3-30). */
const NO_TARGET = 'цель не заполнена';
/**
* Есть ли у нарушения колонка. Ядро кодирует «колонки нет» двумя способами:
* -1 в ответе GET …/statement и null в теле 409 CHECKS_NOT_PASSED (P3-60).
*/
export const hasColumn = (index: number | null | undefined): index is number => typeof index === 'number' && index >= 0;
export const failureKey = (f: Pick<FailedCheckOut, 'column_index' | 'relation'>): string =>
`${hasColumn(f.column_index) ? f.column_index : 'none'}-${f.relation}`;
/** «ожидалось …, получено …» для любых expected/actual, в том числе null. */
export const expectedActualText = (f: Pick<FailedCheckOut, 'expected' | 'actual'>): string =>
f.expected === null
? `${NO_TARGET}, получено ${formatAmount(f.actual)}`
: `ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`;
export const failureText = (f: FailedCheckOut): string => {
const where = hasColumn(f.column_index) ? `Колонка ${f.column_index + 1}` : 'Без колонки';
return `${where}: ${f.relation} — ${expectedActualText(f)}`;
};