Compare commits

..

No commits in common. "12fcea7560592855d4b4fe8efd05584fddc16255" and "ef4d32592e2df0d7583f7b10cf6bf0ffbadec573" have entirely different histories.

41 changed files with 99 additions and 1316 deletions

View File

@ -32,54 +32,5 @@
| P6-17 | `header.source.<поле>.confidence` — `string \| null` (строка Decimal, как прочие уверенности API, план 3 P3-20), в подсказке — `Number()` и округление до целого | одно правило для всех уверенностей | нет |
| P6-18 | `node_modules` worktree — симлинк на установленный каталог с eslint, prettier и vitest 3.1.4; поэтому вместо `make check` (он начинается с `npm install` и поставил бы пакеты в чужой каталог) гоняется `make lint typecheck test build` — те же проверки без установки | память владельца: симлинк, как в прежних срезах | нет; в описании PR сказано, что install пропущен |
| P6-19 | Предупреждения ОСВ-документа (`DocumentOut.issues`, `OSV_*`) — блок `IssuesPanel` над таблицей экрана ОСВ, все записи — у документа (строки ОСВ не адресуются `line_ids`) | спека: «предупреждения листа ОСВ … из `DocumentOut.issues[]`»; экран документа уже загружает `DocumentOut` | нет |
| R-1 | Шаги «сверка с макетом / прогон в браузере» (T3 s5, T4 s4, T7–T10) перенесены в T11 (дата 2026-10-18) | нет стенда ядра с v5.19, общий Playwright-браузер делят параллельные дорожки | визуальные дефекты найдутся позже, в T11 |
| R-2 | Ссылка «Показать» также у проверок со статусом `soft_failed` (дата 2026-10-18) | согласованное расширение: нарушение так же адресуется строкой | только показ |
| R-3 | Компонент меню строки назван `LineMenuButton.tsx`, а не `LineMenu.tsx` (дата 2026-10-18) | на регистронезависимой ФС (macOS) `LineMenu.tsx` и `lineMenu.ts` дают TS1149 | нет |
| O-1 | Два MODIFIED-требования («Форма ББ/ОФР — просмотр и правка», «Подтверждение документа») составлены исполнителем из текущей спеки: в docs-спеке `arm-web` все требования ADDED. «Предупреждения ОСВ-документа» (P6-19) в docs-спеке нет; написано по плану. Сверить в arm-ka-docs#116 (дата 2026-10-18) | иначе change нельзя было бы описать дельтой | расхождение формулировок со спекой docs при архивации |
| T1.D-1 | `cellFlags` возвращает ещё и `confirmed`; двум старым ожиданиям добавлен `confirmed: false`; тест «внимание по уверенности» заменён на «внимание только по needs_review» (дата 2026-10-18) | `toEqual` падает на новом ключе; так велит бриф | только тесты |
| T1.D-2 | В тестовый хелпер `doc()` добавлен `issue_counts: {error: 0, info: 0}` (дата 2026-10-18) | поле обязательно в `DocumentBrief`, иначе падает tsc | нет |
| T1.D-3 | Значения фикстуры `statement-v2.json`, которых нет в брифе, придуманы исполнителем (форматы сумм, уверенности, `text`, UUID, `codes`, сообщения, `is_current`, пустая колонка c2, порядок `edition_lines`) (дата 2026-10-18) | фикстура должна быть полной | тесты завязаны на выдуманные значения; реальные данные — в T11 |
| T1.D-4 | Фикстура приведена к `StatementOut` через `as` в тесте (дата 2026-10-18) | импорт JSON расширяет литералы до `string`; полноту проверяет рантайм-тест пар «строка × колонка» | слабая проверка типов фикстуры |
| T1.D-5 | Тест «переносит поля v2» проверяет ключи по `l-u7`, `code-1250`, `l-1230`, `l-d2` (дата 2026-10-18) | ровно как в брифе | нет |
| T3.D-1 | Несколько строк подсказки кода склеиваются переводом строки; у `codeModified` уверенность не показывается (дата 2026-10-18) | читаемость подсказки | только вид |
| T3.D-2 | Подсказка сумм принимает необязательный `issues` (добавляет message) (дата 2026-10-18) | задел для T4 | нет |
| T3.D-3 | Уверенность округляется `Math.round(Number())` (P6-17) (дата 2026-10-18) | одно правило для всех уверенностей | нет |
| T3.D-4 | В режиме правки у подтверждённой ячейки галочка — `prefix` поля ввода (дата 2026-10-18) | ячейка в режиме правки — Input | наложение значков, см. T8.D-3 |
| T3.D-5 | `id` строки `row-<key>` выставляется только на строках-линиях, не на заголовках разделов (дата 2026-10-18) | якорь прокрутки нужен только строкам | нет |
| T4.D-1 | Подписи мест полей шапки: organization_name — «Организация», inn — «ИНН», report_date — «Дата отчёта», units — «Единица измерения»; неизвестное поле — своим кодом, запись уходит в `document` (дата 2026-10-18) | P6-5 | только вид |
| T4.D-2 | Запись с `field` из блока шапки всегда в `byField`, независимо от `line_ids` (дата 2026-10-18) | поле шапки — самостоятельное место | нет |
| T4.D-3 | `warning` с `line_ids` без `column_id` — место «строка …», с `column_id` — «<строка>, колонка «…»»; `relation` / `soft` в список не входят (дата 2026-10-18) | P6-5 | только вид |
| T4.D-5 | `onShow` панелей передаёт ключ строки; для достроенных строк (`id = null`) перехода нет — `line_ids` их не содержат (дата 2026-10-18) | нет id — нет адреса | нет ссылки «Показать» у достроенной строки |
| T4.D-6 | Значок у наименования строки — только по `byRow`; записи `byCell` дают значок только в ячейке (дата 2026-10-18) | не дублировать | только вид |
| T5.D-1 | Кнопка «Подтвердить дату» рисуется только при переданном `onConfirmDate` (дата 2026-10-18) | иначе мёртвая кнопка | нет |
| T5.D-2 | У организации в подсказке при modified — «Распознано: «…»»; нет `source` — подсказки нет (дата 2026-10-18) | нечего показывать | нет |
| T5.D-3 | Уверенность в подсказке шапки через `Number()` ('67.00' → 67%) (дата 2026-10-18) | P6-17 | нет |
| T5.D-4 | Placeholder организации — «Наименование организации» (текст не из плана) (дата 2026-10-18) | поле в режиме правки должно быть подписано | только вид |
| T6.D-1 | `buildStatementPatch` для суммы в неизвестную строку возвращает ошибку `amount` (раньше бросалось исключение) (дата 2026-10-18) | экран не должен падать | нет |
| T6.D-2 | Подтверждение ячейки и действие строки у строк без `id` молча пропускаются (дата 2026-10-18) | UI их не создаёт (P6-6, P6-7) | молчаливая потеря действия при ошибке UI |
| T6.D-3 | Код сравнивается после `trim`; пустой или равный текущему — не действие и не ошибка, проверка до формата (дата 2026-10-18) | P6-7 | нет |
| T6.D-4 | Конфликт P6-8 ищется по сумме в шаблонную строку (`id = null`) с тем же кодом; `key` ошибки — `lineId` строки с действием (дата 2026-10-18) | P6-8 | нет |
| T6.D-5 | Тексты ошибок: «Наименование организации — не длиннее 1000 символов» (длина организации); «Исправьте суммы с неверным форматом» (суммы, как раньше) (дата 2026-10-18) | тексты не из плана | только вид |
| T6.D-6 | `confirm_report_date` при отсутствии текущей даты ничего не отправляет (дата 2026-10-18) | нечего подтверждать | пустое тело PATCH, «Сохранить» молчит |
| T6.D-7 | Ошибка `amount` подсвечивает ячейку красной рамкой и сама по себе (дата 2026-10-18) | раньше подсветка шла только по разбору формата | только вид |
| T7.D-2 | Подпись пункта меню: «Это расшифровка» для `unreadable` / `missing`, «Сделать расшифровкой» для `ok` (дата 2026-10-18) | разные смыслы действия | только вид |
| T7.D-3 | Меню строки у `ok` — только при `origin = 'source'`; шаблонные строки с id меню не получают (дата 2026-10-18) | бриф | нет |
| T7.D-4 | `lineErrorTarget` читает `ApiError.body.detail` (отдельного поля `detail` у `ApiError` нет) (дата 2026-10-18) | иначе деталей ошибки не достать | нет |
| T7.D-5 | Ошибка сервера сбрасывается любой правкой черновика (в `setDraft`) (дата 2026-10-18) | иначе устаревшая ошибка висит после правки | нет |
| T7.D-6 | У `unreadable` / `missing` с действием detail / confirm поле кода остаётся, рядом тег (дата 2026-10-18) | бриф: поле всегда | см. T7.2: очистка поля теряет действие |
| T8.D-1 | Подтверждение ячейки без `id` значения молча пропускается в `buildStatementPatch`; кнопка не рисуется (`cellConfirmState → null`) (дата 2026-10-18) | P6-6 | нет |
| T8.D-2 | Ожидающая кнопка даты — disabled с текстом «Дата подтверждена (до сохранения)»; без ожидания «Подтвердить дату»; отменить подтверждение даты можно только отменой всей правки. Для колонки ожидание = черновая дата равна `period_date`. Затем текст подсказки при нажатой кнопке подтверждения ячейки: «Подтверждение будет сохранено — нажмите, чтобы убрать» (дата 2026-10-18) | пользователь должен видеть, что подтверждение в черновике | текст не из плана; ширина колонки (T8.2) проверяется в T11 |
| T8.D-3 | При значке записи у Input `paddingLeft: 14`, чтобы значок не перекрывал галочку (дата 2026-10-18) | без браузера не проверено | визуальное наложение; проверка в T11 |
| T8.D-4 | `cellConfirmState` возвращает `pending-unconfirm` при `confirms[key] === false` (дата 2026-10-18) | нужно третье состояние | нет |
| T8.D-5 | Подтверждение ячейки только строкам с `lineId` (дата 2026-10-18) | P6-6, P6-7 | нет |
| T9.D-1 | «Значения есть» = `amount !== null || string_value !== null` по любой ячейке; `detail` не блокирует независимо от значений (дата 2026-10-18) | P6-10 | расхождение с условием ядра |
| T9.D-2 | Ключ ссылки в 409 `LINES_UNRESOLVED` — `line_id` (дата 2026-10-18) | ключ GridRow для строк из БД | ссылка молчит, если строки нет в таблице (T9.6) |
| T9.D-3 | Описание Alert подтверждения собирается императивно (`confirmDescription`); приоритет: failures > lines > duplicate (дата 2026-10-18) | линтер запрещает вложенные тернарники | только вид |
| T10.D-1 | `toFileWarnings` отбрасывает объекты без `code` / `message` или с `severity` не `error` / `info` (дата 2026-10-18) | P6-13 | нет |
| T10.D-2 | В режиме `document` у `IssuesPanel` колонка «место» не показывается; `OsvDocument` кладёт place '' / targetKey null (дата 2026-10-18) | у документа нет места | нет |
| T10.D-3 | Подробности документа в поповере грузятся при открытии (`trigger=click`), состояние сбрасывается при закрытии (дата 2026-10-18) | P6-12 | лишний запрос при повторном открытии |
| T10.D-4 | Значок — `<button>` под плашкой статуса; без `documentId` раздел «Предупреждения документа» не показывается (дата 2026-10-18) | правила a11y | нет |
| T10.D-5 | Пустой список в разделе «Предупреждения документа» — «Нет» (дата 2026-10-18) | текст не из плана | только вид |
| UI-1 | Тексты, которых нет в плане: «Дата подтверждена (до сохранения)», «Подтверждение будет сохранено — нажмите, чтобы убрать», «Строка не найдена в форме», «Наименование организации — не длиннее 1000 символов» (дата 2026-10-18) | нужны для состояний, плана для них не было | только формулировки |
Решения контроллера и исполнителей поверх плана (`R-n`, `O-n`) дописываются сюда по ходу исполнения.

View File

@ -33,7 +33,7 @@ column_id, amount}`; ответ заменяет состояние экрана
- **THEN** запрос не отправляется, поле показывает ошибку формата
### Requirement: Подтверждение документа
Кнопка «Подтвердить данные» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`, а
Кнопка «Подтвердить» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`, а
также при наличии строк `unreadable` / `missing` с суммой или нераспознанным текстом в ячейках (подсказка «Разберите
строки без кода: N» с переходом к первой такой строке). При 409 `CHECKS_NOT_PASSED` экран SHALL показать
`detail.failures[]` и предложить акцепт; при 409 `PERIOD_REQUIRED` — подсказать исправить дату отчёта; при 409

View File

@ -214,19 +214,6 @@ export interface LineOut {
is_confirmed: boolean;
values: ValueOut[];
}
/** Элемент detail 422/409 по строке: LINE_CODE_TAKEN, LINE_CODE_REQUIRED и др. */
export interface LineErrorDetail {
code: string;
message: string;
line_id: string;
line_code: string | null;
}
/** Строка без кода в detail.lines ответа 409 LINES_UNRESOLVED. */
export interface UnresolvedLineOut {
line_id: string;
row_index: number | null;
line_code_text: string | null;
}
export interface FailedCheckOut {
column_index: number;
relation: string;

View File

@ -1,15 +0,0 @@
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();
});
});

View File

@ -1,6 +0,0 @@
/** Уверенность 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

@ -22,7 +22,7 @@ const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) =>
{list.map((f) => (
<div key={`${f.column_index}-${f.relation}`}>
{failureText(f)}
{onShow && (f.line_ids ?? []).length > 0 && (
{onShow && f.line_ids.length > 0 && (
<Button type='link' size='small' onClick={() => onShow(f.line_ids[0])}>
Показать
</Button>

View File

@ -1,71 +1,20 @@
import { AutoComplete, Tag, Tooltip, Typography } from 'antd';
import { Tooltip, Typography } from 'antd';
import { codeCellView } from './cellView';
import { type CodeOption, filterCodeOptions } from './codeSuggestions';
import { showsCodeInput } from './lineMenu';
import type { LineAction } from './statementDraft';
import type { GridRow } from './statementGrid';
/** Правка кода строки: действие черновика, справочник подсказок и ошибка (формат / ответ сервера). */
export type CodeEditing = {
action: LineAction | undefined;
options: CodeOption[];
error?: string;
onCodeChange: (code: string) => void;
};
const actionTag = (action: LineAction | undefined) => {
if (action?.kind === 'detail') return '→ расшифровка';
if (action?.kind === 'confirm') return action.confirmed ? '→ код подтверждён' : '→ подтверждение снято';
return null;
};
/** Ячейка кода: просмотр (код, у нераспознанного — серый сырой текст) или, в режиме правки, поле кода с подсказками. */
export function CodeCell({ row, fontWeight, editing }: { row: GridRow; fontWeight?: number; editing?: CodeEditing }) {
/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */
export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) {
const view = codeCellView(row);
const tag = editing ? actionTag(editing.action) : null;
if (editing && showsCodeInput(row, editing.action)) {
const value = editing.action?.kind === 'code' ? editing.action.code : '';
return (
<Tooltip title={editing.error}>
<span>
<AutoComplete
size='small'
style={{ width: 140 }}
status={editing.error ? 'error' : undefined}
value={value}
placeholder='Код'
options={filterCodeOptions(editing.options, value).map((o) => ({ value: o.code, label: `${o.code} — ${o.name}` }))}
onChange={editing.onCodeChange}
/>
{tag && (
<Tag style={{ marginTop: 2 }} variant='filled'>
{tag}
</Tag>
)}
</span>
</Tooltip>
);
}
const content = view.muted ? (
<Typography.Text type='secondary'>{view.text}</Typography.Text>
) : (
<span style={{ fontWeight }}>{view.text}</span>
);
const withTag = tag ? (
<>
{content}
<Tag style={{ marginLeft: 4 }} variant='filled'>
{tag}
</Tag>
</>
) : (
content
);
if (!view.tip) return withTag;
if (!view.tip) return content;
return (
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{view.tip}</span>}>
{/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
<span style={{ display: 'inline-block', minWidth: 16 }}>{withTag}</span>
<span style={{ display: 'inline-block', minWidth: 16 }}>{content}</span>
</Tooltip>
);
}

View File

@ -1,60 +0,0 @@
import { Button, Input, Tooltip } from 'antd';
import type { ColumnOut, IssueOut } from '../../types/api';
import { SeverityIcon } from './IssuesPanel';
import { columnDateEditable } from './editRules';
import { worstSeverity } from './issuePlaces';
import { columnTitle } from './reviewView';
type Props = {
col: ColumnOut;
issues: IssueOut[];
editing: boolean;
/** введённая, но не сохранённая дата колонки */
draftDate?: string;
error?: boolean;
onDateChange?: (columnId: string, text: string) => void;
/** «Подтвердить дату»: кладёт в черновик текущую дату колонки */
onConfirmDate?: (col: ColumnOut) => void;
};
/** Заголовок колонки: подпись, значок предупреждения и (в режиме правки) поле даты ГГГГ-ММ-ДД с подтверждением. */
export function ColumnHeader({ col, issues, editing, draftDate, error = false, onDateChange, onConfirmDate }: Props) {
const worst = worstSeverity(issues);
const title = (
<span>
{worst && <SeverityIcon severity={worst} style={{ marginRight: 4 }} />}
{columnTitle(col)}
</span>
);
const withTip = worst ? (
<Tooltip
title={issues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
{title}
</Tooltip>
) : (
title
);
const { editable, canConfirm } = columnDateEditable(col, issues);
if (!editing || !editable) return withTip;
const pending = canConfirm && draftDate !== undefined && draftDate === col.period_date;
return (
<div>
{withTip}
<Input
size='small'
status={error ? 'error' : undefined}
placeholder='ГГГГ-ММ-ДД'
value={draftDate ?? col.period_date ?? ''}
onChange={(e) => onDateChange?.(col.id, e.target.value)}
style={{ marginTop: 4, fontWeight: 400 }}
/>
{canConfirm && (
<Button size='small' style={{ marginTop: 4 }} disabled={pending} onClick={() => onConfirmDate?.(col)}>
{pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
</Button>
)}
</div>
);
}

View File

@ -13,14 +13,10 @@ export function SeverityIcon({ severity, style }: { severity: Severity; style?:
);
}
type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: (key: string) => void };
type Props = { items: IssueListItem[]; onShow?: (key: string) => void };
/**
* Список предупреждений; пустой — не показывается. Длинный список прокручивается, как список нарушений.
* `statement` — форма ББ/ОФР: место записи и ссылка «Показать»; `document` (ОСВ, P6-19) — простые пункты без места и ссылок.
*/
export function IssuesPanel({ items, mode, onShow }: Props) {
const plain = mode === 'document';
/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */
export function IssuesPanel({ items, onShow }: Props) {
if (items.length === 0) return null;
return (
<Alert
@ -32,9 +28,8 @@ export function IssuesPanel({ items, mode, onShow }: Props) {
{items.map((item) => (
<div key={item.id}>
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
{item.message}
{!plain && ` (${item.place})`}
{!plain && item.targetKey !== null && onShow && (
{item.message} ({item.place})
{item.targetKey !== null && onShow && (
<Button type='link' size='small' onClick={() => onShow(item.targetKey as string)}>
Показать
</Button>

View File

@ -1,24 +0,0 @@
import { MoreOutlined } from '@ant-design/icons';
import { Button, Dropdown } from 'antd';
import { type LineMenuKey, lineMenuItems } from './lineMenu';
import type { LineAction } from './statementDraft';
import type { GridRow } from './statementGrid';
/** Меню строки в режиме правки: кнопка «⋮» рядом с наименованием. Пусто — кнопки нет. */
export function LineMenuButton({
row,
action,
onSelect,
}: {
row: GridRow;
action: LineAction | undefined;
onSelect: (key: LineMenuKey) => void;
}) {
const items = lineMenuItems(row, action);
if (items.length === 0) return null;
return (
<Dropdown trigger={['click']} menu={{ items, onClick: ({ key }) => onSelect(key as LineMenuKey) }}>
<Button type='text' size='small' icon={<MoreOutlined />} aria-label='Действия со строкой' style={{ marginLeft: 4 }} />
</Dropdown>
);
}

View File

@ -6,7 +6,6 @@ import { ApiError } from '../../api/client';
import { confirmDocument, getAllEntries } from '../../services/api/documents';
import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
import { formatAmount } from '../../utils/money';
import { IssuesPanel } from './IssuesPanel';
import { confirmButtonStyle } from './ReviewCard';
import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
@ -117,11 +116,6 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
// колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу»
const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
// предупреждения документа (P6-19): все записи issues, без привязки к строкам
const issueItems = useMemo(
() => (document.issues ?? []).map((i) => ({ id: i.id, severity: i.severity, message: i.message, place: '', targetKey: null })),
[document.issues],
);
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
const confirm = async () => {
@ -171,7 +165,6 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/>
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
</div>
<IssuesPanel mode='document' items={issueItems} />
{problem && (
<Alert
type='error'

View File

@ -36,9 +36,8 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
}
/**
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица из четырёх полей — организация, ИНН,
* дата отчёта, единица измерения (в режиме правки поля редактируются; дату из `needs_review_fields` можно подтвердить
* кнопкой); дальше заголовок формы. Реквизиты бумажной формы из макета [41]
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения —
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
*/
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) {
@ -74,8 +73,8 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
onChange={(e) => onDraftChange(r.field, e.target.value)}
/>
{r.canConfirm && onConfirmDate && (
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}>
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
<Button size='small' style={{ marginTop: 4 }} onClick={onConfirmDate}>
Подтвердить дату
</Button>
)}
</>

View File

@ -1,10 +1,9 @@
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Splitter, Switch, Typography, message } from 'antd';
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client';
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
import type { DocumentOut, FailedCheckOut, StatementOut } from '../../types/api';
import { parseAmountInput } from '../../utils/money';
import { ChecksPanel, failureText } from './ChecksPanel';
import { IssuesPanel } from './IssuesPanel';
@ -12,13 +11,10 @@ import { ReviewCard, confirmButtonStyle } from './ReviewCard';
import { SourcePreview } from './SourcePreview';
import { StatementHeaderBlock } from './StatementHeaderBlock';
import { StatementTable } from './StatementTable';
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
import { confirmErrorInfo } from './confirmError';
import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces';
import { applyLineMenu, showsCodeInput } from './lineMenu';
import { reviewActions } from './reviewView';
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
@ -35,21 +31,15 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const [saving, setSaving] = useState(false);
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
const [confirmLines, setConfirmLines] = useState<UnresolvedLineOut[]>([]);
const [duplicateId, setDuplicateId] = useState<string | null>(null);
const navigate = useNavigate();
const [showEmptySublines, setShowEmptySublines] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [highlightKey, setHighlightKey] = useState<string | null>(null);
// ошибка элемента lines[] из ответа на сохранение (P6-9); строки на экране нет — текст над таблицей
const [lineServerError, setLineServerError] = useState<{ lineId: string; text: string } | null>(null);
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
const highlightTimer = useRef<number | undefined>(undefined);
const setDraft = (update: (prev: StatementDraft) => StatementDraft) => {
setDraftState(update);
setDraftErrors([]);
setLineServerError(null);
};
const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]);
@ -68,35 +58,15 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts) : new Set<string>()), [grid, draft]);
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
const layout = useMemo(
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
[fullLayout, showDetails, showEmptySublines, emptySublines],
);
const codeOptions = useMemo(() => (statement && grid ? buildCodeOptions(statement.edition_lines, grid.rows) : []), [statement, grid]);
// красные поля кода: ошибки формата из черновика и текст сервера
const lineErrors = useMemo(() => {
const result: Record<string, string> = {};
for (const error of draftErrors) if (error.kind === 'code' || error.kind === 'conflict') result[error.key] = error.message;
if (lineServerError) result[lineServerError.lineId] = lineServerError.text;
return result;
}, [draftErrors, lineServerError]);
const serverErrorOffScreen =
lineServerError !== null &&
!layout.some(
(item) =>
item.kind === 'line' &&
item.row.lineId === lineServerError.lineId &&
editing &&
showsCodeInput(item.row, draft.lines[lineServerError.lineId]),
);
const places = useMemo(
() => (grid && statement ? issuePlaces(statement.issues ?? [], grid.rows, grid.columns) : null),
[grid, statement],
);
const places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]);
const issueItems = useMemo(
() => (grid && statement && places ? issueListItems(statement.issues ?? [], places, grid.rows, grid.columns) : []),
() => (grid && statement && places ? issueListItems(statement.issues, places, grid.rows, grid.columns) : []),
[grid, statement, places],
);
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM
@ -117,7 +87,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}, [pendingFocus]);
useEffect(() => () => window.clearTimeout(highlightTimer.current), []);
const details = grid ? detailCount(grid.rows) : 0;
const unresolved = useMemo(() => (grid ? unresolvedLines(grid.rows) : []), [grid]);
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
@ -143,12 +112,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
setEditing(false);
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
message.success('Изменения сохранены');
onChanged();
} catch (e: unknown) {
const target = lineErrorTarget(e);
if (target) setLineServerError({ lineId: target.lineId, text: errorText(e, 'Не удалось сохранить строку') });
message.error(errorText(e, 'Не удалось сохранить изменения'));
} finally {
setSaving(false);
@ -166,32 +132,23 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
onChanged();
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
} catch (e: unknown) {
message.error(errorText(e, 'Не удалось принять нарушения'));
throw e;
}
};
const resetConfirmError = () => {
setConfirmLines([]);
setDuplicateId(null);
};
const confirm = async () => {
try {
await confirmDocument(document.id);
message.success('Документ подтверждён');
setConfirmFailures([]);
setConfirmMessage(null);
resetConfirmError();
onChanged();
await load();
} catch (e: unknown) {
const info = confirmErrorInfo(e);
setConfirmFailures(info.failures);
setConfirmLines(info.lines);
setDuplicateId(info.code === 'DUPLICATE_FORM' ? info.documentId : null);
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
}
};
@ -203,19 +160,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
return null;
}
let confirmDescription: ReactNode;
if (confirmFailures.length > 0) {
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
} else if (confirmLines.length > 0) {
confirmDescription = (
<Typography.Link onClick={() => focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)}</Typography.Link>
);
} else if (duplicateId) {
confirmDescription = (
<Typography.Link onClick={() => navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму</Typography.Link>
);
}
const confirmEnabled = actions.canConfirm && unresolved.length === 0;
const cardActions = editing ? (
<Space>
<Button
@ -237,14 +181,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<Button
type='primary'
icon={<CheckOutlined />}
style={confirmEnabled ? confirmButtonStyle : undefined}
disabled={!confirmEnabled}
style={actions.canConfirm ? confirmButtonStyle : undefined}
disabled={!actions.canConfirm}
onClick={confirm}>
Подтвердить данные
</Button>
{unresolved.length > 0 && (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
)}
</Space>
);
@ -270,8 +211,19 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))}
/>
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
<IssuesPanel mode='statement' items={issueItems} onShow={focusRow} />
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
<IssuesPanel items={issueItems} onShow={focusRow} />
{confirmMessage && (
<Alert
type='error'
showIcon
title={confirmMessage}
description={
confirmFailures.length > 0
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
: undefined
}
/>
)}
{emptySublines.size > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
@ -288,7 +240,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
</label>
</span>
)}
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
<StatementTable
columns={grid.columns}
layout={layout}
@ -296,28 +247,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
highlightKey={highlightKey}
editable={editing && actions.canSave}
draft={draft.amounts}
lineActions={draft.lines}
codeOptions={codeOptions}
lineErrors={lineErrors}
onLineCodeChange={(row, code) =>
row.lineId && setDraft((prev) => ({ ...prev, lines: { ...prev.lines, [row.lineId as string]: { kind: 'code', code } } }))
}
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
errorKeys={errorKeys}
fullDraft={draft}
onConfirmToggle={(key, state) =>
setDraft((prev) => {
const confirms = { ...prev.confirms };
// повторный клик по нажатой кнопке убирает подтверждение из черновика
if (state === 'pending-confirm' || state === 'pending-unconfirm') delete confirms[key];
else confirms[key] = state === 'confirm';
return { ...prev, confirms };
})
}
onColumnDateChange={(columnId, text) => setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [columnId]: text } }))}
onColumnDateConfirm={(col) =>
col.period_date && setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [col.id]: col.period_date as string } }))
}
onDraftChange={(key, text, original) =>
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
}

View File

@ -1,19 +1,14 @@
import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut } from '../../types/api';
import type { ColumnOut, IssueOut } from '../../types/api';
import { CodeCell } from './CodeCell';
import { ColumnHeader } from './ColumnHeader';
import { SeverityIcon } from './IssuesPanel';
import { LineMenuButton } from './LineMenuButton';
import { ValueCell } from './ValueCell';
import { COLORS, rowBackground, valueBackground } from './cellView';
import type { CodeOption } from './codeSuggestions';
import { type CellConfirmState, cellConfirmState } from './editRules';
import type { LayoutRow } from './formLayout';
import { type IssuePlaces, worstSeverity } from './issuePlaces';
import type { LineMenuKey } from './lineMenu';
import type { LineAction, StatementDraft } from './statementDraft';
import { type GridRow, cellKey } from './statementGrid';
import { columnTitle } from './reviewView';
import { cellKey } from './statementGrid';
type Props = {
columns: ColumnOut[];
@ -27,17 +22,6 @@ type Props = {
places: IssuePlaces;
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
highlightKey?: string | null;
/** действия над строками из черновика, подсказки кода и ошибки полей кода (lineId → текст) */
lineActions?: Record<string, LineAction>;
codeOptions?: CodeOption[];
lineErrors?: Record<string, string>;
onLineCodeChange?: (row: GridRow, code: string) => void;
onLineMenu?: (row: GridRow, key: LineMenuKey) => void;
/** весь черновик: подтверждения ячеек и даты колонок (суммы — в `draft` выше) */
fullDraft?: StatementDraft;
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
onColumnDateChange?: (columnId: string, text: string) => void;
onColumnDateConfirm?: (col: ColumnOut) => void;
};
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
@ -47,29 +31,27 @@ function lineWeight(r: LayoutRow): number | undefined {
if (r.grand) return 700;
return r.total ? 600 : undefined;
}
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && (r.row.lineStatus === 'unreadable' || r.row.lineStatus === 'missing');
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined;
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
export function StatementTable({
columns,
layout,
editable,
draft,
onDraftChange,
errorKeys,
places,
highlightKey = null,
lineActions = {},
codeOptions = [],
lineErrors = {},
onLineCodeChange,
onLineMenu,
fullDraft,
onConfirmToggle,
onColumnDateChange,
onColumnDateConfirm,
}: Props) {
function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
const worst = worstSeverity(issues);
if (!worst) return <>{columnTitle(col)}</>;
return (
<Tooltip
title={issues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
<span>
<SeverityIcon severity={worst} style={{ marginRight: 4 }} />
{columnTitle(col)}
</span>
</Tooltip>
);
}
export function StatementTable({ columns, layout, editable, draft, onDraftChange, errorKeys, places, highlightKey = null }: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
@ -98,19 +80,7 @@ export function StatementTable({
)}
</span>
);
const namedText = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
const named = editable ? (
<>
{namedText}
<LineMenuButton
row={r.row}
action={r.row.lineId ? lineActions[r.row.lineId] : undefined}
onSelect={(key) => onLineMenu?.(r.row, key)}
/>
</>
) : (
namedText
);
const named = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
if (!rowWorst) return named;
return (
<>
@ -130,37 +100,14 @@ export function StatementTable({
{
key: 'code',
title: 'Код',
width: editable ? 160 : 60,
width: 60,
align: 'center' as const,
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
render: (_: unknown, r: LayoutRow) => {
if (r.kind !== 'line') return null;
const lineId = r.row.lineId;
const editing =
editable && lineId
? {
action: lineActions[lineId],
options: codeOptions,
error: lineErrors[lineId],
onCodeChange: (code: string) => onLineCodeChange?.(r.row, code),
}
: undefined;
return <CodeCell row={r.row} fontWeight={lineWeight(r)} editing={editing} />;
},
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <CodeCell row={r.row} fontWeight={lineWeight(r)} /> : null),
},
...columns.map((col) => ({
key: col.id,
title: (
<ColumnHeader
col={col}
issues={places.byColumn.get(col.id) ?? []}
editing={editable}
draftDate={fullDraft?.columns[col.id]}
error={errorKeys?.has(col.id)}
onDateChange={onColumnDateChange}
onConfirmDate={onColumnDateConfirm}
/>
),
title: <ColumnTitle col={col} issues={places.byColumn.get(col.id) ?? []} />,
width: 112,
align: 'right' as const,
onCell: (r: LayoutRow) =>
@ -188,8 +135,6 @@ export function StatementTable({
onDraftChange={onDraftChange}
hasError={errorKeys?.has(draftKey)}
issues={places.byCell.get(draftKey)}
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
onConfirmToggle={onConfirmToggle}
/>
);
},

View File

@ -1,10 +1,9 @@
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'antd';
import { CheckOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { SeverityIcon } from './IssuesPanel';
import { cellTipLines } from './cellView';
import type { CellConfirmState } from './editRules';
import { worstSeverity } from './issuePlaces';
type Props = {
@ -18,37 +17,10 @@ type Props = {
issues?: IssueOut[];
/** ошибка черновика, найденная при сохранении */
hasError?: boolean;
/** какая кнопка подтверждения нужна ячейке (editRules.cellConfirmState) и обработчик её нажатия */
confirmState?: CellConfirmState;
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
};
const CONFIRM_TITLE = {
confirm: 'Подтвердить значение',
unconfirm: 'Снять подтверждение',
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
} as const;
// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке
const CONFIRM_LABEL = {
confirm: 'Подтвердить значение',
'pending-confirm': 'Подтвердить значение',
unconfirm: 'Снять подтверждение',
'pending-unconfirm': 'Снять подтверждение',
} as const;
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({
value,
editable,
draftKey,
draft,
onDraftChange,
issues = [],
hasError = false,
confirmState = null,
onConfirmToggle,
}: Props) {
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [], hasError = false }: Props) {
const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
@ -67,10 +39,9 @@ export function ValueCell({
}
const text = draft[draftKey] ?? value.amount ?? '';
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}>
<span>
{corner}
<Input
size='small'
@ -78,21 +49,8 @@ export function ValueCell({
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
value={text}
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
style={{ textAlign: 'right' }}
/>
{confirmState && (
<Tooltip title={CONFIRM_TITLE[confirmState]}>
<Button
size='small'
type={pending ? 'primary' : 'default'}
aria-pressed={pending}
aria-label={CONFIRM_LABEL[confirmState]}
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
/>
</Tooltip>
)}
</span>
</Tooltip>
);

View File

@ -57,14 +57,6 @@ 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»',
@ -93,12 +85,6 @@ 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,7 +1,6 @@
// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются.
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 = {
@ -25,15 +24,16 @@ 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 && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`);
const confidence = formatPercent(value.confidence);
if (confidence !== null) lines.push(`Уверенность: ${confidence}`);
if (value.text !== null) lines.push(`Распознано: «${value.text}»`);
if (value.confidence !== null) lines.push(`Уверенность: ${percent(value.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,8 +55,7 @@ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip:
if (row.codeModified) {
tips.push('Код назначен аналитиком');
} else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) {
const confidence = formatPercent(row.codeConfidence);
if (confidence !== null) tips.push(`Уверенность кода: ${confidence}`);
tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`);
}
if (row.isConfirmed) tips.push('Код подтверждён');
return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined };

View File

@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import type { StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { codeOptions, filterCodeOptions, lineErrorTarget } from './codeSuggestions';
import { type GridRow, buildStatementGrid } from './statementGrid';
const statement = statementV2Json as StatementOut;
const { rows } = buildStatementGrid(statement);
describe('codeOptions', () => {
it('сначала справочник по порядку, коды без повторов', () => {
const options = codeOptions(statement.edition_lines, rows);
expect(options.slice(0, 3).map((o) => o.code)).toEqual(['1110', '1150', '1170']);
expect(options.filter((o) => o.code === '1250')).toEqual([{ code: '1250', name: 'Денежные средства и денежные эквиваленты' }]);
expect(options.filter((o) => o.code === '1370')).toHaveLength(1);
});
it('код ok-строки файла вне справочника — в конце, с её именем', () => {
const base = rows.find((r) => r.key === 'l-1210');
if (!base) throw new Error('нет строки l-1210');
const extra: GridRow = { ...base, key: 'x', code: '1231', name: 'Прочая задолженность', referenceName: null };
const options = codeOptions(statement.edition_lines, [...rows, extra]);
expect(options.map((o) => o).pop()).toEqual({ code: '1231', name: 'Прочая задолженность' });
});
it('справочник пуст — только коды ok-строк; unreadable и detail не попадают', () => {
const options = codeOptions([], rows);
expect(options.length).toBeGreaterThan(0);
expect(options.map((o) => o.code)).toContain('1210');
expect(options.every((o) => /^\d{4,5}$/.test(o.code))).toBe(true);
const codes = options.map((o) => o.code);
expect(new Set(codes).size).toBe(codes.length);
});
});
describe('filterCodeOptions', () => {
const options = codeOptions(statement.edition_lines, rows);
it('по началу кода', () => {
const found = filterCodeOptions(options, '12');
expect(found.length).toBeGreaterThan(0);
expect(found.every((o) => o.code.startsWith('12'))).toBe(true);
});
it('по названию без учёта регистра и ё/е', () => {
expect(filterCodeOptions(options, 'денеж').map((o) => o.code)).toEqual(['1250']);
expect(filterCodeOptions(options, 'ДЕНЁЖ').map((o) => o.code)).toEqual(['1250']);
});
it('пустой запрос — первые 20', () => {
const many = Array.from({ length: 30 }, (_, i) => ({ code: String(1000 + i), name: '' }));
expect(filterCodeOptions(many, '')).toHaveLength(20);
expect(filterCodeOptions(many, '', 5)).toHaveLength(5);
});
});
describe('lineErrorTarget', () => {
it('409 LINE_CODE_TAKEN → строка и код', () => {
const body = { detail: { code: 'LINE_CODE_TAKEN', message: 'Код занят', line_id: 'l-u7', line_code: '1170' } };
expect(lineErrorTarget(new ApiError(409, 'LINE_CODE_TAKEN', 'Код занят', body))).toEqual({ lineId: 'l-u7', lineCode: '1170' });
});
it('422 INVALID_LINE_PATCH с line_code null', () => {
const body = { detail: { code: 'INVALID_LINE_PATCH', message: 'm', line_id: 'l-u7', line_code: null } };
expect(lineErrorTarget(new ApiError(422, 'INVALID_LINE_PATCH', 'm', body))).toEqual({ lineId: 'l-u7', lineCode: null });
});
it('LINES_UNRESOLVED и массив pydantic → null', () => {
const unresolved = { detail: { code: 'LINES_UNRESOLVED', message: 'm', lines: [{ line_id: 'a' }] } };
expect(lineErrorTarget(new ApiError(409, 'LINES_UNRESOLVED', 'm', unresolved))).toBeNull();
expect(lineErrorTarget(new ApiError(422, undefined, 'msg', { detail: [{ msg: 'msg' }] }))).toBeNull();
});
it('не ApiError → null', () => {
expect(lineErrorTarget(new Error('x'))).toBeNull();
});
});

View File

@ -1,46 +0,0 @@
import { ApiError } from '../../api/client';
import type { EditionLineOut, LineErrorDetail } from '../../types/api';
import type { GridRow } from './statementGrid';
export type CodeOption = { code: string; name: string };
const LIST_LIMIT = 20;
/**
* Подсказки кода (P6-1): сначала справочник редакции по порядку, затем коды строк `ok` файла вне справочника.
* Без повторов; строки без кода (unreadable / missing / detail) не попадают.
*/
export function codeOptions(editionLines: EditionLineOut[], rows: GridRow[]): CodeOption[] {
const result: CodeOption[] = [];
const seen = new Set<string>();
for (const line of editionLines) {
if (seen.has(line.code)) continue;
seen.add(line.code);
result.push({ code: line.code, name: line.name });
}
for (const row of rows) {
if (row.lineStatus !== 'ok' || !row.code || seen.has(row.code)) continue;
seen.add(row.code);
result.push({ code: row.code, name: row.referenceName ?? row.name ?? '' });
}
return result;
}
const fold = (text: string) => text.toLowerCase().replace(/ё/g, 'е');
/** Отбор подсказок: код начинается с запроса или имя содержит его (регистр и ё/е не важны). */
export function filterCodeOptions(options: CodeOption[], query: string, limit = LIST_LIMIT): CodeOption[] {
const q = fold(query.trim());
if (q === '') return options.slice(0, limit);
return options.filter((o) => o.code.startsWith(q) || fold(o.name).includes(q)).slice(0, limit);
}
const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVALID_LINE_PATCH', 'UNKNOWN_LINE', 'LINE_CODE_REQUIRED']);
/** P6-9: к какой строке относится ошибка элемента `lines[]` ответа на сохранение; иная ошибка — null. */
export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null {
if (!(error instanceof ApiError) || !error.code || !LINE_ERROR_CODES.has(error.code)) return null;
const detail = (error.body as { detail?: Partial<Record<keyof LineErrorDetail, unknown>> } | undefined)?.detail;
if (typeof detail?.line_id !== 'string') return null;
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
}

View File

@ -1,40 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut, ValueOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
import { type GridRow, buildStatementGrid } from './statementGrid';
const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut);
const emptyCell = { amount: null, string_value: null } as ValueOut;
const row = (patch: Partial<GridRow>): GridRow => ({ ...rows[0], cells: {}, ...patch });
describe('unresolvedLines', () => {
it('фикстура: строки без кода со значениями', () => {
expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']);
});
it('сортирует по row_index, без индекса — в конец, равные — как пришли', () => {
const withAmount = { ...emptyCell, amount: '1.00' } as ValueOut;
const mk = (lineId: string, rowIndex: number | null) => row({ lineId, rowIndex, lineStatus: 'unreadable', cells: { a: withAmount } });
const shuffled = [mk('n1', null), mk('c', 30), mk('a', 10), mk('b', 20), mk('n2', null), mk('b2', 20)];
expect(unresolvedLines(shuffled).map((r) => r.lineId)).toEqual(['a', 'b', 'b2', 'c', 'n1', 'n2']);
});
it('missing без значений не блокирует', () => {
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
});
it('unreadable только со string_value блокирует', () => {
const r = row({ lineStatus: 'unreadable', cells: { a: { ...emptyCell, string_value: '211(1 |' } } });
expect(unresolvedLines([r])).toEqual([r]);
});
it('detail с суммой не блокирует', () => {
expect(unresolvedLines([row({ lineStatus: 'detail', cells: { a: { ...emptyCell, amount: '1.00' } } })])).toEqual([]);
});
it('форма 1.x: все ok — пусто', () => {
expect(unresolvedLines(rows.filter((r) => r.lineStatus === 'ok'))).toEqual([]);
});
});
describe('unresolvedHint', () => {
it('текст', () => {
expect(unresolvedHint(2)).toBe('Разберите строки без кода: 2');
});
});

View File

@ -1,17 +0,0 @@
import type { GridRow } from './statementGrid';
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */
export function unresolvedLines(rows: GridRow[]): GridRow[] {
const found = rows.filter(
(row) =>
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)),
);
// порядок как в бланке: по row_index (без индекса — в конец), при равенстве — как пришло
return found
.map((row, i) => ({ row, i }))
.sort((a, b) => (a.row.rowIndex ?? Number.POSITIVE_INFINITY) - (b.row.rowIndex ?? Number.POSITIVE_INFINITY) || a.i - b.i)
.map(({ row }) => row);
}
export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`;

View File

@ -8,13 +8,7 @@ describe('confirmErrorInfo', () => {
const e = new ApiError(409, 'CHECKS_NOT_PASSED', 'не пройдены проверки', {
detail: { code: 'CHECKS_NOT_PASSED', message: 'x', failures: [failure] },
});
expect(confirmErrorInfo(e)).toEqual({
code: 'CHECKS_NOT_PASSED',
message: 'не пройдены проверки',
failures: [failure],
lines: [],
documentId: null,
});
expect(confirmErrorInfo(e)).toEqual({ code: 'CHECKS_NOT_PASSED', message: 'не пройдены проверки', failures: [failure] });
});
it('прочие ошибки без нарушений', () => {
expect(
@ -23,24 +17,7 @@ describe('confirmErrorInfo', () => {
code: 'PERIOD_REQUIRED',
message: 'нужна дата',
failures: [],
lines: [],
documentId: null,
});
expect(confirmErrorInfo(new Error('boom'))).toEqual({
code: undefined,
message: 'Не удалось подтвердить документ',
failures: [],
lines: [],
documentId: null,
});
});
it('LINES_UNRESOLVED со строками', () => {
const line = { line_id: 'l-m8', row_index: 8, line_code_text: null };
const e = new ApiError(409, 'LINES_UNRESOLVED', 'есть строки без кода', { detail: { lines: [line] } });
expect(confirmErrorInfo(e).lines).toEqual([line]);
});
it('DUPLICATE_FORM с document_id', () => {
const e = new ApiError(409, 'DUPLICATE_FORM', 'форма уже есть', { detail: { document_id: 'doc-0' } });
expect(confirmErrorInfo(e).documentId).toBe('doc-0');
expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] });
});
});

View File

@ -1,20 +1,12 @@
import { ApiError } from '../../api/client';
import type { FailedCheckOut, UnresolvedLineOut } from '../../types/api';
import type { FailedCheckOut } from '../../types/api';
/** Разбор ошибки подтверждения: код, текст, нарушения проверок (CHECKS_NOT_PASSED), строки без кода (LINES_UNRESOLVED) и документ-дубль (DUPLICATE_FORM). */
export function confirmErrorInfo(e: unknown): {
code: string | undefined;
message: string;
failures: FailedCheckOut[];
lines: UnresolvedLineOut[];
documentId: string | null;
} {
/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */
export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } {
if (!(e instanceof ApiError)) {
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [], lines: [], documentId: null };
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] };
}
const detail = (e.body as { detail?: { failures?: unknown; lines?: unknown; document_id?: unknown } } | undefined)?.detail;
const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail;
const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : [];
const lines = Array.isArray(detail?.lines) ? (detail.lines as UnresolvedLineOut[]) : [];
const documentId = typeof detail?.document_id === 'string' ? detail.document_id : null;
return { code: e.code, message: e.message, failures, lines, documentId };
return { code: e.code, message: e.message, failures };
}

View File

@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { IssueOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { cellConfirmState, columnDateEditable } from './editRules';
import { emptyDraft } from './statementDraft';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as StatementOut;
const { rows, columns } = buildStatementGrid(statement);
const col = (id: string) => columns.find((c) => c.id === id) as (typeof columns)[number];
const issue = (code: string) => statement.issues.find((i) => i.code === code) as IssueOut;
const rowOf = (key: string) => rows.find((r) => r.key === key) as (typeof rows)[number];
describe('columnDateEditable', () => {
it('колонка без даты с записью — правится, подтверждать нечего', () => {
expect(columnDateEditable(col('c2'), [issue('COLUMN_WITHOUT_DATE')])).toEqual({ editable: true, canConfirm: false });
});
it('колонка с датой и без записей — не правится', () => {
expect(columnDateEditable(col('c1'), [])).toEqual({ editable: false, canConfirm: false });
});
it('колонка с датой и COLUMN_DATE_UNCERTAIN — правится и подтверждается', () => {
const uncertain = { ...issue('COLUMN_WITHOUT_DATE'), code: 'COLUMN_DATE_UNCERTAIN' } as IssueOut;
expect(columnDateEditable(col('c1'), [uncertain])).toEqual({ editable: true, canConfirm: true });
});
it('посторонняя запись колонки не открывает правку', () => {
expect(columnDateEditable(col('c1'), [issue('RELATION_FAILED')])).toEqual({ editable: false, canConfirm: false });
});
});
describe('cellConfirmState', () => {
const row = rowOf('l-u7');
const value = row.cells.c1;
const key = cellKey('l-u7', 'c1');
it('needs_review → confirm', () => {
expect(cellConfirmState(row, value, emptyDraft(), key)).toBe('confirm');
});
it('подтверждение в черновике → pending-confirm', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: true } }, key)).toBe('pending-confirm');
});
it('снятие в черновике → pending-unconfirm', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: false } }, key)).toBe('pending-unconfirm');
});
it('сумма в черновике → null', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), amounts: { [key]: '7' } }, key)).toBeNull();
});
it('шаблонная строка (нет id) → null', () => {
const tpl = rowOf('code-1250');
expect(cellConfirmState(tpl, tpl.cells.c1, emptyDraft(), cellKey('code-1250', 'c1'))).toBeNull();
});
it('значение без id → null', () => {
expect(cellConfirmState(row, value && { ...value, id: null }, emptyDraft(), key)).toBeNull();
});
it('is_confirmed → unconfirm', () => {
expect(cellConfirmState(row, value && { ...value, needs_review: false, is_confirmed: true }, emptyDraft(), key)).toBe('unconfirm');
});
it('обычная ячейка → null', () => {
expect(cellConfirmState(row, value && { ...value, needs_review: false }, emptyDraft(), key)).toBeNull();
});
});

View File

@ -1,25 +0,0 @@
// Правила правки: когда у колонки можно править дату и когда у ячейки доступно подтверждение (P6-6, P6-11).
import type { ColumnOut, IssueOut, ValueOut } from '../../types/api';
import type { StatementDraft } from './statementDraft';
import type { GridRow } from './statementGrid';
/** Записи issues, при которых аналитик правит или подтверждает дату колонки. */
export const COLUMN_DATE_CODES: ReadonlySet<string> = new Set(['COLUMN_WITHOUT_DATE', 'COLUMN_DATE_UNCERTAIN']);
export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean } {
const flagged = columnIssues.some((issue) => COLUMN_DATE_CODES.has(issue.code));
const editable = (col.period_kind === 'none' && col.period_date === null) || flagged;
return { editable, canConfirm: editable && col.period_date !== null && flagged };
}
export type CellConfirmState = 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null;
/** Какая кнопка подтверждения нужна ячейке. Нужны оба `id` (у достроенных ядро ответит UNKNOWN_CELL); сумма в черновике подтверждение отменяет. */
export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): CellConfirmState {
if (!row.lineId || !value || value.id === null) return null;
if (key in draft.amounts) return null;
if (key in draft.confirms) return draft.confirms[key] ? 'pending-confirm' : 'pending-unconfirm';
if (value.needs_review) return 'confirm';
if (value.is_confirmed) return 'unconfirm';
return null;
}

View File

@ -136,13 +136,6 @@ describe('formLayout — строки детализации (parent_code)', ()
const rows = [row('9990'), sub('99901', '9990')];
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']);
});
it('строка без кода после строки из «Прочих строк» встаёт под ней', () => {
const rows = [
{ ...row('9990'), rowIndex: 5 },
{ ...row(null, 'нераспознанная'), key: 'u1', lineId: 'u1', lineStatus: 'unreadable' as const, rowIndex: 6 },
];
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', 'null']);
});
it('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => {
const rows = [row('2110'), sub('21101', '2110'), row('2120'), sub('21201', '2120'), row('2100'), sub('29901', '2990')];
expect(shape(formLayout('INCOME', rows))).toEqual(['2110', '21101', '2120', '21201', '2100 (итог)', '29901']);

View File

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

View File

@ -40,20 +40,19 @@ export function issuePlaces(
for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r);
const columnIds = new Set(columns.map((c) => c.id));
for (const issue of issues) {
const lineIds = issue.line_ids ?? [];
if (issue.field !== null) {
if (fields.includes(issue.field)) push(places.byField, issue.field, issue);
else places.document.push(issue);
continue;
}
const known = lineIds.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined);
const known = issue.line_ids.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined);
const columnId = issue.column_id !== null && columnIds.has(issue.column_id) ? issue.column_id : null;
if (lineIds.length > 0 && known.length > 0) {
if (issue.line_ids.length > 0 && known.length > 0) {
for (const r of known) {
if (columnId !== null) push(places.byCell, cellKey(r.key, columnId), issue);
else push(places.byRow, r.key, issue);
}
} else if (lineIds.length === 0 && columnId !== null) {
} else if (issue.line_ids.length === 0 && columnId !== null) {
push(places.byColumn, columnId, issue);
} else {
places.document.push(issue);
@ -93,7 +92,7 @@ export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: Gr
const items = issues
.filter((issue) => issue.kind === 'warning')
.map((issue): IssueListItem => {
const first = (issue.line_ids ?? []).map((id) => rowByLineId.get(id)).find((r) => r !== undefined);
const first = issue.line_ids.map((id) => rowByLineId.get(id)).find((r) => r !== undefined);
const column = issue.column_id !== null ? columnById.get(issue.column_id) : undefined;
let place: string;
let targetKey: string | null = null;

View File

@ -1,78 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { applyLineMenu, lineMenuItems, showsCodeInput } from './lineMenu';
import type { LineAction } from './statementDraft';
import { type GridRow, buildStatementGrid } from './statementGrid';
const { rows } = buildStatementGrid(statementV2Json as StatementOut);
const row = (key: string): GridRow => {
const found = rows.find((r) => r.key === key);
if (!found) throw new Error(key);
return found;
};
const keys = (r: GridRow, action?: LineAction) => lineMenuItems(r, action).map((i) => i.key);
describe('lineMenuItems', () => {
it('unreadable / missing — «Это расшифровка»', () => {
expect(keys(row('l-u7'))).toEqual(['mark_detail']);
expect(keys(row('l-m8'))).toEqual(['mark_detail']);
expect(lineMenuItems(row('l-u7'), undefined)[0].label).toBe('Это расшифровка');
});
it('detail — «Назначить код»', () => {
expect(lineMenuItems(row('l-d2'), undefined)).toEqual([{ key: 'assign_code', label: 'Назначить код' }]);
});
it('ok с needsReview — смена и подтверждение кода', () => {
expect(lineMenuItems(row('l-1230'), undefined)).toEqual([
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
{ key: 'change_code', label: 'Сменить код' },
{ key: 'confirm_code', label: 'Подтвердить код' },
]);
});
it('ok подтверждённая — снять подтверждение', () => {
const confirmed = { ...row('l-1230'), isConfirmed: true };
expect(lineMenuItems(confirmed, undefined)).toEqual([
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
{ key: 'change_code', label: 'Сменить код' },
{ key: 'unconfirm_code', label: 'Снять подтверждение кода' },
]);
});
it('при действии последним — «Отменить изменение строки»', () => {
const items = lineMenuItems(row('l-u7'), { kind: 'detail' });
expect(items.map((i) => i).pop()).toEqual({ key: 'reset', label: 'Отменить изменение строки' });
});
it('шаблонная строка без id — пусто', () => {
expect(keys(row('code-1250'))).toEqual([]);
});
});
describe('applyLineMenu', () => {
it('confirm_code → confirm true', () => {
expect(applyLineMenu(row('l-1230'), 'confirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: true } });
expect(applyLineMenu(row('l-1230'), 'unconfirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: false } });
});
it('assign_code / change_code / mark_detail', () => {
expect(applyLineMenu(row('l-d2'), 'assign_code', {})['l-d2']).toEqual({ kind: 'code', code: '' });
expect(applyLineMenu(row('l-1230'), 'change_code', {})['l-1230']).toEqual({ kind: 'code', code: '1230' });
expect(applyLineMenu(row('l-u7'), 'mark_detail', {})['l-u7']).toEqual({ kind: 'detail' });
});
it('reset убирает ключ, новое действие заменяет прежнее, вход не мутируется', () => {
const before: Record<string, LineAction> = { 'l-u7': { kind: 'detail' }, other: { kind: 'detail' } };
expect(applyLineMenu(row('l-u7'), 'reset', before)).toEqual({ other: { kind: 'detail' } });
expect(applyLineMenu(row('l-1230'), 'confirm_code', { 'l-1230': { kind: 'code', code: '1231' } })['l-1230']).toEqual({
kind: 'confirm',
confirmed: true,
});
expect(before['l-u7']).toEqual({ kind: 'detail' });
});
});
describe('showsCodeInput', () => {
it('unreadable / missing всегда; прочие — только при действии code', () => {
expect(showsCodeInput(row('l-u7'), undefined)).toBe(true);
expect(showsCodeInput(row('l-m8'), undefined)).toBe(true);
expect(showsCodeInput(row('l-1230'), undefined)).toBe(false);
expect(showsCodeInput(row('l-1230'), { kind: 'code', code: '1230' })).toBe(true);
expect(showsCodeInput(row('l-d2'), { kind: 'code', code: '' })).toBe(true);
});
});

View File

@ -1,58 +0,0 @@
import type { LineAction } from './statementDraft';
import type { GridRow } from './statementGrid';
export type LineMenuKey = 'mark_detail' | 'assign_code' | 'change_code' | 'confirm_code' | 'unconfirm_code' | 'reset';
const LABELS: Record<LineMenuKey, string> = {
mark_detail: 'Сделать расшифровкой',
assign_code: 'Назначить код',
change_code: 'Сменить код',
confirm_code: 'Подтвердить код',
unconfirm_code: 'Снять подтверждение кода',
reset: 'Отменить изменение строки',
};
/** Пункты меню строки (P6-7). Строка без id (достроенная из шаблона) не меняется — меню пусто. */
export function lineMenuItems(row: GridRow, action: LineAction | undefined): { key: LineMenuKey; label: string }[] {
if (row.lineId === null) return [];
const keys: LineMenuKey[] = [];
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') {
keys.push('mark_detail');
} else if (row.lineStatus === 'detail') {
keys.push('assign_code');
} else if (row.origin === 'source') {
keys.push('mark_detail', 'change_code');
if (row.isConfirmed) keys.push('unconfirm_code');
else if (row.needsReview) keys.push('confirm_code');
}
if (action && keys.length > 0) keys.push('reset');
const items = keys.map((key) => ({ key, label: key === 'mark_detail' && row.lineStatus !== 'ok' ? 'Это расшифровка' : LABELS[key] }));
return items;
}
/** Новый набор действий строк после выбора пункта; исходный объект не меняется. */
export function applyLineMenu(row: GridRow, key: LineMenuKey, lines: Record<string, LineAction>): Record<string, LineAction> {
const id = row.lineId;
if (id === null) return lines;
const { [id]: _removed, ...rest } = lines;
switch (key) {
case 'reset':
return rest;
case 'mark_detail':
return { ...rest, [id]: { kind: 'detail' } };
case 'assign_code':
return { ...rest, [id]: { kind: 'code', code: '' } };
case 'change_code':
return { ...rest, [id]: { kind: 'code', code: row.code ?? '' } };
case 'confirm_code':
return { ...rest, [id]: { kind: 'confirm', confirmed: true } };
default:
return { ...rest, [id]: { kind: 'confirm', confirmed: false } };
}
}
/** Поле кода: у строк без кода — всегда, у остальных — когда выбрано действие «код». */
export function showsCodeInput(row: GridRow, action: LineAction | undefined): boolean {
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return true;
return action?.kind === 'code';
}

View File

@ -55,19 +55,6 @@ describe('подтверждение ячейки', () => {
});
});
describe('подтверждение ячейки без id значения', () => {
it('пропускается молча (P6-6)', () => {
const row = rows.find((r) => r.key === 'l-u7');
if (!row?.cells.c1) throw new Error('нет ячейки в фикстуре');
const noId = { ...row, cells: { ...row.cells, c1: { ...row.cells.c1, id: null } } };
const result = buildStatementPatch(draftOf({ confirms: { [cellKey('l-u7', 'c1')]: true } }), {
rows: rows.map((r) => (r === row ? noId : r)),
header: statement.header,
});
expect(result).toEqual({ ok: true, body: null });
});
});
describe('действия строки', () => {
it('код, расшифровка, подтверждение кода', () => {
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]);
@ -116,20 +103,7 @@ describe('колонки', () => {
describe('шапка', () => {
it('organization_name обрезается', () => {
expect(bodyOf({ header: { organization_name: ' ООО Ромашка-2 ' } })?.header).toEqual({ organization_name: 'ООО Ромашка-2' });
});
it('значение, равное текущему (после обрезки), не отправляется', () => {
expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })).toBeNull();
expect(bodyOf({ header: { inn: '7700000000', units: ' тыс. руб. ' } })).toBeNull();
});
it('введено и стёрто — тела нет', () => {
expect(bodyOf({ header: { inn: ' ', organization_name: '' } })).toBeNull();
});
it('дата из needs_review_fields с тем же значением — подтверждение, уходит', () => {
expect(bodyOf({ header: { report_date: '2025-09-30' } })?.header).toEqual({ report_date: '2025-09-30' });
const noReview = { rows, header: { ...statement.header, needs_review_fields: [] } };
const res = buildStatementPatch(draftOf({ header: { report_date: '2025-09-30' } }), noReview);
expect(res).toEqual({ ok: true, body: null });
expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })?.header).toEqual({ organization_name: 'ООО Ромашка' });
});
it('длиннее 1000 символов — ошибка header', () => {
expect(errorsOf({ header: { organization_name: 'я'.repeat(1001) } })[0]).toMatchObject({ kind: 'header', key: 'organization_name' });

View File

@ -71,8 +71,7 @@ export function buildStatementPatch(
if (key in draft.amounts) continue;
const { rowKey, columnId } = splitKey(key);
const row = byKey.get(rowKey);
// у достроенной ячейки (нет id значения) подтверждать нечего: ядро ответило бы UNKNOWN_CELL — пропускаем молча
if (row?.lineId && row.cells[columnId]?.id) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
if (row?.lineId) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
}
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)
@ -115,12 +114,7 @@ export function buildStatementPatch(
const header: NonNullable<StatementPatch['header']> = {};
for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) {
const text = draft.header[field]?.trim();
if (!text) continue;
const current = field === 'organization_name' ? ctx.header.organization_name : ctx.header[field].value;
// То же значение не шлём; исключение — дата из needs_review_fields: там то же значение и есть подтверждение (P6-11)
const confirmsDate = field === 'report_date' && (ctx.header.needs_review_fields ?? []).includes('report_date');
if (text === current?.trim() && !confirmsDate) continue;
header[field] = text;
if (text) header[field] = text;
}
if (draft.header.confirm_report_date && !header.report_date && ctx.header.report_date.value) {
header.report_date = ctx.header.report_date.value;

View File

@ -177,8 +177,4 @@ describe('parent_code и пустые строки детализации', () =
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']);
});
it('подстрока с действием над строкой в черновике не считается пустой', () => {
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
expect([...emptySublineKeys(rows, {}, { '11101': { kind: 'confirm', confirmed: true } })]).toEqual(['11102']);
});
});

View File

@ -1,5 +1,4 @@
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
import type { LineAction } from './statementDraft';
export type GridRow = {
key: string;
@ -74,25 +73,13 @@ export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue
const hasValue = (value: ValueOut | undefined) => Boolean(value) && ((value?.amount ?? '') !== '' || (value?.string_value ?? '') !== '');
/**
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки (суммы или действия над строкой) в черновике.
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки в черновике.
* Ноль — значение, такая строка не пуста. Только показ: данные и PATCH не затрагиваются.
*/
export function emptySublineKeys(
rows: GridRow[],
draft: Record<string, string>,
lineActions: Record<string, LineAction> = {},
): Set<string> {
export function emptySublineKeys(rows: GridRow[], draft: Record<string, string>): Set<string> {
const edited = new Set(Object.keys(draft).map((key) => key.slice(0, key.lastIndexOf(':'))));
return new Set(
rows
.filter(
(row) =>
row.parentCode !== null &&
!edited.has(row.key) &&
!(row.lineId !== null && row.lineId in lineActions) &&
!Object.values(row.cells).some(hasValue),
)
.map((row) => row.key),
rows.filter((row) => row.parentCode !== null && !edited.has(row.key) && !Object.values(row.cells).some(hasValue)).map((row) => row.key),
);
}

View File

@ -1,125 +0,0 @@
import { Popover, Spin, Tag } from 'antd';
import { useRef, useState } from 'react';
import { getDocument } from '../../services/api/documents';
import type { IssueOut, Severity } from '../../types/api';
import { severityLabel } from '../../utils/labels';
import { SeverityIcon } from '../DocumentReview/IssuesPanel';
import type { DocumentRow } from './documentRows';
import { warningText } from './fileWarnings';
type IssuesState = { status: 'loading' } | { status: 'error' } | { status: 'done'; issues: IssueOut[] };
const tagColor = (s: Severity) => (s === 'error' ? 'error' : 'processing');
function DocumentIssues({ state }: { state: IssuesState | null }) {
if (state === null || state.status === 'loading') return <Spin size='small' />;
if (state.status === 'error') return <span style={{ color: '#cf1322' }}>Не удалось загрузить</span>;
if (state.issues.length === 0) return <span style={{ color: '#8c8c8c' }}>Нет</span>;
return (
<>
{state.issues.map((i) => (
<div key={i.id}>
<Tag color={tagColor(i.severity)}>{severityLabel(i.severity)}</Tag>
{i.message}
</div>
))}
</>
);
}
/** Ключи для строк, которые могут повторяться: к тексту добавляется порядковый номер повтора (индекс массива в key линтер запрещает). */
function uniqueKeys(list: string[]): { key: string; text: string }[] {
const seen = new Map<string, number>();
return list.map((text) => {
const n = (seen.get(text) ?? 0) + 1;
seen.set(text, n);
return { key: `${text}-${n}`, text };
});
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title}</div>
{children}
</div>
);
}
/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */
export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) {
const [issues, setIssues] = useState<IssuesState | null>(null);
// номер текущего открытия: ответ от прошлого открытия или после закрытия игнорируется
const requestNo = useRef(0);
if (!badge) return null;
// предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново
const onOpenChange = (open: boolean) => {
requestNo.current += 1;
const mine = requestNo.current;
if (!open) {
setIssues(null);
return;
}
if (!row.documentId) return;
setIssues({ status: 'loading' });
getDocument(row.documentId)
.then((d) => mine === requestNo.current && setIssues({ status: 'done', issues: d.issues }))
.catch(() => mine === requestNo.current && setIssues({ status: 'error' }));
};
const content = (
<div
// клики внутри поповера всплывают по дереву React до onClick строки таблицы и открывали бы документ
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}>
{row.fileWarnings.length > 0 && (
<Section title='Предупреждения файла'>
{row.fileWarnings.map((w, i) => (
<div key={`${w.code}-${i}`}>
<Tag color={tagColor(w.severity)}>{severityLabel(w.severity)}</Tag>
{warningText(w)}
</div>
))}
</Section>
)}
{row.mappingWarnings.length > 0 && (
<Section title='Предупреждения обработки'>
{uniqueKeys(row.mappingWarnings).map((w) => (
<div key={w.key}>
<Tag color={tagColor('info')}>{severityLabel('info')}</Tag>
{w.text}
</div>
))}
</Section>
)}
{row.documentId && (
<Section title='Предупреждения документа'>
<DocumentIssues state={issues} />
</Section>
)}
</div>
);
return (
<Popover trigger='click' title='Предупреждения' content={content} onOpenChange={onOpenChange}>
<button
type='button'
aria-label={`Предупреждения: ${badge.count}`}
onClick={(e) => e.stopPropagation()}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
fontSize: 12,
whiteSpace: 'nowrap',
color: badge.level === 'error' ? '#cf1322' : '#1677ff',
}}>
<SeverityIcon severity={badge.level} /> {badge.count}
</button>
</Popover>
);
}

View File

@ -4,8 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import type { CheckResult, FileOut, FileType } from '../../types/api';
import { FileWarningsPopover } from './FileWarningsPopover';
import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows';
import { type DocumentRow, type RowStatus, buildDocumentRows } from './documentRows';
const STATUS_STYLE: Record<RowStatus, { color: string; background: string; border: string }> = {
success: { color: '#237804', background: '#f6ffed', border: '#b7eb8f' },
@ -95,17 +94,7 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
</>
) : null,
},
{
title: head('Статус'),
key: 'status',
width: 160,
render: (_, r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }}>
<StatusPill row={r} />
<FileWarningsPopover row={r} badge={rowBadge(r)} />
</div>
),
},
{ title: head('Статус'), key: 'status', width: 160, render: (_, r) => <StatusPill row={r} /> },
];
if (loading && files.length === 0) {

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { DocumentBrief, FileOut } from '../../types/api';
import { buildDocumentRows, rowBadge } from './documentRows';
import { buildDocumentRows } from './documentRows';
const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
id: 'd1',
@ -116,72 +116,3 @@ describe('buildDocumentRows', () => {
expect(rows[1].subtitle.endsWith(' · вне консолидации')).toBe(true);
});
});
const jsonFailed = { code: 'JSON_FAILED', severity: 'error' as const, message: 'm', locator: 'file:x_ofr.json' };
const job = (over: Partial<NonNullable<FileOut['job']>> = {}): FileOut['job'] => ({
id: 'j',
status: 'DONE',
error_code: null,
error_message: null,
mapping_error: null,
warnings: [],
mapping_warnings: [],
finished_at: null,
...over,
});
describe('buildDocumentRows: предупреждения', () => {
it('предупреждения файла попадают в обе строки файла', () => {
const rows = buildDocumentRows([
file({ job: job({ warnings: [jsonFailed], mapping_warnings: ['w'] }), documents: [doc({ id: 'a' }), doc({ id: 'b' })] }),
]);
expect(rows.map((r) => r.fileWarnings.length)).toEqual([1, 1]);
expect(rows[0].mappingWarnings).toEqual(['w']);
});
it('файл ERROR без документов', () => {
const rows = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })]);
expect(rows[0].fileWarnings).toEqual([jsonFailed]);
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
});
it('job null — пустые списки; старый формат строк не ломает', () => {
expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] });
const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]);
expect(legacy[0].fileWarnings[0]).toMatchObject({ code: 'LEGACY_WARNING', message: 'старое' });
});
it('ядро без issue_counts (до v5.19) — нули, не падает', () => {
const old = doc() as Partial<DocumentBrief>;
old.issue_counts = undefined;
const rows = buildDocumentRows([file({ documents: [old as DocumentBrief] })]);
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
});
it('issue_counts документа переносятся в issueCounts', () => {
const rows = buildDocumentRows([file({ documents: [doc({ issue_counts: { error: 1, info: 3 } })] })]);
expect(rows[0].issueCounts).toEqual({ error: 1, info: 3 });
});
});
describe('rowBadge', () => {
const row = (fileWarnings: unknown[], counts: { error: number; info: number }) =>
buildDocumentRows([file({ job: job({ warnings: fileWarnings as never }), documents: [doc({ issue_counts: counts })] })])[0];
it('только счётчики info', () => {
expect(rowBadge(row([], { error: 0, info: 2 }))).toEqual({ count: 2, level: 'info' });
});
it('ошибка файла + info документа', () => {
expect(rowBadge(row([jsonFailed], { error: 0, info: 1 }))).toEqual({ count: 2, level: 'error' });
});
it('error документа без файла', () => {
expect(rowBadge(row([], { error: 1, info: 0 }))).toMatchObject({ level: 'error' });
});
it('файл без документов: считаются предупреждения файла', () => {
const r = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })])[0];
expect(rowBadge(r)).toEqual({ count: 1, level: 'error' });
});
it('нули — null', () => {
expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull();
});
});

View File

@ -1,9 +1,7 @@
import type { CheckResult, DocumentStatus, FileOut, FileType, FileWarningOut, IssueCountsOut, Severity } from '../../types/api';
import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api';
import { formatMoscowDate } from '../../utils/dates';
import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels';
import { toFileWarnings, warningsLevel } from './fileWarnings';
export type RowStatus = 'success' | 'warning' | 'processing' | 'error';
export interface DocumentRow {
@ -20,19 +18,6 @@ export interface DocumentRow {
statusLabel: string;
errorReason: string | null;
linkable: boolean;
/** предупреждения разбора файла (общие для всех документов файла) */
fileWarnings: FileWarningOut[];
mappingWarnings: string[];
/** счётчики issues документа; у строки файла без документов — нули */
issueCounts: IssueCountsOut;
}
/** Значок у статуса (P6-12): число предупреждений и цвет уровня; ничего нет — null. */
export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null {
const count = row.fileWarnings.length + row.issueCounts.error + row.issueCounts.info;
if (count === 0) return null;
const level = warningsLevel(row.fileWarnings) === 'error' || row.issueCounts.error > 0 ? 'error' : 'info';
return { count, level };
}
const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']);
@ -56,8 +41,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
const extra = labelFor?.(f) ?? null;
const name = f.original_name ?? 'без имени';
const date = formatMoscowDate(f.uploaded_at);
const fileWarnings = toFileWarnings(f.job?.warnings);
const mappingWarnings = f.job?.mapping_warnings ?? [];
if (f.documents.length === 0) {
const failed = f.status === 'ERROR';
return [
@ -72,9 +55,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
statusLabel: failed ? 'Ошибка' : 'Обработка',
errorReason: errorReason(f),
linkable: false,
fileWarnings,
mappingWarnings,
issueCounts: { error: 0, info: 0 },
},
];
}
@ -97,10 +77,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
statusLabel: documentStatusLabel(d.status),
errorReason: null,
linkable: LINKABLE.has(d.document_type),
fileWarnings,
mappingWarnings,
// ядро без v5.19 не присылает issue_counts — считаем, что замечаний нет
issueCounts: d.issue_counts ?? { error: 0, info: 0 },
};
});
});

View File

@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import { toFileWarnings, warningText, warningsLevel } from './fileWarnings';
describe('toFileWarnings', () => {
it('старый формат: строка становится LEGACY_WARNING', () => {
expect(toFileWarnings(['sheet=ОСВ 60 строки без родителя'])).toEqual([
{ code: 'LEGACY_WARNING', severity: 'info', message: 'sheet=ОСВ 60 строки без родителя', locator: null },
]);
});
it('смесь: мусор отбрасывается, объект без locator получает null', () => {
const res = toFileWarnings([
'a',
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
5,
{ code: 'X' },
{ code: 'Y', severity: 'warn', message: 'z' },
]);
expect(res).toEqual([
{ code: 'LEGACY_WARNING', severity: 'info', message: 'a', locator: null },
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
]);
});
it('объект без locator — locator null', () => {
expect(toFileWarnings([{ code: 'C', severity: 'info', message: 'm' }])[0].locator).toBeNull();
});
it('не массив — пустой список', () => {
expect(toFileWarnings('строка')).toEqual([]);
expect(toFileWarnings(null)).toEqual([]);
expect(toFileWarnings(undefined)).toEqual([]);
});
});
describe('warningsLevel', () => {
it('error важнее info', () => {
expect(warningsLevel([{ severity: 'info' }, { severity: 'error' }])).toBe('error');
expect(warningsLevel([{ severity: 'info' }])).toBe('info');
});
it('пусто — null', () => {
expect(warningsLevel([])).toBeNull();
});
});
describe('warningText', () => {
it('с locator и без', () => {
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: 'file:x_ofr.json' })).toBe('m — file:x_ofr.json');
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: null })).toBe('m');
});
});

View File

@ -1,34 +0,0 @@
import type { FileWarningOut, Severity } from '../../types/api';
/**
* Приводит `job.warnings` к структурам (P6-13): старые строки становятся `LEGACY_WARNING` уровня info,
* записи без code/message/корректного severity и любые прочие значения отбрасываются.
*/
export function toFileWarnings(value: unknown): FileWarningOut[] {
if (!Array.isArray(value)) return [];
const result: FileWarningOut[] = [];
for (const item of value) {
if (typeof item === 'string') {
result.push({ code: 'LEGACY_WARNING', severity: 'info', message: item, locator: null });
} else if (typeof item === 'object' && item !== null) {
const w = item as Record<string, unknown>;
if (typeof w.code === 'string' && typeof w.message === 'string' && (w.severity === 'error' || w.severity === 'info')) {
result.push({
code: w.code,
severity: w.severity,
message: w.message,
locator: typeof w.locator === 'string' ? w.locator : null,
});
}
}
}
return result;
}
/** Высший уровень списка: error, если есть хоть одно; пустой список — null. */
export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null {
if (list.length === 0) return null;
return list.some((w) => w.severity === 'error') ? 'error' : 'info';
}
export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message);