Compare commits
10 Commits
ef4d32592e
...
12fcea7560
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12fcea7560 | ||
|
|
4783593fe6 | ||
|
|
d965fdc064 | ||
|
|
6556e3d58d | ||
|
|
8d2deb97a3 | ||
|
|
fc5cc16b3d | ||
|
|
fdf9358b40 | ||
|
|
5b11f2d00a | ||
|
|
a779d2a04a | ||
|
|
d416422e73 |
@ -32,5 +32,54 @@
|
||||
| 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`) дописываются сюда по ходу исполнения.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -214,6 +214,19 @@ 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;
|
||||
|
||||
15
src/utils/percent.test.ts
Normal file
15
src/utils/percent.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatPercent } from './percent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('округляет до целого', () => {
|
||||
expect(formatPercent('61.54')).toBe('62%');
|
||||
expect(formatPercent('67.00')).toBe('67%');
|
||||
expect(formatPercent(100)).toBe('100%');
|
||||
});
|
||||
it('не число — null', () => {
|
||||
expect(formatPercent('abc')).toBeNull();
|
||||
expect(formatPercent('')).toBeNull();
|
||||
expect(formatPercent(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
6
src/utils/percent.ts
Normal file
6
src/utils/percent.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** Уверенность API (строка Decimal) → «62%» (округление до целого); не число — null, чтобы не показывать «NaN%». */
|
||||
export function formatPercent(confidence: string | number | null | undefined): string | null {
|
||||
if (confidence === null || confidence === undefined || confidence === '') return null;
|
||||
const n = Number(confidence);
|
||||
return Number.isFinite(n) ? `${Math.round(n)}%` : null;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -1,20 +1,71 @@
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { AutoComplete, Tag, 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';
|
||||
|
||||
/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */
|
||||
export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) {
|
||||
/** Правка кода строки: действие черновика, справочник подсказок и ошибка (формат / ответ сервера). */
|
||||
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 }) {
|
||||
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>
|
||||
);
|
||||
if (!view.tip) return content;
|
||||
const withTag = tag ? (
|
||||
<>
|
||||
{content}
|
||||
<Tag style={{ marginLeft: 4 }} variant='filled'>
|
||||
{tag}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
if (!view.tip) return withTag;
|
||||
return (
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{view.tip}</span>}>
|
||||
{/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
|
||||
<span style={{ display: 'inline-block', minWidth: 16 }}>{content}</span>
|
||||
<span style={{ display: 'inline-block', minWidth: 16 }}>{withTag}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
60
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
60
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -13,10 +13,14 @@ export function SeverityIcon({ severity, style }: { severity: Severity; style?:
|
||||
);
|
||||
}
|
||||
|
||||
type Props = { items: IssueListItem[]; onShow?: (key: string) => void };
|
||||
type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: (key: string) => void };
|
||||
|
||||
/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */
|
||||
export function IssuesPanel({ items, onShow }: Props) {
|
||||
/**
|
||||
* Список предупреждений; пустой — не показывается. Длинный список прокручивается, как список нарушений.
|
||||
* `statement` — форма ББ/ОФР: место записи и ссылка «Показать»; `document` (ОСВ, P6-19) — простые пункты без места и ссылок.
|
||||
*/
|
||||
export function IssuesPanel({ items, mode, onShow }: Props) {
|
||||
const plain = mode === 'document';
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<Alert
|
||||
@ -28,8 +32,9 @@ export function IssuesPanel({ items, onShow }: Props) {
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
|
||||
{item.message} ({item.place})
|
||||
{item.targetKey !== null && onShow && (
|
||||
{item.message}
|
||||
{!plain && ` (${item.place})`}
|
||||
{!plain && item.targetKey !== null && onShow && (
|
||||
<Button type='link' size='small' onClick={() => onShow(item.targetKey as string)}>
|
||||
Показать
|
||||
</Button>
|
||||
|
||||
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ 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';
|
||||
@ -116,6 +117,11 @@ 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 () => {
|
||||
@ -165,6 +171,7 @@ 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'
|
||||
|
||||
@ -36,8 +36,9 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
|
||||
}
|
||||
|
||||
/**
|
||||
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения —
|
||||
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица из четырёх полей — организация, ИНН,
|
||||
* дата отчёта, единица измерения (в режиме правки поля редактируются; дату из `needs_review_fields` можно подтвердить
|
||||
* кнопкой); дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
|
||||
*/
|
||||
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) {
|
||||
@ -73,8 +74,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 }} onClick={onConfirmDate}>
|
||||
Подтвердить дату
|
||||
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}>
|
||||
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { ApiError } from '../../api/client';
|
||||
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
||||
import type { DocumentOut, FailedCheckOut, StatementOut } from '../../types/api';
|
||||
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
|
||||
import { parseAmountInput } from '../../utils/money';
|
||||
import { ChecksPanel, failureText } from './ChecksPanel';
|
||||
import { IssuesPanel } from './IssuesPanel';
|
||||
@ -11,10 +12,13 @@ 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';
|
||||
@ -31,15 +35,21 @@ 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]);
|
||||
|
||||
@ -58,15 +68,35 @@ 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) : new Set<string>()), [grid, draft]);
|
||||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : 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 places = useMemo(() => (grid && statement ? issuePlaces(statement.issues, grid.rows, grid.columns) : null), [grid, statement]);
|
||||
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 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
|
||||
@ -87,6 +117,7 @@ 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';
|
||||
@ -112,9 +143,12 @@ 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);
|
||||
@ -132,23 +166,32 @@ 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);
|
||||
}
|
||||
};
|
||||
@ -160,6 +203,19 @@ 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
|
||||
@ -181,11 +237,14 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<CheckOutlined />}
|
||||
style={actions.canConfirm ? confirmButtonStyle : undefined}
|
||||
disabled={!actions.canConfirm}
|
||||
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={confirm}>
|
||||
Подтвердить данные
|
||||
</Button>
|
||||
{unresolved.length > 0 && (
|
||||
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@ -211,19 +270,8 @@ 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 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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IssuesPanel mode='statement' items={issueItems} onShow={focusRow} />
|
||||
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
|
||||
{emptySublines.size > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
|
||||
@ -240,6 +288,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
|
||||
<StatementTable
|
||||
columns={grid.columns}
|
||||
layout={layout}
|
||||
@ -247,7 +296,28 @@ 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) }))
|
||||
}
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
import { Table, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ColumnOut, IssueOut } from '../../types/api';
|
||||
import type { ColumnOut } 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 { columnTitle } from './reviewView';
|
||||
import { cellKey } from './statementGrid';
|
||||
import type { LineMenuKey } from './lineMenu';
|
||||
import type { LineAction, StatementDraft } from './statementDraft';
|
||||
import { type GridRow, cellKey } from './statementGrid';
|
||||
|
||||
type Props = {
|
||||
columns: ColumnOut[];
|
||||
@ -22,6 +27,17 @@ 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; // отступ названия строки детализации под родителем
|
||||
@ -31,27 +47,29 @@ 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 !== 'ok' && rowBackground(r.row) !== undefined;
|
||||
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && (r.row.lineStatus === 'unreadable' || r.row.lineStatus === 'missing');
|
||||
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
|
||||
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
|
||||
|
||||
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) {
|
||||
export function StatementTable({
|
||||
columns,
|
||||
layout,
|
||||
editable,
|
||||
draft,
|
||||
onDraftChange,
|
||||
errorKeys,
|
||||
places,
|
||||
highlightKey = null,
|
||||
lineActions = {},
|
||||
codeOptions = [],
|
||||
lineErrors = {},
|
||||
onLineCodeChange,
|
||||
onLineMenu,
|
||||
fullDraft,
|
||||
onConfirmToggle,
|
||||
onColumnDateChange,
|
||||
onColumnDateConfirm,
|
||||
}: Props) {
|
||||
const span = columns.length + 2;
|
||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
|
||||
@ -80,7 +98,19 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
const named = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
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
|
||||
);
|
||||
if (!rowWorst) return named;
|
||||
return (
|
||||
<>
|
||||
@ -100,14 +130,37 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Код',
|
||||
width: 60,
|
||||
width: editable ? 160 : 60,
|
||||
align: 'center' as const,
|
||||
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }),
|
||||
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <CodeCell row={r.row} fontWeight={lineWeight(r)} /> : null),
|
||||
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} />;
|
||||
},
|
||||
},
|
||||
...columns.map((col) => ({
|
||||
key: col.id,
|
||||
title: <ColumnTitle col={col} issues={places.byColumn.get(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}
|
||||
/>
|
||||
),
|
||||
width: 112,
|
||||
align: 'right' as const,
|
||||
onCell: (r: LayoutRow) =>
|
||||
@ -135,6 +188,8 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange
|
||||
onDraftChange={onDraftChange}
|
||||
hasError={errorKeys?.has(draftKey)}
|
||||
issues={places.byCell.get(draftKey)}
|
||||
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
|
||||
onConfirmToggle={onConfirmToggle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { Button, 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 = {
|
||||
@ -17,10 +18,37 @@ 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 }: Props) {
|
||||
export function ValueCell({
|
||||
value,
|
||||
editable,
|
||||
draftKey,
|
||||
draft,
|
||||
onDraftChange,
|
||||
issues = [],
|
||||
hasError = false,
|
||||
confirmState = null,
|
||||
onConfirmToggle,
|
||||
}: Props) {
|
||||
const lines = cellTipLines(value, issues);
|
||||
const worst = worstSeverity(issues);
|
||||
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
|
||||
@ -39,9 +67,10 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
|
||||
}
|
||||
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>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{corner}
|
||||
<Input
|
||||
size='small'
|
||||
@ -49,8 +78,21 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
|
||||
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' }}
|
||||
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст
|
||||
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
|
||||
@ -57,6 +57,14 @@ describe('cellTipLines', () => {
|
||||
it('нет текста и уверенности — пусто', () => {
|
||||
expect(cellTipLines(val({ text: null, confidence: null }))).toEqual([]);
|
||||
});
|
||||
it('пустой или пробельный текст — строки «Распознано» нет', () => {
|
||||
expect(cellTipLines(val({ text: '', confidence: '100' }))).toEqual(['Уверенность: 100%']);
|
||||
expect(cellTipLines(val({ text: ' ', confidence: null }))).toEqual([]);
|
||||
});
|
||||
it('уверенность округляется, нечисловая — без строки', () => {
|
||||
expect(cellTipLines(val({ text: null, confidence: '61.54' }))).toEqual(['Уверенность: 62%']);
|
||||
expect(cellTipLines(val({ text: null, confidence: 'abc' }))).toEqual([]);
|
||||
});
|
||||
it('расшифровка без needs_review тоже получает подсказку', () => {
|
||||
expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([
|
||||
'Распознано: «70045»',
|
||||
@ -85,6 +93,12 @@ describe('codeCellView', () => {
|
||||
tip: 'Код не распознан: «ИЧ,250»',
|
||||
});
|
||||
});
|
||||
it('unreadable с пробельным текстом — тире и в подсказке', () => {
|
||||
expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: ' ' })).tip).toBe('Код не распознан: «—»');
|
||||
});
|
||||
it('ok: нечисловая уверенность кода — без хвоста', () => {
|
||||
expect(codeCellView(row({ codeConfidence: 'abc' })).tip).toBeUndefined();
|
||||
});
|
||||
it('unreadable без текста — тире', () => {
|
||||
expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: null })).text).toBe('—');
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются.
|
||||
import type { IssueOut, ValueOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { formatPercent } from '../../utils/percent';
|
||||
import { type GridRow, cellFlags } from './statementGrid';
|
||||
|
||||
export const COLORS = {
|
||||
@ -24,16 +25,15 @@ export function rowBackground(row: GridRow): string | undefined {
|
||||
return row.lineStatus === 'ok' && row.needsReview ? COLORS.attention : undefined;
|
||||
}
|
||||
|
||||
const percent = (confidence: string): string => `${Math.round(Number(confidence))}%`;
|
||||
|
||||
/** Строки подсказки ячейки суммы; подсказка есть и без подсветки (Р-23). */
|
||||
export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] {
|
||||
const lines: string[] = [];
|
||||
if (value.is_modified) {
|
||||
lines.push(`Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`);
|
||||
}
|
||||
if (value.text !== null) lines.push(`Распознано: «${value.text}»`);
|
||||
if (value.confidence !== null) lines.push(`Уверенность: ${percent(value.confidence)}`);
|
||||
if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`);
|
||||
const confidence = formatPercent(value.confidence);
|
||||
if (confidence !== null) lines.push(`Уверенность: ${confidence}`);
|
||||
if (value.is_confirmed) lines.push('Подтверждено аналитиком');
|
||||
for (const issue of issues) lines.push(issue.message);
|
||||
return lines;
|
||||
@ -44,7 +44,7 @@ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip:
|
||||
switch (row.lineStatus) {
|
||||
case 'unreadable': {
|
||||
const raw = row.codeText?.trim() ?? '';
|
||||
return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw}»` };
|
||||
return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw || '—'}»` };
|
||||
}
|
||||
case 'missing':
|
||||
return { text: '—', muted: true, tip: 'Кода в строке нет' };
|
||||
@ -55,7 +55,8 @@ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip:
|
||||
if (row.codeModified) {
|
||||
tips.push('Код назначен аналитиком');
|
||||
} else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) {
|
||||
tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`);
|
||||
const confidence = formatPercent(row.codeConfidence);
|
||||
if (confidence !== null) tips.push(`Уверенность кода: ${confidence}`);
|
||||
}
|
||||
if (row.isConfirmed) tips.push('Код подтверждён');
|
||||
return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined };
|
||||
|
||||
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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 };
|
||||
}
|
||||
40
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal file
40
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
17
src/widgets/DocumentReview/confirmBlockers.ts
Normal file
17
src/widgets/DocumentReview/confirmBlockers.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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}`;
|
||||
@ -8,7 +8,13 @@ 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] });
|
||||
expect(confirmErrorInfo(e)).toEqual({
|
||||
code: 'CHECKS_NOT_PASSED',
|
||||
message: 'не пройдены проверки',
|
||||
failures: [failure],
|
||||
lines: [],
|
||||
documentId: null,
|
||||
});
|
||||
});
|
||||
it('прочие ошибки без нарушений', () => {
|
||||
expect(
|
||||
@ -17,7 +23,24 @@ describe('confirmErrorInfo', () => {
|
||||
code: 'PERIOD_REQUIRED',
|
||||
message: 'нужна дата',
|
||||
failures: [],
|
||||
lines: [],
|
||||
documentId: null,
|
||||
});
|
||||
expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] });
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { FailedCheckOut } from '../../types/api';
|
||||
import type { FailedCheckOut, UnresolvedLineOut } from '../../types/api';
|
||||
|
||||
/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */
|
||||
export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } {
|
||||
/** Разбор ошибки подтверждения: код, текст, нарушения проверок (CHECKS_NOT_PASSED), строки без кода (LINES_UNRESOLVED) и документ-дубль (DUPLICATE_FORM). */
|
||||
export function confirmErrorInfo(e: unknown): {
|
||||
code: string | undefined;
|
||||
message: string;
|
||||
failures: FailedCheckOut[];
|
||||
lines: UnresolvedLineOut[];
|
||||
documentId: string | null;
|
||||
} {
|
||||
if (!(e instanceof ApiError)) {
|
||||
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] };
|
||||
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [], lines: [], documentId: null };
|
||||
}
|
||||
const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail;
|
||||
const detail = (e.body as { detail?: { failures?: unknown; lines?: unknown; document_id?: unknown } } | undefined)?.detail;
|
||||
const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : [];
|
||||
return { code: e.code, message: e.message, failures };
|
||||
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 };
|
||||
}
|
||||
|
||||
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
25
src/widgets/DocumentReview/editRules.ts
Normal file
25
src/widgets/DocumentReview/editRules.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// Правила правки: когда у колонки можно править дату и когда у ячейки доступно подтверждение (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;
|
||||
}
|
||||
@ -136,6 +136,13 @@ 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']);
|
||||
|
||||
@ -62,6 +62,13 @@ describe('headerFields — шапка: организация, поля на п
|
||||
expect(headerFields(h)[0].tip).toBe('Распознано: «ООО Р» · уверенность 67%');
|
||||
expect(headerFields(header())[0].tip).toBeUndefined();
|
||||
});
|
||||
it('уверенность в подсказке округляется, нечисловая — без хвоста, пустой текст — без подсказки', () => {
|
||||
const tip = (confidence: string | null, text = 'ООО Р') =>
|
||||
headerFields(header({ source: { organization_name: { text, confidence } } }))[0].tip;
|
||||
expect(tip('61.54')).toBe('Распознано: «ООО Р» · уверенность 62%');
|
||||
expect(tip('abc')).toBe('Распознано: «ООО Р»');
|
||||
expect(tip('67', ' ')).toBeUndefined();
|
||||
});
|
||||
it('исправленное поле — исходное в подсказке', () => {
|
||||
const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) }));
|
||||
expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' });
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api';
|
||||
import { formatPercent } from '../../utils/percent';
|
||||
import { formatReportDate } from './reviewView';
|
||||
|
||||
export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units';
|
||||
@ -33,13 +34,14 @@ export function headerFields(header: StatementHeaderOut, byField?: Map<string, I
|
||||
const raw = isOrg ? header.organization_name : (h?.value ?? null);
|
||||
const text = (field === 'report_date' ? formatReportDate(raw) : raw) ?? '—';
|
||||
const modified = isOrg ? header.organization_name_modified : h?.modified === true;
|
||||
const attention = header.needs_review_fields.includes(field);
|
||||
const src = header.source[field];
|
||||
const recognized = src?.text != null ? `Распознано: «${src.text}»` : undefined;
|
||||
const attention = (header.needs_review_fields ?? []).includes(field);
|
||||
const src = header.source?.[field];
|
||||
const recognized = src?.text != null && src.text.trim() !== '' ? `Распознано: «${src.text}»` : undefined;
|
||||
let tip: string | undefined;
|
||||
if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`;
|
||||
else if (recognized) {
|
||||
tip = src?.confidence != null ? `${recognized} · уверенность ${Number(src.confidence)}%` : recognized;
|
||||
const confidence = formatPercent(src?.confidence);
|
||||
tip = confidence !== null ? `${recognized} · уверенность ${confidence}` : recognized;
|
||||
}
|
||||
return {
|
||||
field,
|
||||
|
||||
@ -40,19 +40,20 @@ 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 = issue.line_ids.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined);
|
||||
const known = lineIds.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 (issue.line_ids.length > 0 && known.length > 0) {
|
||||
if (lineIds.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 (issue.line_ids.length === 0 && columnId !== null) {
|
||||
} else if (lineIds.length === 0 && columnId !== null) {
|
||||
push(places.byColumn, columnId, issue);
|
||||
} else {
|
||||
places.document.push(issue);
|
||||
@ -92,7 +93,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;
|
||||
|
||||
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
@ -0,0 +1,58 @@
|
||||
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';
|
||||
}
|
||||
@ -55,6 +55,19 @@ 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' }]);
|
||||
@ -103,7 +116,20 @@ describe('колонки', () => {
|
||||
|
||||
describe('шапка', () => {
|
||||
it('organization_name обрезается', () => {
|
||||
expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })?.header).toEqual({ 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 });
|
||||
});
|
||||
it('длиннее 1000 символов — ошибка header', () => {
|
||||
expect(errorsOf({ header: { organization_name: 'я'.repeat(1001) } })[0]).toMatchObject({ kind: 'header', key: 'organization_name' });
|
||||
|
||||
@ -71,7 +71,8 @@ export function buildStatementPatch(
|
||||
if (key in draft.amounts) continue;
|
||||
const { rowKey, columnId } = splitKey(key);
|
||||
const row = byKey.get(rowKey);
|
||||
if (row?.lineId) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
|
||||
// у достроенной ячейки (нет id значения) подтверждать нечего: ядро ответило бы UNKNOWN_CELL — пропускаем молча
|
||||
if (row?.lineId && row.cells[columnId]?.id) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
|
||||
}
|
||||
|
||||
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)
|
||||
@ -114,7 +115,12 @@ 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) header[field] = text;
|
||||
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 (draft.header.confirm_report_date && !header.report_date && ctx.header.report_date.value) {
|
||||
header.report_date = ctx.header.report_date.value;
|
||||
|
||||
@ -177,4 +177,8 @@ 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']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
|
||||
import type { LineAction } from './statementDraft';
|
||||
|
||||
export type GridRow = {
|
||||
key: string;
|
||||
@ -73,13 +74,25 @@ 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>): Set<string> {
|
||||
export function emptySublineKeys(
|
||||
rows: GridRow[],
|
||||
draft: Record<string, string>,
|
||||
lineActions: Record<string, LineAction> = {},
|
||||
): 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) && !Object.values(row.cells).some(hasValue)).map((row) => row.key),
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
125
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
125
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,8 @@ 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 { type DocumentRow, type RowStatus, buildDocumentRows } from './documentRows';
|
||||
import { FileWarningsPopover } from './FileWarningsPopover';
|
||||
import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const STATUS_STYLE: Record<RowStatus, { color: string; background: string; border: string }> = {
|
||||
success: { color: '#237804', background: '#f6ffed', border: '#b7eb8f' },
|
||||
@ -94,7 +95,17 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
||||
</>
|
||||
) : null,
|
||||
},
|
||||
{ title: head('Статус'), key: 'status', width: 160, render: (_, r) => <StatusPill row={r} /> },
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DocumentBrief, FileOut } from '../../types/api';
|
||||
import { buildDocumentRows } from './documentRows';
|
||||
import { buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
|
||||
id: 'd1',
|
||||
@ -116,3 +116,72 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api';
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType, FileWarningOut, IssueCountsOut, Severity } 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 {
|
||||
@ -18,6 +20,19 @@ 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']);
|
||||
@ -41,6 +56,8 @@ 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 [
|
||||
@ -55,6 +72,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
statusLabel: failed ? 'Ошибка' : 'Обработка',
|
||||
errorReason: errorReason(f),
|
||||
linkable: false,
|
||||
fileWarnings,
|
||||
mappingWarnings,
|
||||
issueCounts: { error: 0, info: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -77,6 +97,10 @@ 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 },
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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);
|
||||
Loading…
x
Reference in New Issue
Block a user