feat(types): API формы v5.19 — статусы строк, needs_review, issues, предупреждения файла (#43)

- types/api.ts: LineStatus, Severity, IssueKind, PeriodKind, FileWarningOut, IssueOut, EditionLineOut,
  IssueCountsOut, CellTarget, LinePatch, ColumnPatch; новые поля StatementHeaderOut, ColumnOut, ValueOut,
  LineOut, FailedCheckOut, StatementOut (form_variant, issues, edition_lines), DocumentBrief (issue_counts),
  DocumentOut (issues), JobBrief.warnings — FileWarningOut[].
- statementGrid.ts: GridRow получает статус строки, номер строки, код как распознан, флаги needs_review /
  is_confirmed / codeModified; cellFlags: внимание только по needs_review, новый флаг confirmed.
- labels.ts: lineStatusLabel, severityLabel.
- фикстура __fixtures__/statement-v2.json (БАЛАНС 2025 Q3, NEEDS_CHECK): 16 строк × 2 колонки, у каждой пары ValueOut.
- тесты: переносы полей v2 и покрытие пар строка × колонка, cellFlags по needs_review, подписи; хелперы
  row() / v() / gridRow / doc() дополнены значениями по умолчанию.

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 13:15:29 +04:00
parent 0f2bbc80c9
commit f83b03543e
8 changed files with 1222 additions and 17 deletions

View File

@ -28,6 +28,25 @@ export interface UploadResult {
rejected: RejectedOut[];
}
/** Статусы строки формы (v5.19): ok — строка бланка; unreadable — код не распознан; missing — кода нет; detail — расшифровка. */
export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail';
export type Severity = 'error' | 'info';
export type IssueKind = 'warning' | 'relation' | 'soft';
export type PeriodKind =
| 'absolute'
| 'relative:current'
| 'relative:prev_year_end'
| 'relative:prev_prev_year_end'
| 'relative:prev_period'
| 'none';
/** Предупреждение разбора файла (`job.warnings`); `mapping_warnings` остаются строками. */
export interface FileWarningOut {
code: string;
severity: Severity;
message: string;
locator: string | null;
}
export type RecognitionStatus = string; // pending | processing | done | error | timeout | dlq — только для показа
export interface JobBrief {
id: string;
@ -35,7 +54,7 @@ export interface JobBrief {
error_code: string | null;
error_message: string | null;
mapping_error: string | null;
warnings: string[];
warnings: FileWarningOut[];
mapping_warnings: string[];
finished_at: string | null;
}
@ -52,6 +71,7 @@ export interface DocumentBrief {
counterparty_check: CheckResult | null;
locator: string | null;
comments_count: number;
issue_counts: IssueCountsOut;
}
export type FileType = 'XLS' | 'XLSX' | 'XLSB' | 'PDF' | 'PNG' | 'JPG';
export interface FileOut {
@ -74,6 +94,36 @@ export interface DocumentOut extends DocumentBrief {
confirmed_by: string | null;
confirmed_at: string | null;
warnings: string[];
issues: IssueOut[];
}
/** Замечание к документу или форме: предупреждение разбора, нарушение соотношения или мягкая проверка. */
export interface IssueOut {
id: string;
kind: IssueKind;
code: string;
severity: Severity;
message: string;
line_ids: string[];
column_id: string | null;
column_index: number | null;
field: string | null;
source: 'service' | 'core';
relation: string | null;
codes: string[];
expected: string | null;
actual: string | null;
}
export interface HeaderSourceOut {
text: string | null;
confidence: string | null;
}
export interface EditionLineOut {
code: string;
name: string;
}
export interface IssueCountsOut {
error: number;
info: number;
}
/** Уровень строки ОСВ (DBML `osv_group_type`). */
export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER';
@ -109,17 +159,25 @@ export interface StatementHeaderOut {
report_date: HeaderFieldOut;
units: HeaderFieldOut;
organization_name: string | null;
organization_name_modified: boolean;
okud: string | null;
okpo: string | null;
okopf: string | null;
okfs: string | null;
okei: string | null;
address: string | null;
/** поля шапки, которые требуют проверки аналитиком */
needs_review_fields: string[];
/** что распознано до нормализации, по имени поля */
source: Partial<Record<string, HeaderSourceOut>>;
}
export interface ColumnOut {
id: string;
column_index: number;
label: string | null;
label_confidence: string | null;
period_date: string | null;
period_kind: PeriodKind;
is_current: boolean | null;
}
export interface ValueOut {
@ -128,18 +186,32 @@ export interface ValueOut {
column_index: number;
amount: string | null;
string_value: string | null;
text: string | null;
confidence: string | null;
is_recognized: boolean | null;
is_modified: boolean;
original_amount: string | null;
needs_review: boolean;
is_confirmed: boolean;
}
export interface LineOut {
id: string | null; // null — строка достроена по справочнику редакции
origin: 'source' | 'template';
row_index: number | null;
source_ref: string | null;
line_status: LineStatus;
line_code: string | null;
line_code_text: string | null;
line_code_confidence: string | null;
line_code_modified: boolean;
line_name: string | null;
/** справочное имя строки по коду редакции */
reference_name: string | null;
// код четырёхзначной строки-родителя у пятизначной подстроки (считает ядро, AURORA-1527); иначе null
parent_code: string | null;
parent_row_index: number | null;
needs_review: boolean;
is_confirmed: boolean;
values: ValueOut[];
}
export interface FailedCheckOut {
@ -147,6 +219,8 @@ export interface FailedCheckOut {
relation: string;
expected: string;
actual: string;
codes: string[];
line_ids: string[];
}
export interface ChecksOut {
failed: FailedCheckOut[];
@ -165,17 +239,34 @@ export interface StatementOut {
counterparty_check: CheckResult | null;
locator: string | null;
file_id: string;
form_variant: 'full' | 'simplified' | null;
};
edition: string | null;
header: StatementHeaderOut;
columns: ColumnOut[];
lines: LineOut[];
checks: ChecksOut;
issues: IssueOut[];
edition_lines: EditionLineOut[];
}
/** Адрес строки в PATCH: из БД — `line_id`, достроенная — `line_code`. */
export type CellTarget = { line_id: string } | { line_code: string };
export type CellPatch =
| (CellTarget & { column_id: string; amount: string | null })
| { line_id: string; column_id: string; confirmed: boolean };
export type LinePatch =
| { line_id: string; line_code: string }
| { line_id: string; line_status: 'detail' }
| { line_id: string; confirmed: boolean };
export interface ColumnPatch {
column_id: string;
period_date: string;
}
export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null };
export interface StatementPatch {
header?: { inn?: string; report_date?: string; units?: string };
header?: { inn?: string; report_date?: string; units?: string; organization_name?: string };
values?: CellPatch[];
columns?: ColumnPatch[];
lines?: LinePatch[];
}
export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL';

View File

@ -4,9 +4,11 @@ import {
documentStatusLabel,
documentTypeLabel,
fileStatusLabel,
lineStatusLabel,
matchKindLabel,
periodLabel,
quarterLabel,
severityLabel,
} from './labels';
import { osvPeriodLabel, quarterShortLabel } from './labels';
@ -71,3 +73,16 @@ describe('подписи периода', () => {
expect(osvPeriodLabel('странное')).toBe('странное');
});
});
describe('подписи формы v5.19', () => {
it('статус строки', () => {
expect(lineStatusLabel('ok')).toBe('Строка формы');
expect(lineStatusLabel('unreadable')).toBe('Код не распознан');
expect(lineStatusLabel('missing')).toBe('Кода нет');
expect(lineStatusLabel('detail')).toBe('Расшифровка');
});
it('уровень замечания', () => {
expect(severityLabel('error')).toBe('Ошибка');
expect(severityLabel('info')).toBe('Информация');
});
});

View File

@ -1,4 +1,4 @@
import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, MatchKind, Quarter } from '../types/api';
import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, LineStatus, MatchKind, Quarter, Severity } from '../types/api';
const TYPE_LABELS: Record<DocumentType, string> = {
BALANCE: 'Бух. баланс',
@ -70,3 +70,14 @@ const MATCH_KIND: Record<MatchKind, string> = {
export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t;
export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен');
const LINE_STATUS: Record<LineStatus, string> = {
ok: 'Строка формы',
unreadable: 'Код не распознан',
missing: 'Кода нет',
detail: 'Расшифровка',
};
export const lineStatusLabel = (s: LineStatus): string => LINE_STATUS[s] ?? s;
const SEVERITY: Record<Severity, string> = { error: 'Ошибка', info: 'Информация' };
export const severityLabel = (s: Severity): string => SEVERITY[s] ?? s;

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,15 @@ const row = (code: string | null, name: string | null = `строка ${code}`):
name,
parentCode: null,
cells: {},
referenceName: null,
lineStatus: 'ok',
rowIndex: null,
parentRowIndex: null,
codeText: code,
codeConfidence: null,
needsReview: false,
isConfirmed: false,
codeModified: false,
});
const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode });
const shape = (rows: ReturnType<typeof formLayout>) =>

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import type { GridRow } from './statementGrid';
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid';
@ -9,13 +10,19 @@ const v = (id: string | null, column_id: string, column_index: number, extra: Pa
column_index,
amount: '1.00',
string_value: null,
text: null,
confidence: '100',
is_recognized: true,
is_modified: false,
original_amount: null,
needs_review: false,
is_confirmed: false,
...extra,
});
// Фикстура v5.19 (тип StatementOut): приведение нужно, т.к. импорт JSON расширяет строковые литералы до string
const statementV2 = statementV2Json as StatementOut;
const statement = {
columns: [
{ id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false },
@ -32,6 +39,32 @@ describe('buildStatementGrid', () => {
expect(grid.rows[0].cells.c1?.id).toBe('v1');
expect(grid.rows[0].cells.c2?.id).toBe('v2');
});
it('буквально переносит поля v2 из фикстуры', () => {
const { rows } = buildStatementGrid(statementV2);
const byKey = new Map(rows.map((row) => [row.key, row]));
expect(byKey.get('l-u7')).toMatchObject({
lineStatus: 'unreadable',
rowIndex: 7,
codeText: 'ИЧ,250',
code: null,
key: 'l-u7',
});
expect(byKey.get('l-d2')?.parentRowIndex).toBe(1);
expect(byKey.get('code-1250')).toMatchObject({
key: 'code-1250',
rowIndex: null,
referenceName: 'Денежные средства и денежные эквиваленты',
});
expect(byKey.get('l-1230')).toMatchObject({ needsReview: true, codeConfidence: '50' });
});
it('каждая пара «строка × колонка» фикстуры имеет ValueOut', () => {
const { columns, rows } = buildStatementGrid(statementV2);
for (const row of rows) {
for (const column of columns) {
expect(row.cells[column.id], `${row.key} × ${column.id}`).toBeDefined();
}
}
});
it('достроенная строка: ключ по коду, lineId = null', () => {
const withTemplate = {
...statement,
@ -51,22 +84,28 @@ describe('buildStatementGrid', () => {
});
});
describe('cellFlags', () => {
it('внимание: уверенность < 100 или не распознано', () => {
expect(cellFlags(v('a', 'c', 0, { confidence: '87.5' })).attention).toBe(true);
expect(cellFlags(v('a', 'c', 0, { is_recognized: false })).attention).toBe(true);
expect(cellFlags(v('a', 'c', 0)).attention).toBe(false);
expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false);
describe('cellFlags — внимание только по needs_review', () => {
it('низкая уверенность и is_recognized=false без needs_review — не внимание', () => {
expect(cellFlags(v('a', 'c', 0, { confidence: '50', is_recognized: false, needs_review: false })).attention).toBe(false);
});
it('needs_review=true — внимание, даже при уверенности 100', () => {
expect(cellFlags(v('a', 'c', 0, { confidence: '100', needs_review: true })).attention).toBe(true);
});
it('is_confirmed — подтверждена, уверенность не меняется', () => {
expect(cellFlags(v('a', 'c', 0, { is_confirmed: true })).confirmed).toBe(true);
});
it('undefined — все флаги false', () => {
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false, confirmed: false });
});
it('пустая достроенная ячейка не требует внимания', () => {
expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({
attention: false,
modified: false,
confirmed: false,
});
});
it('исправлено', () => {
expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true);
expect(cellFlags(undefined)).toEqual({ attention: false, modified: false });
});
});
@ -129,6 +168,15 @@ describe('parent_code и пустые строки детализации', () =
origin: 'source',
parentCode,
cells,
referenceName: null,
lineStatus: 'ok',
rowIndex: null,
parentRowIndex: null,
codeText: key,
codeConfidence: null,
needsReview: false,
isConfirmed: false,
codeModified: false,
});
it('пустая подстрока: все ячейки без суммы и без строки; родитель и нулевая сумма — не пустые', () => {
const rows = [

View File

@ -1,13 +1,26 @@
import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api';
import type { CellPatch, ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
export type GridRow = {
key: string;
lineId: string | null;
code: string | null;
/** line_name — наименование из файла */
name: string | null;
/** справочное имя строки по коду редакции */
referenceName: string | null;
origin: 'source' | 'template';
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
parentCode: string | null;
lineStatus: LineStatus;
/** порядковый номер строки в файле; null у достроенной и созданной правкой */
rowIndex: number | null;
parentRowIndex: number | null;
/** код как распознан в файле (например «ИЧ,250»), даже если `code` не распознан */
codeText: string | null;
codeConfidence: string | null;
needsReview: boolean;
isConfirmed: boolean;
codeModified: boolean;
cells: Record<string, ValueOut | undefined>;
};
@ -26,21 +39,32 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu
lineId: line.id,
code: line.line_code,
name: line.line_name,
referenceName: line.reference_name,
origin: line.origin,
parentCode: line.parent_code ?? null,
lineStatus: line.line_status,
rowIndex: line.row_index,
parentRowIndex: line.parent_row_index,
codeText: line.line_code_text,
codeConfidence: line.line_code_confidence,
needsReview: line.needs_review,
isConfirmed: line.is_confirmed,
codeModified: line.line_code_modified,
cells,
};
});
return { columns, rows };
}
/** Уверенность — строка Decimal «0..100»; сравнение с 100 — это не сумма, float здесь безопасен. */
export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } {
/**
* Флаги ячейки: внимание — только по `needs_review` (уверенность решает ядро, сама по себе не подсвечивает);
* подтверждённая ячейка не меняет уверенность, это отдельный флаг.
*/
export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean; confirmed: boolean } {
if (!value) {
return { attention: false, modified: false };
return { attention: false, modified: false, confirmed: false };
}
const lowConfidence = value.confidence !== null && Number(value.confidence) < 100;
return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified };
return { attention: value.needs_review, modified: value.is_modified, confirmed: value.is_confirmed };
}
export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`;

View File

@ -14,6 +14,7 @@ const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
counterparty_check: null,
locator: null,
comments_count: 0,
issue_counts: { error: 0, info: 0 },
...over,
});