From 0f2bbc80c99a4037e78d3797fa439c408862d81f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:11:49 +0400 Subject: [PATCH 01/22] =?UTF-8?q?openspec(statements-v2):=20change=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF=D0=BE=20?= =?UTF-8?q?API=20v5.19=20(arm-ka-docs#116,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-statements-v2.md | 933 ++++++++++++++++++ openspec/changes/statements-v2/.openspec.yaml | 2 + openspec/changes/statements-v2/design.md | 36 + openspec/changes/statements-v2/proposal.md | 34 + .../specs/document-review-ui/spec.md | 109 ++ .../specs/primary-files-ui/spec.md | 11 + 6 files changed, 1125 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-18-statements-v2.md create mode 100644 openspec/changes/statements-v2/.openspec.yaml create mode 100644 openspec/changes/statements-v2/design.md create mode 100644 openspec/changes/statements-v2/proposal.md create mode 100644 openspec/changes/statements-v2/specs/document-review-ui/spec.md create mode 100644 openspec/changes/statements-v2/specs/primary-files-ui/spec.md diff --git a/docs/superpowers/plans/2026-10-18-statements-v2.md b/docs/superpowers/plans/2026-10-18-statements-v2.md new file mode 100644 index 0000000..16b01f4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-statements-v2.md @@ -0,0 +1,933 @@ +# План 6 — arm-web: экран формы ББ/ОФР и карточка файла по API v5.19. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 12 задач, задачи +> опираются на интерфейсы друг друга). Контроллер SDD — фоновый сабагент на модели **sonnet**; исполнители — по SDD Model +> Selection (`~/.claude/CLAUDE.md`, «Исполнение»): T1 — haiku (типы и фикстура заданы планом), остальные — sonnet, +> финальное ревью ветки — opus. Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** экран проверки формы ББ/ОФР [41] показывает все строки источника на своих местах (строки без кода — под +ближайшей предыдущей строкой с кодом, расшифровки — под переключателем), подсвечивает только то, что требует проверки +(`needs_review`), показывает предупреждения и нарушения у места, даёт в режиме «Редактировать» разобрать строки, +подтвердить ячейки, коды и даты одним `PATCH`, блокирует «Подтвердить данные» при неразобранных строках; карточка файла +читает `job.warnings` структурами. + +**Architecture:** вся логика — чистые функции в `.ts` рядом с компонентами (`src/widgets/DocumentReview/`, +`src/widgets/FilesDrawer/`), покрытые vitest; компоненты React только рисуют то, что вернули функции, и не тестируются +юнит-тестами (соглашение `arm-web/CLAUDE.md`). Черновик правок аналитика — одна структура `StatementDraft`, из которой +одна функция собирает тело единственного `PATCH`. Экран проверяется сценарием в Playwright на локальном стенде; до того +как сервисы распознавания начнут слать контракт 2.0, строки со статусами подставляются в ответ `GET …/statement` +перехватом запроса (фикстура плана). + +**Tech Stack:** React 18 + TypeScript strict, antd 6 (`Table`, `AutoComplete`, `Dropdown`, `Popover`, `Tooltip`, +`Switch`), rsbuild, vitest **3.1.4**, biome + ESLint + Prettier; Playwright (MCP-плагин сессии) для сценария; `tea` +(Gitea), OpenSpec. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/arm-web/spec.md`; API, которое экран +потребляет, — `specs/arm-core/spec.md` (требования «Ответ `GET …/statement` по v2.0», «PATCH формы — строки, колонки, +подтверждение, наименование организации», «Подтверждение формы — порядок 409», «Предупреждения и нарушения документа — +`document_issue`», «Предупреждения файла и ОСВ в API»); гайд фронта — `dev/frontend-api-guide.md` §5.6.1; правила — +`context/05-domain-rules.md` §9.5 п. 2, §11.9 (таблица предупреждений: коды и уровни); точные схемы ответов и коды — +план 3 (`plans/2026-10-18-plan-3-arm-core.md`), задачи A5, A8, A11, A12, A13; решения — `design.md` (Р-14 экран, Р-23 +значения `detail`, Р-25 подтверждение без правки, S-62 родитель `detail`, S-47 пробелы PATCH). Макет — `mockup/screens/41-document-check.png`, +`41-02-document-check-edit.png`, `39-org-documents.png`. + +--- + +## Место в работе и порядок + +| План | Что | Связь с этим планом | +|---|---|---| +| 3, часть А | arm-core: схема v5.19, `StatementOut` с новыми полями, `PATCH` (`lines`, `columns`, `confirmed`, `organization_name`), порядок 409, `JobBrief.warnings` структурами | задачи T1–T10 пишутся по спеке и фикстуре **до** слияния А; T11 (стенд) — после слияния А в `main` arm-core | +| 3, часть Б | приём контракта 2.0, §7 в `document_issue` | для T11 желательна (нарушения §7 в `issues` у форм 1.x появляются с Б) | +| сервисы (шаг 5 `proposal.md`) | tabular-parser и scan-gateway шлют 2.0 | до них реальных строк `unreadable` / `missing` / `detail` на стенде нет — их показывает перехват (P6-14); повторный прогон на `aep_3` после шага 5 — follow-up | +| `sync-web` | перенос `arm-web@test` в `arm-core/web` | после merge PR этого плана в `test`; снимок контура — только вместе с ядром А + Б (O-9 плана 3) | + +**Выкладка:** фронт выходит в той же выкладке, что ядро: `FileOut.job.warnings` меняет тип со строк на объекты +(несовместимо), а встроенный фронт контура — `arm-core/web`, который обновляется только `make -C docs sync-web`. + +## Как это устроено — для владельца + +- **Раскладка «как на бумаге».** Ядро отдаёт строки списком; разделы баланса и места итогов строит фронт + (`formLayout.ts`, так уже работает с Среза 3b). Новое: строки без кода (`unreadable`, `missing`) и расшифровки + (`detail`) не участвуют в раскладке по коду, а «прицепляются» к строке с кодом — к той, что стояла перед ними в файле + (`row_index`), расшифровка — к своему родителю (`parent_row_index`). Это отдельная чистая функция, её проверяют тесты + на сценариях спеки («ИЧ,250 под 1240»). +- **Почему черновик, а не запрос на каждое действие.** Спека требует: «Сохранить» отправляет **один** `PATCH`. Поэтому + всё, что аналитик делает в режиме «Редактировать» (суммы, подтверждения ячеек, коды строк, даты колонок, шапка), + копится в одном объекте `StatementDraft`, а функция `buildStatementPatch` превращает его в тело запроса и заранее + ловит ошибки, которые иначе вернул бы сервер (неверный формат кода или даты, конфликт «шаблонная строка + тот же код»). + Ответ сервера целиком заменяет состояние экрана — так пересчитанные ядром статусы и проверки всегда актуальны. +- **Почему тесты только на функции.** Так принято в arm-web: компоненты не тестируются, а всё, что решает «что + показать», вынесено в `.ts`-функции. Компоненты проверяются руками в браузере по сценарию T11 со сверкой с макетом. +- **Новых npm-пакетов нет:** всё нужное (выпадающая подсказка кода, меню строки, всплывающий список) есть в antd; в + зеркале npm банка не всё доступно (vitest ≤ 3.1.4). + +## Разбивка на PR + +| | | +|---|---| +| Issue | RSHB/arm-web: «Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (arm-ka-docs#116)» | +| Ветка | `feat/43-statements-v2` (база — `origin/test`) | +| Зеркальный workspace | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-web/` | +| Worktree arm-web | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-web/arm-web` | +| Change OpenSpec arm-web | `openspec/changes/statements-v2/` | +| PR | в `test` (не в `main`); merge — только по команде владельца | +| После merge | `make -C /Users/andrey.epifancev/projects/awp-ca/docs sync-web` → PR в arm-core (`arm-core/web` напрямую не править) | + +## Global Constraints + +- Типы `src/types/api.ts` повторяют схемы ядра v5.19 (спека `arm-core` «Ответ `GET …/statement` по v2.0», план 3 A5/A8) + и гайд §5.6.1; полей, которых там нет, не придумывать. +- Все запросы — через `src/api/client.ts` (`api.get|post|patch`); прямого `fetch` нет. +- Суммы — строки (`string | null`); арифметики над суммами на фронте нет; `Number()` — только для уверенности (0..100). +- Подсветка ячейки «требует внимания» — **только** по `values[].needs_review`; по `confidence < 100` и + `is_recognized = false` напрямую не подсвечивать; «исправлено» (`is_modified`) — прежний синий. +- Тексты интерфейса — дословно: «Показать расшифровки (N)», «Разберите строки без кода: N», «Это расшифровка», + «Назначить код», «Сделать расшифровкой», «Подтвердить код», «Подтвердить данные», «Редактировать», «Сохранить». +- Переключателя «как в файле» нет (Р-14). +- «Сохранить» отправляет ровно один `PATCH /documents/{id}/statement` (`header`, `values`, `columns`, `lines`); пустой + черновик — запроса нет; ответ заменяет `StatementOut` целиком. +- Коды ошибок — дословно: 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` + (`detail.document_id`), `LINE_CODE_TAKEN` (`detail.line_id`, `detail.line_code`), `PERIOD_REQUIRED`, + `CHECKS_NOT_PASSED` (`detail.failures`); 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, + `LINE_CODE_REQUIRED` (у всех четырёх — `detail.line_id`, `detail.line_code` первого неверного элемента, план 3 + P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. +- Новых зависимостей npm нет; `vitest` остаётся `3.1.4`; `package-lock.json` в git не попадает. +- Компоненты (`.tsx`) юнит-тестами не покрываются; каждая чистая функция плана — в `.ts` с `.test.ts` рядом. +- Перед коммитом — `npm run format`; зелёным считается `make lint typecheck test build` (P6-18); перед PR — то же, вывод + в описание PR. +- Язык комментариев, коммитов, issue, PR — русский; идентификаторы — английские, как в API. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 ` и `Claude-Session: + https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude + Code](https://claude.com/claude-code)`. +- Находки и решения — с маркерами (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`, `H-n`); решения этого плана — `P6-n` + (ниже); исполнитель переносит их в `openspec/changes/statements-v2/design.md` arm-web, раздел «Decisions made without + discussion». +- Каждый экран после правок сверяется с PNG макета (`docs/mockup/screens/41-*.png`, `39-*.png`) при окне 1600×1000 и + прогоняется самим исполнителем в браузере (память владельца «сверять фронт с макетом»). + +## Review Focus + +1. **Якорь строки без кода, который скрыт или повторён** — строка `unreadable` после пустой пятизначной подстроки + (скрыта по умолчанию), после второй из двух строк 2410, после строки в «Прочих строках»: вложение стоит под своим + экземпляром (по ключу строки, не по коду) и не исчезает, когда якорь скрыт. Тесты — T2. +2. **Предупреждение у скрытой или несуществующей строки** — запись `issues` у строки `detail` при выключенном + переключателе (ссылка «Показать» включает расшифровки и ведёт к строке); `line_ids` со строкой, которой нет в ответе + (удалённая шаблонная), и поле шапки, которого нет в блоке шапки (`okpo`, `okei`), — уходят в список документа, а не + теряются. Тесты — T4. +3. **Смешанный черновик в одном сохранении** — сумма и подтверждение одной ячейки, действие строки и сумма той же + строки, сумма в шаблонную строку 1250 и назначение 1250 строке без кода, пустое поле кода, код, равный текущему: + тело `PATCH` не нарушает правил ядра (одно действие на строку, `UNKNOWN_CELL` по P3-10), пустое не отправляется. + Тесты — T6. +4. **Форма, принятая по 1.x** (все строки `ok`, `row_index` по порядку, `source_ref = null`, у чистых значений + `needs_review = false`, в `issues` — `MAPPING_WARNING`) — экран выглядит как до изменения, кроме подсветки по + `needs_review`; переключателя расшифровок нет (N = 0); кнопка «Подтвердить данные» не заблокирована. Тесты — T2, + T9; сценарий — T11. +5. **`job.warnings` старого формата** — строки, смесь строк и объектов, не список, объект без `severity`: панель файлов + не падает, строки показываются как `LEGACY_WARNING` `info`, мусор пропускается. Тесты — T10. + +--- + +## Карта файлов (arm-web, относительно корня репозитория) + +| Файл | Ответственность | +|---|---| +| `src/types/api.ts` | DTO v5.19: строки, значения, колонки, шапка, `issues`, `JobBrief.warnings`, тела `PATCH` | +| `src/utils/labels.ts` | подписи статуса строки и уровня предупреждения | +| `src/utils/dates.ts` | проверка даты `ГГГГ-ММ-ДД` | +| `src/widgets/DocumentReview/__fixtures__/statement-v2.json` | эталонный `StatementOut` v2 для тестов и перехвата в Playwright | +| `src/widgets/DocumentReview/statementGrid.ts` | `StatementOut` → строки сетки `GridRow` с полями v2 | +| `src/widgets/DocumentReview/cellView.ts` | вид ячейки значения и ячейки кода: фон, подсказка, приглушённый текст | +| `src/widgets/DocumentReview/formLayout.ts` | раскладка по разделам + вложения (`unreadable` / `missing` / `detail`), фильтр показа, имена | +| `src/widgets/DocumentReview/issuePlaces.ts` | привязка `issues` к месту (строка, ячейка, колонка, поле, документ), пункты списка | +| `src/widgets/DocumentReview/IssuesPanel.tsx` | список предупреждений документа со ссылкой «Показать» | +| `src/widgets/DocumentReview/headerFields.ts`, `StatementHeaderBlock.tsx` | шапка: наименование организации, `needs_review_fields`, подсказки из `header.source` | +| `src/widgets/DocumentReview/statementDraft.ts` | черновик правок и сборка тела `PATCH` (заменяет `draftToPatch`) | +| `src/widgets/DocumentReview/codeSuggestions.ts`, `lineMenu.ts` | подсказка кода из `edition_lines`, пункты меню строки, ошибка элемента `lines[]` у поля строки | +| `src/widgets/DocumentReview/editRules.ts` | когда у колонки поле даты и у ячейки кнопка подтверждения | +| `src/widgets/DocumentReview/CodeCell.tsx`, `LineMenu.tsx`, `ValueCell.tsx`, `ColumnHeader.tsx` | ячейки таблицы и заголовок колонки | +| `src/widgets/DocumentReview/StatementTable.tsx`, `StatementReview.tsx`, `ChecksPanel.tsx` | сборка экрана, переключатели, сохранение, переход к строке | +| `src/widgets/DocumentReview/confirmBlockers.ts`, `confirmError.ts` | неразобранные строки, разбор 409 подтверждения | +| `src/widgets/DocumentReview/OsvDocument.tsx` | `DocumentOut.issues` у ОСВ-документа | +| `src/widgets/FilesDrawer/fileWarnings.ts`, `documentRows.ts`, `FilesList.tsx`, `FileWarningsPopover.tsx` | предупреждения файла в панели файлов | + +--- + +## Решения плана без обсуждения (`P6-n`) + +| Маркер | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| P6-1 | Подсказка кода — `StatementOut.edition_lines` (справочник строк редакции формы, у формы без даты — всех редакций; план 3 P3-22), затем коды строк `ok` файла, которых в справочнике нет (имя — `reference_name` ?? `line_name`); отдельного запроса нет | справочник приходит с ответом формы | нет | +| P6-2 | Якорь строки `unreadable` / `missing` — строка `ok` из источника (`row_index` не `null`) с наибольшим `row_index` меньше своего; якорь `detail` — строка `ok` из источника с `row_index = parent_row_index`; нет якоря (в том числе `detail` с `parent_row_index = null` или указывающим не на `ok`) — в начало первого раздела (после ведущих заголовков разделов; у ОФР — в начало таблицы). Вложение встаёт сразу после строки-якоря; несколько вложений одного якоря — по `row_index` | спека `arm-web` «Раскладка», S-62 («строка `detail` без родителя на экране — в начале раздела»), P3-17 (тот же порядок вложений в ответе ядра) | нет | +| P6-3 | Скрытие (пустые подстроки, расшифровки) применяется **после** раскладки, фильтром | иначе скрытый якорь сдвинул бы вложения к соседу | нет | +| P6-4 | Справочное имя (`reference_name`) — основное; файловое (`line_name`, после срезания приклеенных заголовков разделов) показывается вторым серым текстом, только если отличается после нормализации: нижний регистр, `ё` → `е`, оставлены только буквы и цифры | переносы OCR («задолжен-ность») не должны давать ложного «отличается» | лишнее или пропущенное второе имя — только показ | +| P6-5 | Маркеры «у места» строятся из **всех** записей `issues` (в том числе `relation` / `soft` — у них `line_ids` + `column_id`); список `IssuesPanel` показывает только `kind = warning`; нарушения §7 остаются списком в `ChecksPanel` (из `checks`, с акцептом) и получают ссылку «Показать» по `line_ids[0]`. Запись с `column_id` без `line_ids` (например, `COLUMN_WITHOUT_DATE`) показывается у заголовка колонки; поле шапки, которого нет в блоке шапки, и строки, которых нет в ответе, — у документа | одна запись не дублируется в двух списках; заголовок колонки — естественное место для предупреждения о дате колонки | нет | +| P6-6 | Подтверждение ячейки — в черновике; доступно, только если у строки есть `id` и у значения есть `id` (у достроенных ячеек — нет, ядро ответит `UNKNOWN_CELL`); сумма в черновике той же ячейки отменяет её подтверждение | `PATCH`: в элементе `values` ровно одно из `amount` / `confirmed`; правка суммы и так снимает `needs_review` | нет | +| P6-7 | Одно действие на строку в черновике (`code` / `detail` / `confirm`), новое заменяет прежнее; пустое поле кода и код, равный текущему, — не действие; у достроенных шаблонных строк (`id = null`) действий строки нет | 422 `INVALID_LINE_PATCH` при двух действиях; `lines[]` адресуется только `line_id` | нет | +| P6-8 | Сумма в шаблонную строку по `line_code` X и назначение кода X другой строке в одном сохранении — ошибка черновика «Код X назначается строке, а в шаблонную строку X введены суммы — сохраните по очереди», запрос не уходит | ядро ответит 422 `UNKNOWN_CELL` (P3-10), а сообщение ядра не говорит, что делать | нет | +| P6-9 | Ошибка элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`) показывается у поля кода строки `detail.line_id` (текст сервера в подсказке поля и `message.error`); строки с этим `id` на экране нет — текстом над таблицей | ядро отдаёт `detail.line_id` и `detail.line_code` (план 3 P3-24) | нет | +| P6-10 | Строка «неразобранная» для кнопки «Подтвердить данные» — `line_status ∈ {unreadable, missing}` и хотя бы у одного значения `amount !== null` или `string_value !== null` (ровно условие ядра для 409 `LINES_UNRESOLVED`) | кнопка и сервер считают одинаково | нет | +| P6-11 | Подтверждение даты отчёта — кнопка «Подтвердить дату» у поля из `needs_review_fields` (уходит `header.report_date` тем же значением); дата колонки — поле `ГГГГ-ММ-ДД` у заголовка колонки (как поле даты отчёта) с кнопкой «Подтвердить дату», если дата есть и у колонки `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` | перепечатывать ту же дату ради подтверждения неочевидно; формат ввода — как у существующего поля даты | нет | +| P6-12 | Панель файлов: у строки документа — значок с числом «предупреждения файла (`job.warnings`) + `issue_counts.error` + `issue_counts.info`» документа, красный при хотя бы одном `error` (в файле или `issue_counts.error > 0`); у строки файла без документов — только `job.warnings`; нуль — значка нет. Клик открывает список: предупреждения файла (`message` и `locator`), `job.mapping_warnings` (как `info`) и `DocumentOut.issues` документа, загружаемые при открытии (`GET /documents/{id}`) | `DocumentBrief.issue_counts` (план 3 P3-23) помечает документ без запроса на каждый; подробности — по клику | акцептованное нарушение тоже даёт значок — подробности в списке | +| P6-13 | `job.warnings` на клиенте нормализуется так же, как ядро: строка → `{code: "LEGACY_WARNING", severity: "info", message, locator: null}`, объект с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается, не список → `[]` | экран не падает при ошибке порядка выкладки | нет | +| P6-14 | Сценарий T11 — на локальном стенде; строки `unreadable` / `missing` / `detail` и записи `issues` всех видов подставляются перехватом `GET` и `PATCH …/statement` (`page.route`) из фикстуры плана, пока сервисы шлют 1.x; на реальных формах 1.x — регрессия | до шага 5 выкладки 2.0-данных на стенде нет | повторный прогон на `aep_3` после шага 5 — follow-up | +| P6-15 | Цвета: ячейка `needs_review` — `#fff7e6`, исправлено — `#e6f4ff`, строка `unreadable` / `missing` целиком — `#fff1f0`, строка `ok` с `needs_review` — фон ячеек наименования и кода `#fff7e6`; подтверждённая ячейка — маленькая серая галочка перед суммой; уровень `error` — красный значок `ExclamationCircleOutlined` `#cf1322`, `info` — синий `InfoCircleOutlined` `#1677ff` | палитра уже используется на экране и в панели файлов | только вид | +| P6-16 | Ячейка кода: `ok` — код; `unreadable` — серым `line_code_text` (пусто — «—»), подсказка «Код не распознан: «<текст>»»; `missing` — серым «—», подсказка «Кода в строке нет»; `detail` — пусто, подсказка «Расшифровка — в суммы формы не входит»; у `ok` в подсказке «Уверенность кода: N%» (если меньше 100 и код не правлен), «Код подтверждён», «Код назначен аналитиком» | спека: «в ячейке кода серым — сырой текст с подсказкой» | только вид | +| 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` | нет | + +## Чего не хватает в API ядра для экрана (`O-n`) + +Все пункты закрыты сведением с планом 3 (18.10.2026, его P3-20…P3-24; спека `arm-core` и гайд §5.6.1 дополнены); +маркеры сохранены. + +- **O-1.** Закрыт: `LineOut.line_code_confidence` (строка Decimal) в A8 плана 3; тип — `string | null` (T1). +- **O-2.** Закрыт: у 409 `LINE_CODE_TAKEN` и 422 `INVALID_LINE_CODE`, `UNKNOWN_LINE`, `INVALID_LINE_PATCH`, + `LINE_CODE_REQUIRED` в `detail` есть `line_id` и `line_code` (P3-24); экран ставит ошибку у поля строки (P6-9, + `lineErrorTarget`, T7). +- **O-3.** Закрыт: `StatementOut.edition_lines [{code, name}]` (P3-22), у формы без даты — коды всех редакций; подсказка + кода — из него (P6-1). +- **O-4.** Закрыт: `DocumentBrief.issue_counts {error, info}` (P3-23); панель помечает документ без запроса (P6-12, + `rowBadge`, T10). +- **O-5.** Закрыт: `header.source.<поле>.confidence` — строка Decimal (P3-20); P6-17. +- **O-6.** Закрыт: `StatementHeaderOut.okei` (P3-21); в блоке шапки поле не показывается (четыре поля T5), запись `issues` + с `field = "okei"` по-прежнему уходит в список документа (Review Focus 2). + +## Открытые вопросы владельцу + +- **В-1.** Приёмка экрана на шаге 4 выкладки — по перехвату с фикстурой (P6-14) и регрессии на формах 1.x; проверка на + реальных 2.0-данных (`aep_3`, РБ 1 кв. 2025) — после перехода сервисов (шаг 5), отдельной задачей. Устраивает? +- **В-2.** Снят 18.10.2026: `DocumentBrief.issue_counts` (план 3 P3-23) — панель помечает и документы только с + предупреждениями документа (P6-12). +- **В-3.** Подтверждённая ячейка помечается маленькой галочкой (P6-15) — в спеке и макете вида нет. Устраивает? + +--- + +## Task 0: Подготовка — worktree, issue, ветка, OpenSpec change arm-web + +**Files:** +- Create: `docs/superpowers/plans/2026-10-18-statements-v2.md` (копия этого плана), `openspec/changes/statements-v2/proposal.md`, + `design.md`, `specs/document-review-ui/spec.md`, `specs/primary-files-ui/spec.md` + +**Interfaces:** +- Produces: worktree на ветке `feat/43-statements-v2`; хук require-plan пропускает правки `src/` (план с именем + ветки лежит в `docs/superpowers/plans/`); `design.md` с разделом `## Decisions made without discussion` (туда — `P6-n` и + рулинги контроллера). + +- [ ] **Step 1: Worktree и зеркальный workspace** (порядок — `~/.claude/CLAUDE.md`, «Роли инструментов»): + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca +mkdir -p $WS/.wt/normalization-web +git -C $WS/arm-web fetch origin +git -C $WS/arm-web worktree add -b tmp/statements-v2 $WS/.wt/normalization-web/arm-web origin/test +git -C $WS/docs fetch origin +# до слияния arm-ka-docs#116 — ветка change; после — origin/main +git -C $WS/docs worktree add --detach $WS/.wt/normalization-web/docs origin/feat/116-unified-normalization +for l in fixtures reference-old-arm CLAUDE.md .claude arm-core; do ln -s $WS/$l $WS/.wt/normalization-web/$l; done +``` + + Пути — абсолютные (`git -C worktree add` считает относительный путь от репозитория). +- [ ] **Step 2: node_modules** (P6-18) — симлинк на каталог, где есть eslint, prettier и vitest 3.1.4: + +```bash +for c in $WS/.wt/fmt/arm-web/node_modules $WS/.wt/web-proto/arm-web/node_modules; do + [ -d $c/eslint ] && [ -d $c/prettier ] && grep -q '"version": "3.1.4"' $c/vitest/package.json && echo $c && break +done +``` + + Найденный — `ln -s <путь> $WS/.wt/normalization-web/arm-web/node_modules`. Не нашёлся — `npm install` в worktree + (свой каталог; `package-lock.json` не коммитить) и отметка `O-n` в ledger. Проверка: `cd $WS/.wt/normalization-web/arm-web + && make lint typecheck test build` — PASS на базе `test`. +- [ ] **Step 3: Issue и ветка:** + +```bash +tea issues create --login home --repo RSHB/arm-web \ + --title "Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (arm-ka-docs#116)" \ + --description "Спека: arm-ka-docs, change unified-statement-normalization, specs/arm-web/spec.md. План 6: openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md. Выкладка вместе с ядром (plan 3 А+Б): JobBrief.warnings меняет тип." +git -C $WS/.wt/normalization-web/arm-web branch -m feat/43-statements-v2 +cd $WS/.wt/normalization-web/arm-web && codegraph init +``` + + Все вызовы `codegraph_explore` — с `projectPath=$WS/.wt/normalization-web/arm-web`; пустой ответ — один раз отметить в + ledger и дальше Read/Grep. +- [ ] **Step 4: Копия плана** — этот файл копируется в `docs/superpowers/plans/2026-10-18-statements-v2.md` с подстановкой + номера issue вместо `43` (`sed "s/43//g"`); проверка: `grep -c "feat/-statements-v2"` ≥ 1. +- [ ] **Step 5: Change OpenSpec** — `openspec new change statements-v2`; `proposal.md`: ссылка на change docs + (`unified-statement-normalization`, arm-ka-docs#116), что меняется (по разделу Goal), non-goals (переключатель «как в + файле», склейка форм), зависимость от ядра v5.19. Спеки-дельты (формулировки требований — из `specs/arm-web/spec.md` + docs, дословно): + - `document-review-ui`: **MODIFIED** «Форма ББ/ОФР — просмотр и правка» (подсветка «требует внимания» — по + `needs_review`; правка расширена строками, колонками, подтверждениями, наименованием организации; полный текст + требования с изменёнными фразами); **MODIFIED** «Подтверждение документа» (порядок 409 с `LINES_UNRESOLVED`, + `DUPLICATE_FORM`, отключение кнопки); **ADDED** «Раскладка строк формы со статусами», «Подсветка и предупреждения у + места», «Правка строк, колонок и подтверждение в режиме «Редактировать»», «Предупреждения ОСВ-документа» (P6-19) — + со сценариями из docs; + - `primary-files-ui`: **ADDED** «Предупреждения файла и документа в карточке файла». + `design.md` — раздел «Decisions made without discussion» с таблицей `P6-1…P6-19` (дата 2026-10-18). +- [ ] **Step 6:** `openspec validate statements-v2 --strict` — без ошибок. +- [ ] **Step 7: Commit** `openspec(statements-v2): change и план экрана формы по API v5.19 (arm-ka-docs#116, #43)`. + +## Task 1: Типы API v5.19, фикстура, строки сетки + +**Files:** +- Modify: `src/types/api.ts`, `src/widgets/DocumentReview/statementGrid.ts`, `src/utils/labels.ts`, + `src/widgets/DocumentReview/statementGrid.test.ts`, `src/widgets/DocumentReview/formLayout.test.ts` (хелпер `row()`), + `src/widgets/FilesDrawer/documentRows.test.ts` (`warnings: []` — тип) +- Create: `src/widgets/DocumentReview/__fixtures__/statement-v2.json`, `src/utils/labels.test.ts` (дополнить, если есть) + +**Interfaces:** +- Produces (в `src/types/api.ts`): + +```ts +export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail'; +export type Severity = 'error' | 'info'; +export type IssueKind = 'warning' | 'relation' | 'soft'; +export type PeriodKind = 'absolute' | 'relative:current' | 'relative:prev_year_end' | 'relative:prev_prev_year_end' + | 'relative:prev_period' | 'none'; +export interface FileWarningOut { code: string; severity: Severity; message: string; locator: string | null } +export interface IssueOut { + id: string; kind: IssueKind; code: string; severity: Severity; message: string; + line_ids: string[]; column_id: string | null; column_index: number | null; field: string | null; + source: 'service' | 'core'; relation: string | null; codes: string[]; expected: string | null; actual: string | null; +} +export interface HeaderSourceOut { text: string | null; confidence: string | null } +export interface EditionLineOut { code: string; name: string } +export interface IssueCountsOut { error: number; info: number } +export interface LineErrorDetail { code: string; message: string; line_id: string; line_code: string | null } +export interface UnresolvedLineOut { line_id: string; row_index: number | null; line_code_text: string | null } +export type CellTarget = { line_id: string } | { line_code: string }; +export type CellPatch = (CellTarget & { column_id: string; amount: string | null }) + | { line_id: string; column_id: string; confirmed: boolean }; +export type LinePatch = { line_id: string; line_code: string } | { line_id: string; line_status: 'detail' } + | { line_id: string; confirmed: boolean }; +export interface ColumnPatch { column_id: string; period_date: string } +export interface StatementPatch { + header?: { inn?: string; report_date?: string; units?: string; organization_name?: string }; + values?: CellPatch[]; columns?: ColumnPatch[]; lines?: LinePatch[]; +} +``` + + Изменения существующих: `JobBrief.warnings: FileWarningOut[]` (`mapping_warnings` — `string[]`); `DocumentOut.issues: + IssueOut[]` (`warnings: string[]` остаётся); `StatementHeaderOut` + `needs_review_fields: string[]`, `source: + Partial>`, `organization_name_modified: boolean`, `okei: string | null`; `ColumnOut` + + `period_kind: PeriodKind`, + `label_confidence: string | null`; `LineOut` + `row_index: number | null`, `source_ref: string | null`, `line_status: + LineStatus`, `line_code_text: string | null`, `line_code_confidence: string | null`, `reference_name: string | null`, + `parent_row_index: number | null`, `needs_review: boolean`, `is_confirmed: boolean`, `line_code_modified: boolean`; + `ValueOut` + `text: string | null`, `needs_review: boolean`, `is_confirmed: boolean`; `FailedCheckOut` + `codes: + string[]`, `line_ids: string[]`; `StatementOut.document.form_variant: 'full' | 'simplified' | null`; + `StatementOut.issues: IssueOut[]`, `StatementOut.edition_lines: EditionLineOut[]`; `DocumentBrief.issue_counts: + IssueCountsOut` (и `DocumentOut`, который его расширяет). +- Produces (`statementGrid.ts`): + +```ts +export type GridRow = { + key: string; lineId: string | null; code: string | null; + name: string | null; // line_name — из файла + referenceName: string | null; // справочное имя по коду + origin: 'source' | 'template'; parentCode: string | null; + lineStatus: LineStatus; rowIndex: number | null; parentRowIndex: number | null; + codeText: string | null; codeConfidence: string | null; + needsReview: boolean; isConfirmed: boolean; codeModified: boolean; + cells: Record; +}; +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] }; +export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean; confirmed: boolean }; +``` + + Ключ строки — прежний: `id`, у достроенной — `code-<код>`. `draftToPatch`, `applyDraftInput`, `emptySublineKeys`, + `cellKey` пока остаются (переезд — T6). +- Produces (`labels.ts`): `lineStatusLabel(s: LineStatus): string` — `ok` «Строка формы», `unreadable` «Код не + распознан», `missing` «Кода нет», `detail` «Расшифровка»; `severityLabel(s: Severity): string` — `error` «Ошибка», + `info` «Информация». +- Produces (фикстура `statement-v2.json`, тип `StatementOut`; у **каждой** пары «строка × колонка» есть `ValueOut`): + документ `BALANCE`, `NEEDS_CHECK`, 2025 `Q3`, `reporting_date "2025-09-30"`, `form_variant "full"`, `edition "fsbu4"`; + шапка: ИНН `7700000000` (уверенность `100`), дата отчёта `2025-09-30` (`original_value "30 сентября 2025 г."`, + уверенность `67`), единицы «тыс. руб.», `organization_name "ООО Ромашка"`, `needs_review_fields ["report_date"]`, + `okei "384"`, `source {organization_name: {text: "ООО «Ромашкa»", confidence: "67.00"}, report_date: {text: "30 + сентября 2025 г.", confidence: "67.00"}}`; колонки `c1` (`column_index 2`, «На 30 сентября 2025 г.», `2025-09-30`, `absolute`) и `c2` + (`column_index 3`, `label null`, `period_date null`, `none`); строки: + +| id | `row_index` | статус | код / `line_code_text` | наименование (`reference_name`) | `parent_row_index` | особенности (колонка `c1`) | +|---|---|---|---|---|---|---| +| `l-1110` | 0 | ok | 1110 | Нематериальные активы (то же) | — | `10` | +| `l-1150` | 1 | ok | 1150 | Основные средства (то же) | — | `70045` | +| `l-d2` | 2 | detail | — | в том числе: пригодные к использованию | 1 | `70045`, `text "70045"`, уверенность `67`, `needs_review false` | +| `l-1100` | 3 | ok | 1100 | Итого по разделу I (то же) | — | `70055` | +| `l-1210` | 4 | ok | 1210 | Запасы (то же) | — | `100` | +| `l-1230` | 5 | ok | 1230, уверенность кода `50`, строка `needs_review true` | «Дебиторская задолжен-ность» (`Дебиторская задолженность`) | — | `428311`, `text "42831 1"`, `is_recognized false`, `needs_review false` | +| `l-1240` | 6 | ok | 1240 | Финансовые вложения (то же) | — | `5` | +| `l-u7` | 7 | unreadable | `ИЧ,250` | Денежные средства | — | `50`, уверенность `67`, `needs_review true` | +| `l-m8` | 8 | missing | `null` | Прочие оборотные активы | — | `7` | +| `l-1200` | 9 | ok | 1200 | Итого по разделу II (то же) | — | `200`, `needs_review true` | +| `l-1600` | 10 | ok | 1600 | БАЛАНС (то же) | — | `70255` | +| `l-1370a` | 11 | ok | 1370 | Нераспределенная прибыль (то же) | — | `500` | +| `l-1370b` | 12 | ok | 1370 | в т.ч. прошлых лет (Нераспределенная прибыль) | — | `300` | +| `l-d13` | 13 | detail | — | справочно | `null` | `amount null`, `string_value "211(1 |"` | +| `null` | `null` | ok, `origin template` | 1250 | (Денежные средства и денежные эквиваленты) | — | все пустые, `id` значений `null` | +| `l-t1170` | `null` | ok, `origin template` | 1170 | (Финансовые вложения) | — | `3`, `is_modified true` (строка создана правкой) | + + `issues`: `i1` warning `ROW_SPLIT` info, `line_ids [l-1370a, l-1370b]`; `i2` warning `COLUMN_WITHOUT_DATE` error, + `column_id c2`; `i3` warning `ORGANIZATION_UNCERTAIN` info, `field "organization_name"`; `i4` warning `COLUMN_SKIPPED` + info, `column_index 5`, `column_id null`; `i5` warning `REPORT_DATE_UNCERTAIN` error, `field "report_date"`; `i6` + relation `RELATION_FAILED` error, `line_ids [l-1200, l-1210, l-1230, l-1240]`, `column_id c1`, `relation "1200 = 1210 + 1220 + + 1230 + 1240 + 1250 + 1260"`, `codes [...]`, `expected "155"`, `actual "200"`; `i7` warning `NAME_MISMATCH` info, source + `core`, `line_ids [l-1230]`. `checks.failed` — одно нарушение с теми же `codes`, `line_ids`, `column_index 2`. + `edition_lines` — коды ББ `fsbu4` по одному в порядке бланка с первыми именами (в том числе `{code: "1250", name: + "Денежные средства и денежные эквиваленты"}`, `{code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}`, + `{code: "1170", name: "Финансовые вложения"}`; кода `1120` нет). + +- [ ] **Step 1: Write the failing tests** (`statementGrid.test.ts`): + - `buildStatementGrid переносит поля v2` — на фикстуре строка `l-u7`: `lineStatus 'unreadable'`, `rowIndex 7`, + `codeText 'ИЧ,250'`, `code null`, `key 'l-u7'`; `l-d2`: `parentRowIndex 1`; шаблонная 1250: `key 'code-1250'`, + `rowIndex null`, `referenceName 'Денежные средства и денежные эквиваленты'`; `l-1230`: `needsReview true`, + `codeConfidence '50'`. + - `cellFlags — внимание только по needs_review` — `{confidence: '50', is_recognized: false, needs_review: false}` → + `attention false`; `{confidence: '100', needs_review: true}` → `attention true`; `{is_confirmed: true}` → `confirmed + true`; `undefined` → все `false`. + - `labels.test.ts`: четыре подписи статуса и две уровня — дословно, как в Interfaces. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/statementGrid.test.ts src/utils/labels.test.ts` — FAIL. +- [ ] **Step 3: Implement** типы, фикстуру, `GridRow`, `cellFlags`, подписи; хелперы существующих тестов (`row()` в + `formLayout.test.ts`, `v()` в `statementGrid.test.ts`, `job` в `documentRows.test.ts`) дополняются полями по умолчанию + (`lineStatus 'ok'`, `rowIndex` по порядку, `needs_review false` и т. д.), ожидания старых тестов не меняются, кроме + теста `cellFlags` по уверенности — он заменяется новым. +- [ ] **Step 4:** `make lint typecheck test build` — PASS (компоненты, читающие `job.warnings` как строки, на `test` нет). +- [ ] **Step 5: Commit** `feat(types): API формы v5.19 — статусы строк, needs_review, issues, предупреждения файла (#43)`. + +## Task 2: Раскладка — вложения строк без кода и расшифровок, фильтр показа, имена + +**Files:** +- Modify: `src/widgets/DocumentReview/formLayout.ts`, `src/widgets/DocumentReview/formLayout.test.ts` + +**Interfaces:** +- Consumes: `GridRow` (T1). +- Produces: + +```ts +export type LayoutRow = + | { kind: 'section'; key: string; title: string; level: 1 | 2 } + | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; + child: boolean; // подстрока под родителем или расшифровка — отступ + displayName: string; // справочное имя, иначе файловое без приклеенных заголовков + secondaryName: string | null; // файловое имя, если отличается (P6-4) + fullName: string | null }; // сырое line_name для подсказки, если показано не оно +export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[]; +export function filterLayout(layout: LayoutRow[], opts: { showDetails: boolean; hiddenKeys: ReadonlySet }): LayoutRow[]; +export function detailCount(rows: GridRow[]): number; +``` + + `formLayout` раскладывает по разделам только строки `lineStatus === 'ok'` (как сейчас), одинаковые коды — по + `rowIndex` (`null` последним), подстроки под родителем — по коду, затем `rowIndex`; затем вставляет вложения по P6-2. + Расшифровки возвращаются всегда; скрывает их `filterLayout`. + +- [ ] **Step 1: Write the failing tests** (`shape` в тесте: раздел — `# <заголовок>`, строка `ok` — код, `unreadable` / + `missing` — `?`, `detail` — `> `): + - `строка без кода под ближайшей предыдущей по row_index` — `1230 r0, 1240 r1, unreadable 'ИЧ,250' r2, 1250 r3` → + в разделе II: `1230`, `1240`, `?ИЧ,250`, `1250`; + - `якорь по файлу, а не по коду` — `1250 r0, unreadable r1, 1210 r2` → `1210`, `1250`, `?…`; + - `нет якоря — в начало первого раздела` — `missing r0, 1110 r1` (ББ) → `['# АКТИВ', '# I. Внеоборотные активы', '?-', + '1110', …]`; ОФР `missing r0, 2110 r1` → первая строка `?-`; + - `расшифровки под родителем по row_index с отступом` — `1150 r4, d r6 (parent 4), d r5 (parent 4)` → `1150`, `> r5`, + `> r6`, у обеих `child true`; + - `расшифровка без родителя — в начало первого раздела` — `parentRowIndex null` и `parentRowIndex 99` (нет такой + строки); + - `повтор кода: по row_index, вложение у своего экземпляра` — `2410 r9, 2410 r6, unreadable r7` (ОФР) → `2410` (r6), + `?…`, `2410` (r9) — Review Focus 1; + - `шаблонная строка не якорь` — `1250 (rowIndex null, template)`, `unreadable r1` после `1210 r0` → вложение под 1210; + - `якорь — пятизначная подстрока` — `1150 r0, 11501 r1 (parentCode 1150), unreadable r2` → `1150`, `11501`, `?…`; + - `filterLayout скрывает расшифровки и пустые подстроки, вложение скрытого якоря остаётся` — `showDetails false` убирает + `> …`; `hiddenKeys {11501}` убирает `11501`, `?…` остаётся после `1150` — Review Focus 1; + - `detailCount` — фикстура → `2`; форма без `detail` → `0` (Review Focus 4); + - `имена` — `referenceName 'Дебиторская задолженность'`, `name 'Дебиторская задолжен-ность'` → `displayName` справочное, + `secondaryName null`; `name 'Прочие оборотные'` → `secondaryName 'Прочие оборотные'`; `referenceName 'Гудвил'`, + `name 'АКТИВ I. Внеоборотные активы Гудвил'` → `secondaryName null`, `fullName` = сырое имя; строка без + `referenceName` — `displayName` из `name`, `secondaryName null`; + - `фикстура целиком` — порядок: `# АКТИВ`, `# I. Внеоборотные активы`, `> справочно` (l-d13 без родителя), `1110`, + `1150`, `> в том числе…`, `1170`, `1100`, `# II. Оборотные активы`, `1210`, `1230`, `1240`, `?ИЧ,250`, `?-`, `1250`, + `1200`, `1600`, `# ПАССИВ`, `# III. Капитал и резервы`, `1370` (a), `1370` (b). +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/formLayout.test.ts` — FAIL. +- [ ] **Step 3: Implement.** Вставка вложений — отдельной внутренней функцией после построения базовой раскладки: + +```text +anchorKey(a) = detail → ключ строки ok из источника с rowIndex === a.parentRowIndex, иначе null + иначе → ключ строки ok из источника с max rowIndex < a.rowIndex, иначе null (a.rowIndex null → null) +группы по anchorKey, внутри — по rowIndex (null последним), затем key +обход базы: после строки с ключом-якорем — её группа; группа null — после ведущих строк kind = 'section' +``` + + Имена (P6-4): нормализация для сравнения — `toLowerCase`, `ё` → `е`, удалить всё, кроме букв и цифр. +- [ ] **Step 4:** тот же запуск — PASS; `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `feat(statement): раскладка строк без кода и расшифровок на своих местах (#43)`. + +## Task 3: Таблица формы — вид строк и ячеек, переключатель расшифровок + +**Files:** +- Create: `src/widgets/DocumentReview/cellView.ts`, `cellView.test.ts`, `CodeCell.tsx`, `ValueCell.tsx` +- Modify: `src/widgets/DocumentReview/StatementTable.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `formLayout`, `filterLayout`, `detailCount` (T2), `cellFlags` (T1). +- Produces: + +```ts +export const COLORS: { attention: '#fff7e6'; modified: '#e6f4ff'; unresolved: '#fff1f0'; section: '#f2f4f7'; grand: '#f9f0ff' }; +export function valueBackground(value: ValueOut | undefined): string | undefined; // modified → attention → нет +export function rowBackground(row: GridRow): string | undefined; // unresolved для unreadable/missing; attention для ok с needsReview (только наименование и код) +export function cellTipLines(value: ValueOut, issues?: IssueOut[]): string[]; +export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined }; +``` + + `StatementTable` получает готовые `layout: LayoutRow[]` (раскладку и фильтр считает `StatementReview` в `useMemo`), а не + `rows`. Строка таблицы получает `id="row-"` (`onRow`) — для перехода к строке (T4, T8). + +- [ ] **Step 1: Write the failing tests** (`cellView.test.ts`): + - `cellTipLines` — значение `{text: '42831 1', confidence: '100', is_recognized: false}` → `['Распознано: «42831 1»', + 'Уверенность: 100%']`; исправленное `{is_modified: true, original_amount: '293709'}` → первой строкой `` `Исходное: ${formatAmount('293709', { fraction: 'auto', empty: '-' })}` `` (как сейчас); + `{is_confirmed: true}` → содержит `'Подтверждено аналитиком'`; `text null`, `confidence null` → `[]`; значение + строки `detail` с `needs_review false`, `text '70045'`, `confidence '67'` → `['Распознано: «70045»', 'Уверенность: 67%']` + (подсказка есть и без подсветки, Р-23); + - `codeCellView` — тексты P6-16 дословно для `ok` (с уверенностью 50 → `'Уверенность кода: 50%'`; `codeModified true` → + `'Код назначен аналитиком'`, уверенности нет), `unreadable 'ИЧ,250'` → `{text: 'ИЧ,250', muted: true, tip: 'Код не + распознан: «ИЧ,250»'}`, `missing` → `{text: '—', muted: true, tip: 'Кода в строке нет'}`, `detail` → `{text: '', + muted: true, tip: 'Расшифровка — в суммы формы не входит'}`; + - `valueBackground` / `rowBackground` — `needs_review true` → `#fff7e6`, `is_modified true` → `#e6f4ff` (перекрывает), + `confidence '50'` без `needs_review` → `undefined`; строка `unreadable` → `#fff1f0`, `ok` с `needsReview` → `#fff7e6`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/cellView.test.ts` — FAIL. +- [ ] **Step 3: Implement** функции; компоненты: `CodeCell` (просмотр — `codeCellView`; серым — `Typography.Text + type='secondary'`), `ValueCell` (сумма, галочка подтверждения `CheckOutlined` 10px серым, подсказка — строки + `cellTipLines` через `
`); `StatementTable`: колонка наименования — `displayName`, ниже серым `secondaryName`, + подсказка `fullName`; фон строки `unreadable` / `missing` — во всех ячейках строки; `StatementReview`: переключатель + «Показать расшифровки (N)» (`Switch`, как «Показать пустые строки детализации»), показывается при `detailCount > 0`; + пустые подстроки по-прежнему считает `emptySublineKeys` — теперь как `hiddenKeys` для `filterLayout`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Сверка с макетом.** `make start`; экран ББ на стенде (форма 1.x) и с перехватом фикстуры (рецепт — T11, + шаг 2): окно 1600×1000, скриншот против `docs/mockup/screens/41-document-check.png` — разделы, итоги, «БАЛАНС» + фиолетовым, строка «ИЧ,250» под 1240 красноватым фоном с серым кодом, переключатель «Показать расшифровки (2)»; + расхождения — в ledger (`O-n`). +- [ ] **Step 6: Commit** `feat(statement): вид строк по статусам, подсветка по needs_review, переключатель расшифровок (#43)`. + +## Task 4: Предупреждения и нарушения у места + +**Files:** +- Create: `src/widgets/DocumentReview/issuePlaces.ts`, `issuePlaces.test.ts`, `IssuesPanel.tsx` +- Modify: `StatementTable.tsx`, `ValueCell.tsx`, `StatementReview.tsx`, `ChecksPanel.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `ColumnOut`, `IssueOut` (T1); `columnTitle` (`reviewView.ts`); `lineStatusLabel`, `severityLabel` (T1). +- Produces: + +```ts +export const HEADER_BLOCK_FIELDS = ['organization_name', 'inn', 'report_date', 'units'] as const; +export interface IssuePlaces { + byRow: Map; byCell: Map; // ключ ячейки — cellKey(rowKey, columnId) + byColumn: Map; byField: Map; document: IssueOut[]; +} +export function issuePlaces(issues: IssueOut[], rows: GridRow[], columns: ColumnOut[], + fields?: readonly string[] /* по умолчанию HEADER_BLOCK_FIELDS */): IssuePlaces; +export interface IssueListItem { id: string; severity: Severity; message: string; place: string; targetKey: string | null } +export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[]; +export function worstSeverity(issues: IssueOut[]): Severity | null; +``` + + Правила `issuePlaces` (P6-5): `field` из `fields` → `byField`, `field` вне `fields` → `document`; иначе непустые + `line_ids` → известные строки (по `lineId`); ни одной известной → `document`; есть `column_id` известной колонки → + `byCell` для каждой известной строки, иначе `byRow`; без `line_ids` с известным `column_id` → `byColumn`; всё прочее + (в том числе `column_index` без `column_id`) → `document`. `issueListItems` — только `kind === 'warning'`, сначала + `error`, затем `info`, внутри — по порядку `issues`; `place`: строка `ok` — «строка <код>», `unreadable` / `missing` — + «строка без кода «»» (без текста — «строка без кода»), `detail` — «расшифровка «»»; ячейка — «<строка>, + колонка «»»; колонка — «колонка «»»; поле — «поле «<подпись поля>»»; документ — «документ», + с `column_index` без `column_id` — «столбец вне колонок формы»; `targetKey` — ключ первой строки места + (иначе `null`). + +- [ ] **Step 1: Write the failing tests** (`issuePlaces.test.ts`, на фикстуре): + - `привязка фикстуры` — `i1` в `byRow` у `l-1370a` и `l-1370b`; `i6` в `byCell` у четырёх ячеек `c1`; `i2` в + `byColumn.c2`; `i3`, `i5` в `byField`; `i4` в `document`; `i7` в `byRow.l-1230`; + - `неизвестные строки и поля — у документа` — `line_ids ['l-gone']` → `document`; `line_ids ['l-gone', 'l-1210']` → + только `l-1210`; `field 'okpo'` → `document` — Review Focus 2; + - `issueListItems` — на фикстуре 6 пунктов (без `i6`), первыми `i2` и `i5` (`error`); `i4.place === 'столбец 6 вне + колонок формы'`, `targetKey null`; `i1.place === 'строка 1370'`, `targetKey 'l-1370a'`; запись у `l-d13` → `place + 'расшифровка «справочно»'`; + - `worstSeverity` — `[info, error]` → `'error'`; `[]` → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/issuePlaces.test.ts` — FAIL. +- [ ] **Step 3: Implement** функции и UI: значок уровня (P6-15) в ячейке наименования (`byRow`), в углу ячейки значения + (`byCell`, сообщения — в её подсказке после `cellTipLines`), в заголовке колонки (`byColumn`); `IssuesPanel` — `Alert` + «Предупреждения (N)» (тип `error`, если есть `error`, иначе `info`), список с прокруткой (`maxHeight 120`, как + `failureList`), у пункта — тег уровня (`severityLabel`), текст, место и ссылка «Показать» при `targetKey`; + `ChecksPanel` — ссылка «Показать» у нарушения по `line_ids[0]`. Переход к строке — функция `focusRow(key)` в + `StatementReview`: если строка — скрытая расшифровка или скрытая пустая подстрока, сначала включает соответствующий + переключатель; после отрисовки `document.getElementById('row-' + key)?.scrollIntoView({block: 'center'})` и подсветка + рамкой на 2 с (Review Focus 2). +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате фикстуры: «Показать» у `ROW_SPLIT` прокручивает к + 1370; у записи на `l-d13` при выключенных расшифровках — включает их. +- [ ] **Step 5: Commit** `feat(statement): предупреждения и нарушения у строк, ячеек, колонок и в списке документа (#43)`. + +## Task 5: Шапка формы — наименование организации, поля на проверку, подсказки источника + +**Files:** +- Modify: `src/widgets/DocumentReview/headerFields.ts`, `headerFields.test.ts`, `StatementHeaderBlock.tsx` + +**Interfaces:** +- Consumes: `StatementHeaderOut` (T1), `IssuePlaces.byField` (T4). +- Produces: + +```ts +export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units'; +export type HeaderFieldRow = { field: HeaderFieldKey; label: string; text: string; raw: string | null; + attention: boolean; modified: boolean; tip: string | undefined; issues: IssueOut[]; canConfirm: boolean }; +export function headerFields(header: StatementHeaderOut, byField?: Map): HeaderFieldRow[]; +``` + + Порядок и подписи: «Организация», «ИНН», «Дата отчёта», «Единица измерения». `attention` — поле в + `needs_review_fields` (только); `modified` — `organization_name_modified` или `HeaderFieldOut.modified`; `tip`: + исправлено → «Исходное: » (у организации — «Распознано: «»»), иначе при + наличии `source[field]` — «Распознано: «» · уверенность N%» (`Number(confidence)`, P6-17); `canConfirm` — + `field === 'report_date'` ∧ поле в `needs_review_fields` ∧ `raw !== null`. + +- [ ] **Step 1: Write the failing tests** (`headerFields.test.ts`, фикстура): + - `четыре поля по порядку` — `['organization_name', 'inn', 'report_date', 'units']`, подписи дословно; + - `внимание только по needs_review_fields` — дата отчёта `attention true`, `canConfirm true`; ИНН с уверенностью `50` + вне `needs_review_fields` → `attention false`; пустое ИНН вне списка → `attention false`; + - `подсказка источника` — организация: `tip === 'Распознано: «ООО «Ромашкa»» · уверенность 67%'`; `confidence` строкой + `'67.00'` — тот же текст `67%`; + - `исправленная организация` — `organization_name_modified true` → `modified true`, `tip` с текстом источника; + - `предупреждения поля` — `byField` с `i3` → `issues` у организации — `[i3]`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/headerFields.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `StatementHeaderBlock` рисует строку «Организация» шире остальных (ячейка `minWidth 320`), + значки уровня из `issues`, в режиме правки — `Input` у всех четырёх полей и кнопку «Подтвердить дату» при `canConfirm` + (черновик — T6, T8). Блок по-прежнему справа над заголовком формы, как в `41-document-check.png`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `feat(statement): шапка — организация, поля на проверку и подсказки распознанного текста (#43)`. + +## Task 6: Черновик правок и один PATCH + +**Files:** +- Create: `src/widgets/DocumentReview/statementDraft.ts`, `statementDraft.test.ts` +- Modify: `src/widgets/DocumentReview/statementGrid.ts` (удалить `draftToPatch`; `applyDraftInput`, `emptySublineKeys` + остаются), `statementGrid.test.ts` (тесты `draftToPatch` переезжают), `src/utils/dates.ts`, `src/utils/dates.test.ts`, + `StatementReview.tsx`, `StatementTable.tsx`, `StatementHeaderBlock.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `cellKey` (T1), `HeaderFieldKey` (T5), `parseAmountInput` (`utils/money.ts`). +- Produces: + +```ts +// utils/dates.ts +export function isIsoDate(text: string): boolean; // строго ГГГГ-ММ-ДД и существующая дата +// statementDraft.ts +export type LineAction = { kind: 'code'; code: string } | { kind: 'detail' } | { kind: 'confirm'; confirmed: boolean }; +export type HeaderDraft = Partial> & { confirm_report_date?: boolean }; +export interface StatementDraft { + amounts: Record; // cellKey → введённый текст + confirms: Record; // cellKey → confirmed + lines: Record; // lineId → действие + columns: Record; // columnId → введённая дата + header: HeaderDraft; +} +export const emptyDraft = (): StatementDraft => ({ amounts: {}, confirms: {}, lines: {}, columns: {}, header: {} }); +export function isDraftEmpty(draft: StatementDraft): boolean; +export type DraftError = { kind: 'amount' | 'code' | 'date' | 'header' | 'conflict'; key: string; message: string }; +export function buildStatementPatch(draft: StatementDraft, ctx: { rows: GridRow[]; header: StatementHeaderOut }): + { ok: true; body: StatementPatch | null } | { ok: false; errors: DraftError[] }; +``` + + Правила сборки: `values` — суммы (строка с `id` → `line_id`, достроенная → `line_code`, как прежний `draftToPatch`), + затем подтверждения ячеек без суммы в черновике (P6-6); `lines` — действия по P6-7 (код — `^\d{4,5}$`, иначе ошибка + `code` «Код — 4 или 5 цифр»); `columns` — непустые даты, `isIsoDate`, иначе ошибка `date` «Дата в формате ГГГГ-ММ-ДД»; + `header` — `inn`, `report_date`, `units`, `organization_name` после `trim`, непустые (пустое — не отправляется); + `organization_name` длиннее 1000 — ошибка `header`; `confirm_report_date` без введённой даты → `report_date` = текущее + значение; конфликт P6-8 — ошибка `conflict` с `key` строки действия. Нет ни одной части → `body: null`. + +- [ ] **Step 1: Write the failing tests** (`statementDraft.test.ts`, фикстура; `dates.test.ts`): + - `суммы как раньше` — `l-1210:c1 = '120'` → `values [{line_id: 'l-1210', column_id: 'c1', amount: '120'}]`; шаблонная + `code-1250:c1 = '5'` → `{line_code: '1250', …}`; `'12,3,4'` → ошибка `amount`; + - `подтверждение ячейки` — `confirms {l-u7:c1: true}` → `{line_id: 'l-u7', column_id: 'c1', confirmed: true}`; та же + ячейка и в `amounts` → только элемент с `amount` (Review Focus 3); + - `действия строки` — `{l-u7: code '1250'}` → `lines [{line_id: 'l-u7', line_code: '1250'}]`; `{l-m8: detail}` → + `{line_id: 'l-m8', line_status: 'detail'}`; `{l-1230: confirm true}` → `{line_id: 'l-1230', confirmed: true}`; код + `''` → `body null`; код `'1230'` у `l-1230` → `body null`; код `'12a4'` и `'123'` → ошибка `code` с `key 'l-u7'`; + `'11501'` → проходит; + - `действие строки и сумма той же строки` — `{l-m8: detail}` и `l-m8:c1 = '8'` → оба в теле (`lines` и `values`); + - `конфликт шаблонной суммы и кода` — `code-1250:c1 = '5'` и `{l-u7: code '1250'}` → ошибка `conflict`, текст P6-8 + дословно с `1250` — Review Focus 3; + - `колонки` — `{c2: '2024-12-31'}` → `columns [{column_id: 'c2', period_date: '2024-12-31'}]`; `'31.12.2024'` и + `'2024-02-30'` → ошибка `date`; `''` → не отправляется; + - `шапка` — `organization_name ' ООО Ромашка '` → `'ООО Ромашка'`; 1001 символ → ошибка `header`; `confirm_report_date` + → `report_date '2025-09-30'`; `confirm_report_date` и введённая `'2025-06-30'` → `'2025-06-30'`; `inn ''` → нет `inn`; + - `пустой черновик` — `isDraftEmpty(emptyDraft()) === true`, `buildStatementPatch` → `{ok: true, body: null}`; + - `isIsoDate` — `'2024-12-31'` да; `'2024-2-1'`, `'2024-02-30'`, `'31.12.2024'`, `''` нет. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/statementDraft.test.ts src/utils/dates.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `StatementReview` держит одно состояние `draft: StatementDraft` вместо `draft` и `header`; + `save()` вызывает `buildStatementPatch`: ошибки — `message.error` с первой ошибкой и красная рамка у полей (ключи + ошибок передаются в таблицу и шапку), `body null` — выход из режима правки без запроса, иначе один `patchStatement`; + `dirty` = `!isDraftEmpty(draft)`. Поведение сумм и шапки (ИНН, дата, единицы) не меняется — сценарии W21–W27 + `qa-guide` §8.5 проходят. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `refactor(statement): один черновик правок и сборка единственного PATCH (#43)`. + +## Task 7: Режим «Редактировать» — коды строк, меню строки, LINE_CODE_TAKEN + +**Files:** +- Create: `src/widgets/DocumentReview/codeSuggestions.ts`, `codeSuggestions.test.ts`, `lineMenu.ts`, `lineMenu.test.ts`, + `LineMenu.tsx` +- Modify: `CodeCell.tsx`, `StatementTable.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `EditionLineOut`, `LineErrorDetail` (T1), `StatementDraft`, `LineAction` (T6). +- Produces: + +```ts +// codeSuggestions.ts +export type CodeOption = { code: string; name: string }; +export function codeOptions(editionLines: EditionLineOut[], rows: GridRow[]): CodeOption[]; + // P6-1: сначала edition_lines по порядку, затем коды строк ok файла вне справочника (name = referenceName ?? name ?? ''), без повторов +export function filterCodeOptions(options: CodeOption[], query: string, limit?: number /* 20 */): CodeOption[]; +export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null; + // P6-9: detail.line_id / detail.line_code ошибки элемента lines[] (409 LINE_CODE_TAKEN, 422 INVALID_LINE_CODE, + // INVALID_LINE_PATCH, UNKNOWN_LINE, LINE_CODE_REQUIRED); иная ошибка или нет line_id — null +// lineMenu.ts +export type LineMenuKey = 'mark_detail' | 'assign_code' | 'change_code' | 'confirm_code' | 'unconfirm_code' | 'reset'; +export function lineMenuItems(row: GridRow, action: LineAction | undefined): { key: LineMenuKey; label: string }[]; +export function applyLineMenu(row: GridRow, key: LineMenuKey, lines: Record): Record; +export function showsCodeInput(row: GridRow, action: LineAction | undefined): boolean; +``` + + Пункты (дословно): `unreadable` / `missing` — «Это расшифровка» (`mark_detail`); `detail` — «Назначить код» + (`assign_code` → действие `{kind: 'code', code: ''}`); `ok` из источника — «Сделать расшифровкой» (`mark_detail`), + «Сменить код» (`change_code` → `{kind: 'code', code: <текущий>}`), «Подтвердить код» (`confirm_code`, при `needsReview` и + не `isConfirmed`), «Снять подтверждение кода» (`unconfirm_code`, при `isConfirmed`); при наличии действия — «Отменить + изменение строки» (`reset`); строка с `lineId === null` — пусто. Поле кода видно у `unreadable` / `missing` всегда и у + любой строки с действием `code`. + +- [ ] **Step 1: Write the failing tests:** + - `codeOptions` (фикстура) — первые элементы — `edition_lines` по порядку; `{code: '1250', name: 'Денежные средства и + денежные эквиваленты'}` и `1370` — по одному разу; код строки `ok` файла вне справочника (добавить в тест строку + `ok` 1231) — в конце, с её именем; у формы с `edition_lines: []` — только коды строк `ok`; нет строк `unreadable` / + `detail`; + - `filterCodeOptions` — `'12'` → коды на `12…`; `'денеж'` → 1250; `'ДЕНЁЖ'` → 1250 (`ё` = `е`, регистр не важен); `''` → + первые 20; + - `lineMenuItems` — `l-u7` → `['mark_detail']`; `l-d2` → `['assign_code']`; `l-1230` (`needsReview`) → `['mark_detail', + 'change_code', 'confirm_code']`; `l-1230` с `isConfirmed` → `['mark_detail', 'change_code', 'unconfirm_code']`; при + действии — последним `reset`; шаблонная 1250 → `[]`; подписи дословно; + - `applyLineMenu` — `confirm_code` → `{kind: 'confirm', confirmed: true}`; `reset` убирает ключ; новое действие заменяет + прежнее; + - `lineErrorTarget` — `ApiError` 409 `{code: 'LINE_CODE_TAKEN', message, line_id: 'l-u7', line_code: '1170'}` → + `{lineId: 'l-u7', lineCode: '1170'}`; 422 `INVALID_LINE_PATCH` с `line_code: null` → `{lineId, lineCode: null}`; 409 + `LINES_UNRESOLVED` → `null`; 422 массивом (pydantic) → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/codeSuggestions.test.ts src/widgets/DocumentReview/lineMenu.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `CodeCell` в режиме правки при `showsCodeInput` — `AutoComplete` (ширина колонки кода в + режиме правки — 160) с опциями «<код> — <имя>», значение — код; ошибка формата или строка `lineErrorTarget` ответа — `status='error'` + с подсказкой; у строки с действием `detail` — серый тег «→ расшифровка», с `confirm` — «→ код подтверждён» / + «→ подтверждение снято». `LineMenu` — кнопка `MoreOutlined` в ячейке наименования (`Dropdown`, `trigger click`), + только в режиме правки. Ответ на сохранение с ошибкой элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 коды строк из + Global Constraints): `lineErrorTarget` — красное поле кода этой строки с текстом сервера и `message.error`; строки на + экране нет — тот же текст над таблицей (P6-9). Прочие 422 — `message.error(e.message)`; черновик сохраняется. +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате фикстуры: ввести 1250 у «ИЧ,250» → перехваченное + тело `PATCH` содержит `lines: [{line_id: 'l-u7', line_code: '1250'}]` и только его. +- [ ] **Step 5: Commit** `feat(statement): правка строк — код с подсказкой из справочника, меню строки, LINE_CODE_TAKEN (#43)`. + +## Task 8: Подтверждение ячеек, даты колонок, правка шапки + +**Files:** +- Create: `src/widgets/DocumentReview/ColumnHeader.tsx`, `src/widgets/DocumentReview/editRules.ts`, `editRules.test.ts` +- Modify: `ValueCell.tsx`, `StatementTable.tsx`, `StatementHeaderBlock.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `StatementDraft` (T6), `IssuePlaces.byColumn` (T4), `HeaderFieldRow.canConfirm` (T5). +- Produces: + +```ts +export const COLUMN_DATE_CODES: ReadonlySet; // COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN +export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean }; +export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): + 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null; +``` + + `columnDateEditable`: `editable` — (`period_kind === 'none'` ∧ `period_date === null`) или у колонки запись из + `COLUMN_DATE_CODES`; `canConfirm` — `editable` ∧ `period_date !== null` ∧ запись из `COLUMN_DATE_CODES` есть. + `cellConfirmState`: `null`, если у строки или значения нет `id` или в черновике есть сумма этой ячейки; черновик + `confirms[key]` → `pending-*`; иначе `needs_review` → `confirm`, `is_confirmed` → `unconfirm`, прочее — `null`. + +- [ ] **Step 1: Write the failing tests** (`editRules.test.ts`, фикстура): + - `columnDateEditable` — `c2` с `i2` → `{editable: true, canConfirm: false}`; `c1` без записей → `{false, false}`; `c1` с + записью `COLUMN_DATE_UNCERTAIN` → `{true, true}`; + - `cellConfirmState` — `l-u7:c1` (`needs_review`) → `'confirm'`; с `confirms[key] = true` → `'pending-confirm'`; с + суммой в черновике → `null`; ячейка шаблонной 1250 → `null`; значение `is_confirmed true` → `'unconfirm'`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/editRules.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `ValueCell` в режиме правки — рядом с полем суммы маленькая кнопка: `confirm` — `CheckOutlined` + «Подтвердить значение», `unconfirm` — `UndoOutlined` «Снять подтверждение», `pending-*` — та же кнопка нажатой + (повторный клик убирает из черновика); `ColumnHeader` — подпись `columnTitle`, значки `byColumn`, в режиме правки при + `editable` — `Input` `ГГГГ-ММ-ДД` (значение — черновик или `period_date`) и «Подтвердить дату» при `canConfirm` (кладёт + в черновик текущую дату); шапка — ввод организации и «Подтвердить дату» (`confirm_report_date`). +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате: подтвердить ячейку «ИЧ,250», дату `c2` + `2024-12-31`, «Подтвердить дату» отчёта, организацию «ООО Ромашка» — одно сохранение, в теле `values[{…confirmed: + true}]`, `columns`, `header {report_date: '2025-09-30', organization_name: 'ООО Ромашка'}`. +- [ ] **Step 5: Commit** `feat(statement): подтверждение ячеек, дата колонки, наименование организации и подтверждение даты (#43)`. + +## Task 9: «Подтвердить данные» — неразобранные строки и новые 409 + +**Files:** +- Create: `src/widgets/DocumentReview/confirmBlockers.ts`, `confirmBlockers.test.ts` +- Modify: `src/widgets/DocumentReview/confirmError.ts`, `confirmError.test.ts`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `GridRow` (T1), `focusRow` (T4), `UnresolvedLineOut` (T1). +- Produces: + +```ts +export function unresolvedLines(rows: GridRow[]): GridRow[]; // P6-10, по rowIndex +export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`; +// confirmError.ts +export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[]; + lines: UnresolvedLineOut[]; documentId: string | null }; +``` + +- [ ] **Step 1: Write the failing tests:** + - `unresolvedLines` (фикстура) — `['l-u7', 'l-m8']`; строка `missing` без значений — не входит; `unreadable` только со + `string_value '211(1 |'` — входит; `detail` с суммой — не входит; форма 1.x (все `ok`) — `[]` (Review Focus 4); + - `unresolvedHint(2) === 'Разберите строки без кода: 2'`; + - `confirmErrorInfo` — `ApiError(409, 'LINES_UNRESOLVED', …, {detail: {lines: [{line_id: 'l-m8', row_index: 8, + line_code_text: null}]}})` → `lines` из одного элемента; `DUPLICATE_FORM` с `detail.document_id 'doc-0'` → + `documentId 'doc-0'`; `CHECKS_NOT_PASSED` — `failures` как раньше; не `ApiError` — прежний текст, `lines []`, + `documentId null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/confirmBlockers.test.ts src/widgets/DocumentReview/confirmError.test.ts` — FAIL. +- [ ] **Step 3: Implement**; кнопка «Подтвердить данные» отключена при `unresolvedLines(rows).length > 0` (вдобавок к + `reviewActions`), обёрнута в `Tooltip` с `Typography.Link` «Разберите строки без кода: N» → `focusRow(первая.key)`; + ответ 409: `LINES_UNRESOLVED` — красный блок с текстом `unresolvedHint(detail.lines.length)` и ссылкой к первой строке + по `line_id`; `DUPLICATE_FORM` — текст сервера и ссылка «Открыть подтверждённую форму» (`navigate('/documents/' + + documentId)`); `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` — как сейчас. +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате: кнопка отключена, подсказка «Разберите строки + без кода: 2», клик ведёт к «ИЧ,250»; перехват `POST …/confirm` с 409 `DUPLICATE_FORM` даёт ссылку. +- [ ] **Step 5: Commit** `feat(statement): блокировка подтверждения при строках без кода, LINES_UNRESOLVED и DUPLICATE_FORM (#43)`. + +## Task 10: Карточка файла и ОСВ — предупреждения структурами + +**Files:** +- Create: `src/widgets/FilesDrawer/fileWarnings.ts`, `fileWarnings.test.ts`, `FileWarningsPopover.tsx` +- Modify: `src/widgets/FilesDrawer/documentRows.ts`, `documentRows.test.ts`, `FilesList.tsx`, + `src/widgets/DocumentReview/OsvDocument.tsx` + +**Interfaces:** +- Consumes: `FileWarningOut`, `IssueOut`, `JobBrief` (T1); `IssuesPanel` (T4); `getDocument` (`services/api/documents.ts`). +- Produces: + +```ts +export function toFileWarnings(value: unknown): FileWarningOut[]; // P6-13 +export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null; +export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message); +// documentRows.ts: DocumentRow + fileWarnings: FileWarningOut[]; mappingWarnings: string[]; issueCounts: IssueCountsOut +export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null; + // P6-12: count = fileWarnings.length + issueCounts.error + issueCounts.info; level 'error', если есть error в файле + // или issueCounts.error > 0; count 0 — null +``` + + `IssuesPanel` получает режим `mode: 'statement' | 'document'`: в `document` все записи — пункты без ссылок (P6-19). + +- [ ] **Step 1: Write the failing tests:** + - `toFileWarnings` — `['sheet=ОСВ 60 строки без родителя']` → `[{code: 'LEGACY_WARNING', severity: 'info', message: + 'sheet=ОСВ 60 строки без родителя', locator: null}]`; `['a', {code: 'JSON_FAILED', severity: 'error', message: 'm', + locator: 'file:x_ofr.json'}, 5, {code: 'X'}, {code: 'Y', severity: 'warn', message: 'z'}]` → два элемента + (`LEGACY_WARNING` «a», `JSON_FAILED`); `'строка'` → `[]`; `null` → `[]`; объект без `locator` → `locator null` — + Review Focus 5; + - `warningsLevel` — `[info, error]` → `'error'`; `[]` → `null`; + - `warningText` — `'m — file:x_ofr.json'`; без `locator` — `'m'`; + - `buildDocumentRows` — файл с двумя документами и `job.warnings` из одного `JSON_FAILED` → у обеих строк + `fileWarnings` длины 1; файл `ERROR` без документов — у строки файла тоже; `job null` → `[]`; `mapping_warnings` + переносятся как есть; `issue_counts` документа → `issueCounts` (строка файла без документов — `{error: 0, info: 0}`); + - `rowBadge` — документ без предупреждений файла с `issue_counts {error: 0, info: 2}` → `{count: 2, level: 'info'}`; + `JSON_FAILED` в файле и `{error: 0, info: 1}` → `{count: 2, level: 'error'}`; `{error: 1, info: 0}` без файла → + `level 'error'`; всё по нулям → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/FilesDrawer/fileWarnings.test.ts src/widgets/FilesDrawer/documentRows.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `FilesList` — в колонке «Статус» под плашкой значок с числом (`rowBadge` → цвет P6-15), + клик (`stopPropagation`, строка-ссылка не срабатывает) открывает `Popover` `FileWarningsPopover`: «Предупреждения + файла» (тег уровня + `warningText`), «Предупреждения обработки» (`mappingWarnings`, `info`), «Предупреждения документа» + — при открытии `getDocument(documentId)` → `issues` (загрузка — `Spin`, ошибка — «Не удалось загрузить», без + `documentId` раздела нет). `OsvDocument` — `IssuesPanel mode='document'` над таблицей при непустом `document.issues`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Сверка с макетом** — панель файлов против `docs/mockup/screens/39-org-documents.png` (1600×1000): значок + не ломает высоту строки и ширину колонки «Статус»; расхождения — в ledger. +- [ ] **Step 6: Commit** `feat(files): предупреждения файла структурами и предупреждения документа в панели файлов и у ОСВ (#43)`. + +## Task 11: Сценарий на стенде, сверка с макетом, документация репозитория + +**Files:** +- Modify: `README.md` (раздел про экран проверки формы — что нового, как включить перехват для демо не нужно), + `CLAUDE.md` arm-web («Архитектура»: новые чистые модули `DocumentReview` — `formLayout`, `issuePlaces`, + `statementDraft`, `cellView`, `codeSuggestions`, `lineMenu`, `editRules`, `confirmBlockers`; `FilesDrawer/fileWarnings`) + +**Interfaces:** +- Consumes: всё выше; стенд — `docs/dev/qa-guide.md` §4.1–§4.4 (ядро — из worktree или `main` arm-core с частью А, лучше + А + Б; `make start` в worktree arm-web), формы — §6.9 (Excel Межениновской) и §8.5 (`aep_6.pdf`, при поднятых + сервисах §6.8). + +- [ ] **Step 1: Регрессия на формах 1.x** (реальный стенд, пользователь `ka_go`): прогнать W19–W28b `qa-guide` §8.5 и + форму Excel `1_Форма1_бух_09.xlsx` (§6.9). Ожидание: раскладка и итоги как прежде; оранжевые только ячейки с + `needs_review` (у чистых — нет); переключателя расшифровок нет; «Подтвердить данные» доступна; в шапке — строка + «Организация»; в панели файлов у заданий, сохранённых до v5.19, предупреждения видны как «Информация». Скриншоты против + `41-document-check.png`, `41-02-document-check-edit.png`, `39-org-documents.png`. +- [ ] **Step 2: Перехват v2** (P6-14) — через `browser_run_code_unsafe` Playwright: + +```js +const fixture = /* содержимое src/widgets/DocumentReview/__fixtures__/statement-v2.json, id документа заменён на открытый */; +await page.route('**/api/v1/documents/*/statement', async (route) => { + if (route.request().method() === 'PATCH') { globalThis.lastPatch = route.request().postDataJSON(); } + await route.fulfill({ json: fixture }); +}); +``` + + Сценарии спеки `arm-web` по очереди: «Код-мусор на своём месте» (под 1240, подсвечена, серый «ИЧ,250» с подсказкой); + «Расшифровки скрыты» (не видно, «Показать расшифровки (2)», включённый показывает под 1150 и в начале раздела I); + «Догадка подтверждена проверкой» (1230: не подсвечена, подсказка «Распознано: «42831 1»»); предупреждения у места + (1370 — значок `ROW_SPLIT`, колонка 2 — `COLUMN_WITHOUT_DATE`, организация и дата — значки, `COLUMN_SKIPPED` — в списке); + «Назначить код» (тело `PATCH` — `lines: [{line_id: 'l-u7', line_code: '1250'}]`); «Подтверждение заблокировано» + (подсказка «Разберите строки без кода: 2», клик ведёт к первой); подтверждение ячейки, дата колонки, организация, + подтверждение даты — одно тело. Перехват `POST …/confirm` с 409 `LINES_UNRESOLVED` и `DUPLICATE_FORM`, `PATCH` с 409 + `LINE_CODE_TAKEN` (`detail {line_id: 'l-u7', line_code: '1170'}`) — тексты и места по T7, T9. Перехват + `GET /legal-entities/*/files` с `job.warnings` (`JSON_FAILED` `error`, `locator 'file:x_ofr.json'`) и + `documents[].issue_counts` — сценарий «Потерянный ОФР в карточке», значок у документа без предупреждений файла. +- [ ] **Step 3:** итог сверки — в ledger и в описание PR: что сверено (экраны 41, 41-02, 39), что отличается и почему + (новые элементы, которых нет в макете: поле кода, меню строки, кнопки подтверждения, значки уровней). +- [ ] **Step 4:** README и CLAUDE.md arm-web; `npm run format`; `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `docs(arm-web): экран формы v5.19 — README и карта модулей (#43)`. + +## Завершение + +- [ ] `make lint typecheck test build` — PASS, вывод — в описание PR (с пометкой P6-18: `npm install` пропущен, node_modules — + симлинк). +- [ ] Финальное ревью ветки (opus): код-ревью TS/React с объяснением решений простым языком для владельца (правило + `arm-web/CLAUDE.md`); таблица находок `## Findings` в ledger (`| Маркер | Суть | Статус |`). +- [ ] PR: `tea pulls create --login home --repo RSHB/arm-web --base test --head feat/43-statements-v2`, ссылка на issue и + на arm-ka-docs#116; в описании — «Rulings I made», `P6-n`, O-1…O-6, В-1…В-3, итог сверки с макетом, `deferred`-находки, + строка «выкладывается только вместе с ядром v5.19 (план 3 А + Б); после merge — `make -C docs sync-web`». Merge — по + команде владельца. +- [ ] После merge в `test`: `make -C /Users/andrey.epifancev/projects/awp-ca/docs sync-web SYNC_ARGS=--check` (ожидание — + код 10, отстаёт), затем `make -C …/docs sync-web` — issue и PR в arm-core (ревью кода против бэкенда — за агентом; merge + — по команде владельца). `arm-core/web` напрямую не править. +- [ ] При «закрывай» — чеклист `~/.claude/CLAUDE.md`: `openspec archive statements-v2` в arm-web, SDD workspace, ветка и + worktree `.wt/normalization-web` (симлинк `node_modules` удаляется вместе с worktree, цель симлинка не трогать), follow-up + issue с `deferred` и повторным прогоном на `aep_3` после шага 5 выкладки (В-1). + +--- + +## Покрытие спеки `arm-web` + +| Требование / сценарий | Задачи | +|---|---| +| Раскладка строк формы со статусами: разделы, `ok` по коду, повторы по `row_index`, шаблонные и пятизначные на местах | T2, T3 | +| … `unreadable` / `missing` под ближайшей предыдущей, без неё — в начале первого раздела; подсветка целиком, серый `line_code_text` | T2, T3 | +| … `detail` под переключателем «Показать расшифровки (N)», под родителем с отступом | T2, T3 | +| … справочное имя основным, файловое рядом при отличии; переключателя «как в файле» нет | T2, T3 | +| Сценарии «Код-мусор на своём месте», «Расшифровки скрыты» | T2 (тесты), T11 | +| Подсветка ячеек по `needs_review` с `text` и уверенностью; строки — по `needs_review` строки | T1, T3 | +| Поля шапки — по `needs_review_fields`, подсказка из `header.source` | T5 | +| `issues` и `checks` у строк, ячеек, полей, документа; `column_index` без `column_id` — у документа; `error` ≠ `info` | T4, T5 | +| Значения `detail` не подсвечиваются, подсказка есть; сценарий «Догадка подтверждена проверкой» | T3 | +| Режим «Редактировать»: поле кода с подсказкой, «Это расшифровка», «Назначить код», «Сделать расшифровкой», смена кода, «Подтвердить код» | T7 | +| Подтверждение и снятие подтверждения ячейки и кода; дата колонки (в том числе подтверждение той же даты) | T7, T8 | +| Наименование организации; подтверждение даты отчёта тем же значением | T5, T8 | +| Один `PATCH` (`header`, `values`, `columns`, `lines`); сценарий «Назначить код» | T6, T7, T11 | +| «Подтвердить данные» отключена с подсказкой «Разберите строки без кода: N» и переходом; 409 `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `LINE_CODE_TAKEN` | T7, T9 | +| Сценарий «Подтверждение заблокировано» | T9, T11 | +| Карточка файла: `job.warnings` структурами, `message` и `locator`, `error` ≠ `info`; `DocumentOut.issues`; `mapping_warnings` строками; сценарий «Потерянный ОФР в карточке» | T10, T11 | diff --git a/openspec/changes/statements-v2/.openspec.yaml b/openspec/changes/statements-v2/.openspec.yaml new file mode 100644 index 0000000..5c881ba --- /dev/null +++ b/openspec/changes/statements-v2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-18 diff --git a/openspec/changes/statements-v2/design.md b/openspec/changes/statements-v2/design.md new file mode 100644 index 0000000..9f9f9e9 --- /dev/null +++ b/openspec/changes/statements-v2/design.md @@ -0,0 +1,36 @@ +## Context + +Реализация плана 6 сквозного change `unified-statement-normalization` +(`../docs/openspec/changes/unified-statement-normalization/`, спека `specs/arm-web/spec.md`). Вся логика экрана — +чистые функции в `.ts` с vitest-тестами, компоненты React только рисуют их результат. Черновик правок аналитика — +одна структура `StatementDraft`, из которой одна функция собирает тело единственного `PATCH`. Подробный план — +`docs/superpowers/plans/2026-10-18-statements-v2.md`. + +## Decisions made without discussion + +Дата: 2026-10-18. Решения плана, принятые без обсуждения с владельцем; если решение неверно, цена ошибки — в +последнем столбце. + +| Маркер | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| P6-1 | Подсказка кода — `StatementOut.edition_lines` (справочник строк редакции формы, у формы без даты — всех редакций; план 3 P3-22), затем коды строк `ok` файла, которых в справочнике нет (имя — `reference_name` ?? `line_name`); отдельного запроса нет | справочник приходит с ответом формы | нет | +| P6-2 | Якорь строки `unreadable` / `missing` — строка `ok` из источника (`row_index` не `null`) с наибольшим `row_index` меньше своего; якорь `detail` — строка `ok` из источника с `row_index = parent_row_index`; нет якоря (в том числе `detail` с `parent_row_index = null` или указывающим не на `ok`) — в начало первого раздела (после ведущих заголовков разделов; у ОФР — в начало таблицы). Вложение встаёт сразу после строки-якоря; несколько вложений одного якоря — по `row_index` | спека `arm-web` «Раскладка», S-62 («строка `detail` без родителя на экране — в начале раздела»), P3-17 (тот же порядок вложений в ответе ядра) | нет | +| P6-3 | Скрытие (пустые подстроки, расшифровки) применяется **после** раскладки, фильтром | иначе скрытый якорь сдвинул бы вложения к соседу | нет | +| P6-4 | Справочное имя (`reference_name`) — основное; файловое (`line_name`, после срезания приклеенных заголовков разделов) показывается вторым серым текстом, только если отличается после нормализации: нижний регистр, `ё` → `е`, оставлены только буквы и цифры | переносы OCR («задолжен-ность») не должны давать ложного «отличается» | лишнее или пропущенное второе имя — только показ | +| P6-5 | Маркеры «у места» строятся из **всех** записей `issues` (в том числе `relation` / `soft` — у них `line_ids` + `column_id`); список `IssuesPanel` показывает только `kind = warning`; нарушения §7 остаются списком в `ChecksPanel` (из `checks`, с акцептом) и получают ссылку «Показать» по `line_ids[0]`. Запись с `column_id` без `line_ids` (например, `COLUMN_WITHOUT_DATE`) показывается у заголовка колонки; поле шапки, которого нет в блоке шапки, и строки, которых нет в ответе, — у документа | одна запись не дублируется в двух списках; заголовок колонки — естественное место для предупреждения о дате колонки | нет | +| P6-6 | Подтверждение ячейки — в черновике; доступно, только если у строки есть `id` и у значения есть `id` (у достроенных ячеек — нет, ядро ответит `UNKNOWN_CELL`); сумма в черновике той же ячейки отменяет её подтверждение | `PATCH`: в элементе `values` ровно одно из `amount` / `confirmed`; правка суммы и так снимает `needs_review` | нет | +| P6-7 | Одно действие на строку в черновике (`code` / `detail` / `confirm`), новое заменяет прежнее; пустое поле кода и код, равный текущему, — не действие; у достроенных шаблонных строк (`id = null`) действий строки нет | 422 `INVALID_LINE_PATCH` при двух действиях; `lines[]` адресуется только `line_id` | нет | +| P6-8 | Сумма в шаблонную строку по `line_code` X и назначение кода X другой строке в одном сохранении — ошибка черновика «Код X назначается строке, а в шаблонную строку X введены суммы — сохраните по очереди», запрос не уходит | ядро ответит 422 `UNKNOWN_CELL` (P3-10), а сообщение ядра не говорит, что делать | нет | +| P6-9 | Ошибка элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`) показывается у поля кода строки `detail.line_id` (текст сервера в подсказке поля и `message.error`); строки с этим `id` на экране нет — текстом над таблицей | ядро отдаёт `detail.line_id` и `detail.line_code` (план 3 P3-24) | нет | +| P6-10 | Строка «неразобранная» для кнопки «Подтвердить данные» — `line_status ∈ {unreadable, missing}` и хотя бы у одного значения `amount !== null` или `string_value !== null` (ровно условие ядра для 409 `LINES_UNRESOLVED`) | кнопка и сервер считают одинаково | нет | +| P6-11 | Подтверждение даты отчёта — кнопка «Подтвердить дату» у поля из `needs_review_fields` (уходит `header.report_date` тем же значением); дата колонки — поле `ГГГГ-ММ-ДД` у заголовка колонки (как поле даты отчёта) с кнопкой «Подтвердить дату», если дата есть и у колонки `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` | перепечатывать ту же дату ради подтверждения неочевидно; формат ввода — как у существующего поля даты | нет | +| P6-12 | Панель файлов: у строки документа — значок с числом «предупреждения файла (`job.warnings`) + `issue_counts.error` + `issue_counts.info`» документа, красный при хотя бы одном `error` (в файле или `issue_counts.error > 0`); у строки файла без документов — только `job.warnings`; нуль — значка нет. Клик открывает список: предупреждения файла (`message` и `locator`), `job.mapping_warnings` (как `info`) и `DocumentOut.issues` документа, загружаемые при открытии (`GET /documents/{id}`) | `DocumentBrief.issue_counts` (план 3 P3-23) помечает документ без запроса на каждый; подробности — по клику | акцептованное нарушение тоже даёт значок — подробности в списке | +| P6-13 | `job.warnings` на клиенте нормализуется так же, как ядро: строка → `{code: "LEGACY_WARNING", severity: "info", message, locator: null}`, объект с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается, не список → `[]` | экран не падает при ошибке порядка выкладки | нет | +| P6-14 | Сценарий T11 — на локальном стенде; строки `unreadable` / `missing` / `detail` и записи `issues` всех видов подставляются перехватом `GET` и `PATCH …/statement` (`page.route`) из фикстуры плана, пока сервисы шлют 1.x; на реальных формах 1.x — регрессия | до шага 5 выкладки 2.0-данных на стенде нет | повторный прогон на `aep_3` после шага 5 — follow-up | +| P6-15 | Цвета: ячейка `needs_review` — `#fff7e6`, исправлено — `#e6f4ff`, строка `unreadable` / `missing` целиком — `#fff1f0`, строка `ok` с `needs_review` — фон ячеек наименования и кода `#fff7e6`; подтверждённая ячейка — маленькая серая галочка перед суммой; уровень `error` — красный значок `ExclamationCircleOutlined` `#cf1322`, `info` — синий `InfoCircleOutlined` `#1677ff` | палитра уже используется на экране и в панели файлов | только вид | +| P6-16 | Ячейка кода: `ok` — код; `unreadable` — серым `line_code_text` (пусто — «—»), подсказка «Код не распознан: «<текст>»»; `missing` — серым «—», подсказка «Кода в строке нет»; `detail` — пусто, подсказка «Расшифровка — в суммы формы не входит»; у `ok` в подсказке «Уверенность кода: N%» (если меньше 100 и код не правлен), «Код подтверждён», «Код назначен аналитиком» | спека: «в ячейке кода серым — сырой текст с подсказкой» | только вид | +| 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-n`, `O-n`) дописываются сюда по ходу исполнения. diff --git a/openspec/changes/statements-v2/proposal.md b/openspec/changes/statements-v2/proposal.md new file mode 100644 index 0000000..885f7ef --- /dev/null +++ b/openspec/changes/statements-v2/proposal.md @@ -0,0 +1,34 @@ +## Why + +Сквозной change `unified-statement-normalization` (RSHB/arm-ka-docs#116) переводит ББ/ОФР на единую нормализацию: +ядро (API v5.19, контракт 2.0) отдаёт все строки источника со статусами `ok` / `unreadable` / `missing` / `detail`, +признак `needs_review` у ячеек, строк и полей шапки, предупреждения `document_issue` и структурные `job.warnings`. +Экран формы и карточка файла должны это показывать и давать разобрать. Issue — RSHB/arm-web#43. План — +`docs/superpowers/plans/2026-10-18-statements-v2.md` (план 6 сквозного change). + +## What Changes + +Экраны: [41] проверка формы ББ/ОФР, [39] панель файлов (карточка файла). + +- Раскладка «как на бумаге»: строки `unreadable` / `missing` под ближайшей предыдущей строкой с кодом, `detail` под + переключателем «Показать расшифровки (N)», справочное имя строки основное, файловое — рядом, если отличается. +- Подсветка только по `needs_review` (ячейки, строки, поля шапки); предупреждения и нарушения у места + (`issues`, `checks`). +- Режим «Редактировать»: назначение кода, «Это расшифровка», «Сделать расшифровкой», подтверждение ячеек, кодов и дат, + правка наименования организации, один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). +- «Подтвердить данные» отключена при неразобранных строках; разбор 409 `LINES_UNRESOLVED`, `DUPLICATE_FORM`, + `LINE_CODE_TAKEN`. +- Карточка файла читает `job.warnings` структурами `{code, severity, message, locator}`; предупреждения ОСВ-документа + и документа — из `DocumentOut.issues`. +- Спеки: `document-review-ui` (2 MODIFIED, 4 ADDED), `primary-files-ui` (1 ADDED). + +## Non-goals + +- Переключатель «как в файле» (Р-14: раскладка одна). +- Склейка форм с разных листов/файлов. +- Изменения API ядра и спеки docs: расхождения фиксируются как `O-n` в отчётах задач. + +## Dependencies + +Выкладка вместе с ядром (план 3 сквозного change, API v5.19): `JobBrief.warnings` меняет тип со строк на структуры. +До выкладки 2.0-данных на стенде строки со статусами подставляются перехватом запросов (P6-14). diff --git a/openspec/changes/statements-v2/specs/document-review-ui/spec.md b/openspec/changes/statements-v2/specs/document-review-ui/spec.md new file mode 100644 index 0000000..d1c63bf --- /dev/null +++ b/openspec/changes/statements-v2/specs/document-review-ui/spec.md @@ -0,0 +1,109 @@ +## MODIFIED Requirements + +### Requirement: Форма ББ/ОФР — просмотр и правка +Экран SHALL показывать `GET /documents/{id}/statement`: шапку (ИНН, дата отчёта, единицы — значение и исходное при +правке; наименование, ОКУД/ОКПО) и таблицу «строка (код, наименование) × колонки» с суммами из `values[]`, включая +строки и ячейки, достроенные сервером по справочнику редакции (`id = null`). Ячейки с `needs_review = true` SHALL +подсвечиваться как «требует внимания» (подсветки напрямую по `confidence < 100` или `is_recognized = false` нет), с +`is_modified = true` — как «исправлено» с исходным значением `original_amount` в подсказке; пустая достроенная ячейка не +подсвечивается. Строки «БАЛАНС» формы `BALANCE` (коды 1600 и 1700) SHALL выделяться фоном `#f9f0ff` и жирным шрифтом; +подсветка ячейки перекрывает фон строки. Единицы (`header.units`) SHALL выводиться над таблицей. Пока документ не +`CONFIRMED`, ячейка SHALL быть редактируемой: ввод проверяется по формату суммы (до 2 знаков после точки, знак минус +допустим, пустое — `null`), сохранение отправляет `PATCH /documents/{id}/statement {values: [...]}` суммой-строкой: +ячейка строки из БД — `{line_id, column_id, amount}`, ячейка достроенной строки (`id = null`) — `{line_code, +column_id, amount}`; ответ заменяет состояние экрана. Правка шапки (ИНН, дата отчёта, единицы, наименование +организации) SHALL отправляться тем же `PATCH` в `header`; `inn: null` не отправляется. Правка строк, колонок и +подтверждений в том же `PATCH` (`columns`, `lines`, `values[].confirmed`) описана в требовании «Правка строк, колонок и +подтверждение в режиме «Редактировать»». + +#### Scenario: Исправление ячейки +- **WHEN** аналитик меняет значение ячейки и сохраняет +- **THEN** ячейка помечена «исправлено», проверки пересчитаны по ответу сервера, акцепт снят + +#### Scenario: Заполнение строки, которой не было в распознавании +- **WHEN** у формы нет строки 1700 из источника, аналитик вводит сумму в достроенную строку 1700 и сохраняет +- **THEN** уходит `{line_code: "1700", column_id, amount}`, по ответу строка получила `id`, ячейка помечена «исправлено» + +#### Scenario: Строки «БАЛАНС» +- **WHEN** открыта форма `BALANCE` +- **THEN** строки 1600 и 1700 выделены цветом и жирным, у ОФР выделения нет + +#### Scenario: Неверный формат суммы +- **WHEN** аналитик вводит «12,3,4» +- **THEN** запрос не отправляется, поле показывает ошибку формата + +### Requirement: Подтверждение документа +Кнопка «Подтвердить» SHALL отправлять `POST /documents/{id}/confirm` и быть недоступна для `CONFIRMED` и `ERROR`, а +также при наличии строк `unreadable` / `missing` с суммой или нераспознанным текстом в ячейках (подсказка «Разберите +строки без кода: N» с переходом к первой такой строке). При 409 `CHECKS_NOT_PASSED` экран SHALL показать +`detail.failures[]` и предложить акцепт; при 409 `PERIOD_REQUIRED` — подсказать исправить дату отчёта; при 409 +`LINES_UNRESOLVED` — показать тот же текст, что у отключённой кнопки; при 409 `DUPLICATE_FORM` — со ссылкой на +подтверждённую форму; прочие ошибки — `message`. После успеха экран SHALL перейти в режим только чтения. + +#### Scenario: Подтверждение с неакцептованными нарушениями +- **WHEN** аналитик подтверждает форму с жёсткими нарушениями без акцепта +- **THEN** показан список нарушений из ответа и кнопка «Принять с комментарием» + +#### Scenario: Дубль подтверждённой формы +- **WHEN** сервер отвечает 409 `DUPLICATE_FORM` +- **THEN** экран показывает ошибку со ссылкой на уже подтверждённую форму + +## ADDED Requirements + +### Requirement: Раскладка строк формы со статусами +Экран проверки формы ББ/ОФР ([41]) SHALL сохранять раскладку «как на бумаге» (Р-14): разделы, внутри — строки `ok` по +возрастанию кода, несколько строк одного кода — в порядке файла (`row_index`), шаблонные строки на своих местах, +пятизначные подстроки под родителем. Строка `unreadable` / `missing` SHALL стоять под ближайшей предыдущей по +`row_index` строкой, размещённой по коду, а без такой — в начале первого раздела; она подсвечена целиком, в ячейке +кода серым — сырой текст `line_code_text` с подсказкой. Строки `detail` SHALL быть скрыты под переключателем +«Показать расшифровки (N)» и показываться под родителем (`parent_row_index`) с отступом. Экран показывает справочное +имя строки (`reference_name`), файловое (`line_name`) — рядом, если отличается. Переключателя «как в файле» нет. + +#### Scenario: Код-мусор на своём месте +- **WHEN** строка `unreadable` «ИЧ,250» в файле стоит после строки 1240 +- **THEN** на экране она стоит сразу под 1240, подсвечена, в ячейке кода серым «ИЧ,250» + +#### Scenario: Расшифровки скрыты +- **WHEN** у формы три строки `detail` +- **THEN** по умолчанию их не видно, переключатель «Показать расшифровки (3)»; включённый показывает их под родителями + +### Requirement: Подсветка и предупреждения у места +Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по +`needs_review` строки, поля шапки — по `needs_review_fields` (в подсказке у поля — `header.source.<поле>`: сырой текст и +уверенность). Предупреждения и нарушения (`issues`, `checks`) SHALL показываться у места: у строк (`line_ids`), ячеек +(`line_ids` + `column_id`), полей шапки (`field`) или у документа (в том числе запись с `column_index` без `column_id` — +столбец вне колонок формы); уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = +false` напрямую нет. У значений строк `detail` `needs_review` всегда `false` (Р-23): при показанных расшифровках их +сомнительные ячейки не подсвечиваются, но подсказка с `text` и уверенностью есть. + +#### Scenario: Догадка подтверждена проверкой +- **WHEN** значение `is_recognized = false`, `needs_review = false` +- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность + +### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать» +В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника +(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать +расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у +колонки без даты (`period_kind = none`) или с предупреждением о дате (в том числе подтверждение той же даты); снятие подтверждения ячейки и кода (`confirmed: +false`); правка наименования организации в шапке; подтверждение даты отчёта из `needs_review_fields` без изменения (тем +же значением). «Сохранить» отправляет один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами +SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409 +`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409 +`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или +нераспознанным текстом в ячейках. + +#### Scenario: Назначить код +- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет +- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250 + +#### Scenario: Подтверждение заблокировано +- **WHEN** у формы две строки `missing` с суммами +- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой + +### Requirement: Предупреждения ОСВ-документа +Экран ОСВ-документа SHALL показывать предупреждения документа из `DocumentOut.issues[]` (коды `OSV_*`) блоком над +таблицей; все записи относятся к документу (строки ОСВ `line_ids` не адресуются); уровень `error` отличается от `info`. + +#### Scenario: Предупреждение листа ОСВ +- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*` +- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем diff --git a/openspec/changes/statements-v2/specs/primary-files-ui/spec.md b/openspec/changes/statements-v2/specs/primary-files-ui/spec.md new file mode 100644 index 0000000..bfb18af --- /dev/null +++ b/openspec/changes/statements-v2/specs/primary-files-ui/spec.md @@ -0,0 +1,11 @@ +## ADDED Requirements + +### Requirement: Предупреждения файла и документа в карточке файла +Карточка файла (панель файлов) SHALL читать `job.warnings` как список структур `{code, severity, message, locator}` +(после выкладки ядра v5.19 это не строки; у старых заданий ядро отдаёт строки, обёрнутые в `LEGACY_WARNING`), показывать +`message` и `locator`, отличая `error` от `info`; предупреждения листа ОСВ и документа — из `DocumentOut.issues[]`. +`job.mapping_warnings` остаётся списком строк. + +#### Scenario: Потерянный ОФР в карточке +- **WHEN** у задания `job.warnings = [{code: "JSON_FAILED", severity: "error", message: "…", locator: "file:x_ofr.json"}]` +- **THEN** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки -- 2.47.2 From f83b03543e124da6485d07ae8aa6bdff48c7a9d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:15:29 +0400 Subject: [PATCH 02/22] =?UTF-8?q?feat(types):=20API=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20v5.19=20=E2=80=94=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D1=8B=20=D1=81=D1=82=D1=80=D0=BE=D0=BA,=20needs=5F?= =?UTF-8?q?review,=20issues,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=B0=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/api.ts: LineStatus, Severity, IssueKind, PeriodKind, FileWarningOut, IssueOut, EditionLineOut, IssueCountsOut, CellTarget, LinePatch, ColumnPatch; новые поля StatementHeaderOut, ColumnOut, ValueOut, LineOut, FailedCheckOut, StatementOut (form_variant, issues, edition_lines), DocumentBrief (issue_counts), DocumentOut (issues), JobBrief.warnings — FileWarningOut[]. - statementGrid.ts: GridRow получает статус строки, номер строки, код как распознан, флаги needs_review / is_confirmed / codeModified; cellFlags: внимание только по needs_review, новый флаг confirmed. - labels.ts: lineStatusLabel, severityLabel. - фикстура __fixtures__/statement-v2.json (БАЛАНС 2025 Q3, NEEDS_CHECK): 16 строк × 2 колонки, у каждой пары ValueOut. - тесты: переносы полей v2 и покрытие пар строка × колонка, cellFlags по needs_review, подписи; хелперы row() / v() / gridRow / doc() дополнены значениями по умолчанию. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/types/api.ts | 97 +- src/utils/labels.test.ts | 15 + src/utils/labels.ts | 13 +- .../__fixtures__/statement-v2.json | 1006 +++++++++++++++++ src/widgets/DocumentReview/formLayout.test.ts | 9 + .../DocumentReview/statementGrid.test.ts | 62 +- src/widgets/DocumentReview/statementGrid.ts | 36 +- src/widgets/FilesDrawer/documentRows.test.ts | 1 + 8 files changed, 1222 insertions(+), 17 deletions(-) create mode 100644 src/widgets/DocumentReview/__fixtures__/statement-v2.json diff --git a/src/types/api.ts b/src/types/api.ts index 023da04..55026d8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -28,6 +28,25 @@ export interface UploadResult { rejected: RejectedOut[]; } +/** Статусы строки формы (v5.19): ok — строка бланка; unreadable — код не распознан; missing — кода нет; detail — расшифровка. */ +export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail'; +export type Severity = 'error' | 'info'; +export type IssueKind = 'warning' | 'relation' | 'soft'; +export type PeriodKind = + | 'absolute' + | 'relative:current' + | 'relative:prev_year_end' + | 'relative:prev_prev_year_end' + | 'relative:prev_period' + | 'none'; +/** Предупреждение разбора файла (`job.warnings`); `mapping_warnings` остаются строками. */ +export interface FileWarningOut { + code: string; + severity: Severity; + message: string; + locator: string | null; +} + export type RecognitionStatus = string; // pending | processing | done | error | timeout | dlq — только для показа export interface JobBrief { id: string; @@ -35,7 +54,7 @@ export interface JobBrief { error_code: string | null; error_message: string | null; mapping_error: string | null; - warnings: string[]; + warnings: FileWarningOut[]; mapping_warnings: string[]; finished_at: string | null; } @@ -52,6 +71,7 @@ export interface DocumentBrief { counterparty_check: CheckResult | null; locator: string | null; comments_count: number; + issue_counts: IssueCountsOut; } export type FileType = 'XLS' | 'XLSX' | 'XLSB' | 'PDF' | 'PNG' | 'JPG'; export interface FileOut { @@ -74,6 +94,36 @@ export interface DocumentOut extends DocumentBrief { confirmed_by: string | null; confirmed_at: string | null; warnings: string[]; + issues: IssueOut[]; +} +/** Замечание к документу или форме: предупреждение разбора, нарушение соотношения или мягкая проверка. */ +export interface IssueOut { + id: string; + kind: IssueKind; + code: string; + severity: Severity; + message: string; + line_ids: string[]; + column_id: string | null; + column_index: number | null; + field: string | null; + source: 'service' | 'core'; + relation: string | null; + codes: string[]; + expected: string | null; + actual: string | null; +} +export interface HeaderSourceOut { + text: string | null; + confidence: string | null; +} +export interface EditionLineOut { + code: string; + name: string; +} +export interface IssueCountsOut { + error: number; + info: number; } /** Уровень строки ОСВ (DBML `osv_group_type`). */ export type OsvGroupType = 'ACCOUNT' | 'DEPARTMENT' | 'COUNTERPARTY' | 'CONTRACT' | 'OTHER'; @@ -109,17 +159,25 @@ export interface StatementHeaderOut { report_date: HeaderFieldOut; units: HeaderFieldOut; organization_name: string | null; + organization_name_modified: boolean; okud: string | null; okpo: string | null; okopf: string | null; okfs: string | null; + okei: string | null; address: string | null; + /** поля шапки, которые требуют проверки аналитиком */ + needs_review_fields: string[]; + /** что распознано до нормализации, по имени поля */ + source: Partial>; } export interface ColumnOut { id: string; column_index: number; label: string | null; + label_confidence: string | null; period_date: string | null; + period_kind: PeriodKind; is_current: boolean | null; } export interface ValueOut { @@ -128,18 +186,32 @@ export interface ValueOut { column_index: number; amount: string | null; string_value: string | null; + text: string | null; confidence: string | null; is_recognized: boolean | null; is_modified: boolean; original_amount: string | null; + needs_review: boolean; + is_confirmed: boolean; } export interface LineOut { id: string | null; // null — строка достроена по справочнику редакции origin: 'source' | 'template'; + row_index: number | null; + source_ref: string | null; + line_status: LineStatus; line_code: string | null; + line_code_text: string | null; + line_code_confidence: string | null; + line_code_modified: boolean; line_name: string | null; + /** справочное имя строки по коду редакции */ + reference_name: string | null; // код четырёхзначной строки-родителя у пятизначной подстроки (считает ядро, AURORA-1527); иначе null parent_code: string | null; + parent_row_index: number | null; + needs_review: boolean; + is_confirmed: boolean; values: ValueOut[]; } export interface FailedCheckOut { @@ -147,6 +219,8 @@ export interface FailedCheckOut { relation: string; expected: string; actual: string; + codes: string[]; + line_ids: string[]; } export interface ChecksOut { failed: FailedCheckOut[]; @@ -165,17 +239,34 @@ export interface StatementOut { counterparty_check: CheckResult | null; locator: string | null; file_id: string; + form_variant: 'full' | 'simplified' | null; }; edition: string | null; header: StatementHeaderOut; columns: ColumnOut[]; lines: LineOut[]; checks: ChecksOut; + issues: IssueOut[]; + edition_lines: EditionLineOut[]; +} +/** Адрес строки в PATCH: из БД — `line_id`, достроенная — `line_code`. */ +export type CellTarget = { line_id: string } | { line_code: string }; +export type CellPatch = + | (CellTarget & { column_id: string; amount: string | null }) + | { line_id: string; column_id: string; confirmed: boolean }; +export type LinePatch = + | { line_id: string; line_code: string } + | { line_id: string; line_status: 'detail' } + | { line_id: string; confirmed: boolean }; +export interface ColumnPatch { + column_id: string; + period_date: string; } -export type CellPatch = ({ line_id: string } | { line_code: string }) & { column_id: string; amount: string | null }; export interface StatementPatch { - header?: { inn?: string; report_date?: string; units?: string }; + header?: { inn?: string; report_date?: string; units?: string; organization_name?: string }; values?: CellPatch[]; + columns?: ColumnPatch[]; + lines?: LinePatch[]; } export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL'; diff --git a/src/utils/labels.test.ts b/src/utils/labels.test.ts index 13b2ce6..feef141 100644 --- a/src/utils/labels.test.ts +++ b/src/utils/labels.test.ts @@ -4,9 +4,11 @@ import { documentStatusLabel, documentTypeLabel, fileStatusLabel, + lineStatusLabel, matchKindLabel, periodLabel, quarterLabel, + severityLabel, } from './labels'; import { osvPeriodLabel, quarterShortLabel } from './labels'; @@ -71,3 +73,16 @@ describe('подписи периода', () => { expect(osvPeriodLabel('странное')).toBe('странное'); }); }); + +describe('подписи формы v5.19', () => { + it('статус строки', () => { + expect(lineStatusLabel('ok')).toBe('Строка формы'); + expect(lineStatusLabel('unreadable')).toBe('Код не распознан'); + expect(lineStatusLabel('missing')).toBe('Кода нет'); + expect(lineStatusLabel('detail')).toBe('Расшифровка'); + }); + it('уровень замечания', () => { + expect(severityLabel('error')).toBe('Ошибка'); + expect(severityLabel('info')).toBe('Информация'); + }); +}); diff --git a/src/utils/labels.ts b/src/utils/labels.ts index a3959d8..b27aab6 100644 --- a/src/utils/labels.ts +++ b/src/utils/labels.ts @@ -1,4 +1,4 @@ -import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, MatchKind, Quarter } from '../types/api'; +import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, LineStatus, MatchKind, Quarter, Severity } from '../types/api'; const TYPE_LABELS: Record = { BALANCE: 'Бух. баланс', @@ -70,3 +70,14 @@ const MATCH_KIND: Record = { export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t; export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен'); + +const LINE_STATUS: Record = { + ok: 'Строка формы', + unreadable: 'Код не распознан', + missing: 'Кода нет', + detail: 'Расшифровка', +}; +export const lineStatusLabel = (s: LineStatus): string => LINE_STATUS[s] ?? s; + +const SEVERITY: Record = { error: 'Ошибка', info: 'Информация' }; +export const severityLabel = (s: Severity): string => SEVERITY[s] ?? s; diff --git a/src/widgets/DocumentReview/__fixtures__/statement-v2.json b/src/widgets/DocumentReview/__fixtures__/statement-v2.json new file mode 100644 index 0000000..8cab5b9 --- /dev/null +++ b/src/widgets/DocumentReview/__fixtures__/statement-v2.json @@ -0,0 +1,1006 @@ +{ + "document": { + "id": "7d0c2a64-2f1e-4b8a-9c1d-5e3f6a7b8c01", + "document_type": "BALANCE", + "status": "NEEDS_CHECK", + "period_year": 2025, + "period_quarter": "Q3", + "reporting_date": "2025-09-30", + "counterparty_check": null, + "locator": null, + "file_id": "3f8e1b2a-6c4d-4e5f-8a9b-0c1d2e3f4a5b", + "form_variant": "full" + }, + "edition": "fsbu4", + "header": { + "inn": { + "value": "7700000000", + "original_value": "7700000000", + "confidence": "100", + "verified": false, + "modified": false + }, + "report_date": { + "value": "2025-09-30", + "original_value": "30 сентября 2025 г.", + "confidence": "67", + "verified": false, + "modified": false + }, + "units": { + "value": "тыс. руб.", + "original_value": "тыс. руб.", + "confidence": "100", + "verified": null, + "modified": false + }, + "organization_name": "ООО Ромашка", + "organization_name_modified": false, + "okud": null, + "okpo": null, + "okopf": null, + "okfs": null, + "okei": "384", + "address": null, + "needs_review_fields": ["report_date"], + "source": { + "organization_name": { + "text": "ООО «Ромашкa»", + "confidence": "67.00" + }, + "report_date": { + "text": "30 сентября 2025 г.", + "confidence": "67.00" + } + } + }, + "columns": [ + { + "id": "c1", + "column_index": 2, + "label": "На 30 сентября 2025 г.", + "label_confidence": null, + "period_date": "2025-09-30", + "period_kind": "absolute", + "is_current": true + }, + { + "id": "c2", + "column_index": 3, + "label": null, + "label_confidence": null, + "period_date": null, + "period_kind": "none", + "is_current": null + } + ], + "lines": [ + { + "id": "l-1110", + "origin": "source", + "row_index": 0, + "source_ref": null, + "line_status": "ok", + "line_code": "1110", + "line_code_text": "1110", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Нематериальные активы", + "reference_name": "Нематериальные активы", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1110-c1", + "column_id": "c1", + "column_index": 2, + "amount": "10.00", + "string_value": null, + "text": "10", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1110-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1150", + "origin": "source", + "row_index": 1, + "source_ref": null, + "line_status": "ok", + "line_code": "1150", + "line_code_text": "1150", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Основные средства", + "reference_name": "Основные средства", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1150-c1", + "column_id": "c1", + "column_index": 2, + "amount": "70045.00", + "string_value": null, + "text": "70045", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1150-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-d2", + "origin": "source", + "row_index": 2, + "source_ref": null, + "line_status": "detail", + "line_code": null, + "line_code_text": null, + "line_code_confidence": null, + "line_code_modified": false, + "line_name": "в том числе: пригодные к использованию", + "reference_name": null, + "parent_code": null, + "parent_row_index": 1, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-d2-c1", + "column_id": "c1", + "column_index": 2, + "amount": "70045.00", + "string_value": null, + "text": "70045", + "confidence": "67", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-d2-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1100", + "origin": "source", + "row_index": 3, + "source_ref": null, + "line_status": "ok", + "line_code": "1100", + "line_code_text": "1100", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Итого по разделу I", + "reference_name": "Итого по разделу I", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1100-c1", + "column_id": "c1", + "column_index": 2, + "amount": "70055.00", + "string_value": null, + "text": "70055", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1100-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1210", + "origin": "source", + "row_index": 4, + "source_ref": null, + "line_status": "ok", + "line_code": "1210", + "line_code_text": "1210", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Запасы", + "reference_name": "Запасы", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1210-c1", + "column_id": "c1", + "column_index": 2, + "amount": "100.00", + "string_value": null, + "text": "100", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1210-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1230", + "origin": "source", + "row_index": 5, + "source_ref": null, + "line_status": "ok", + "line_code": "1230", + "line_code_text": "1230", + "line_code_confidence": "50", + "line_code_modified": false, + "line_name": "Дебиторская задолжен-ность", + "reference_name": "Дебиторская задолженность", + "parent_code": null, + "parent_row_index": null, + "needs_review": true, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1230-c1", + "column_id": "c1", + "column_index": 2, + "amount": "428311.00", + "string_value": null, + "text": "42831 1", + "confidence": "50", + "is_recognized": false, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1230-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1240", + "origin": "source", + "row_index": 6, + "source_ref": null, + "line_status": "ok", + "line_code": "1240", + "line_code_text": "1240", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Финансовые вложения", + "reference_name": "Финансовые вложения", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1240-c1", + "column_id": "c1", + "column_index": 2, + "amount": "5.00", + "string_value": null, + "text": "5", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1240-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-u7", + "origin": "source", + "row_index": 7, + "source_ref": null, + "line_status": "unreadable", + "line_code": null, + "line_code_text": "ИЧ,250", + "line_code_confidence": null, + "line_code_modified": false, + "line_name": "Денежные средства", + "reference_name": null, + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-u7-c1", + "column_id": "c1", + "column_index": 2, + "amount": "50.00", + "string_value": null, + "text": "50", + "confidence": "67", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": true, + "is_confirmed": false + }, + { + "id": "v-l-u7-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-m8", + "origin": "source", + "row_index": 8, + "source_ref": null, + "line_status": "missing", + "line_code": null, + "line_code_text": null, + "line_code_confidence": null, + "line_code_modified": false, + "line_name": "Прочие оборотные активы", + "reference_name": null, + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-m8-c1", + "column_id": "c1", + "column_index": 2, + "amount": "7.00", + "string_value": null, + "text": "7", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-m8-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1200", + "origin": "source", + "row_index": 9, + "source_ref": null, + "line_status": "ok", + "line_code": "1200", + "line_code_text": "1200", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Итого по разделу II", + "reference_name": "Итого по разделу II", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1200-c1", + "column_id": "c1", + "column_index": 2, + "amount": "200.00", + "string_value": null, + "text": "200", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": true, + "is_confirmed": false + }, + { + "id": "v-l-1200-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1600", + "origin": "source", + "row_index": 10, + "source_ref": null, + "line_status": "ok", + "line_code": "1600", + "line_code_text": "1600", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "БАЛАНС", + "reference_name": "БАЛАНС", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1600-c1", + "column_id": "c1", + "column_index": 2, + "amount": "70255.00", + "string_value": null, + "text": "70255", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1600-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1370a", + "origin": "source", + "row_index": 11, + "source_ref": null, + "line_status": "ok", + "line_code": "1370", + "line_code_text": "1370", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "Нераспределенная прибыль", + "reference_name": "Нераспределенная прибыль", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1370a-c1", + "column_id": "c1", + "column_index": 2, + "amount": "500.00", + "string_value": null, + "text": "500", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1370a-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-1370b", + "origin": "source", + "row_index": 12, + "source_ref": null, + "line_status": "ok", + "line_code": "1370", + "line_code_text": "1370", + "line_code_confidence": "100", + "line_code_modified": false, + "line_name": "в т.ч. прошлых лет", + "reference_name": "Нераспределенная прибыль", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-1370b-c1", + "column_id": "c1", + "column_index": 2, + "amount": "300.00", + "string_value": null, + "text": "300", + "confidence": "100", + "is_recognized": true, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-1370b-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-d13", + "origin": "source", + "row_index": 13, + "source_ref": null, + "line_status": "detail", + "line_code": null, + "line_code_text": null, + "line_code_confidence": null, + "line_code_modified": false, + "line_name": "справочно", + "reference_name": null, + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-l-d13-c1", + "column_id": "c1", + "column_index": 2, + "amount": null, + "string_value": "211(1 |", + "text": "211(1 |", + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-l-d13-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": null, + "origin": "template", + "row_index": null, + "source_ref": null, + "line_status": "ok", + "line_code": "1250", + "line_code_text": null, + "line_code_confidence": null, + "line_code_modified": false, + "line_name": null, + "reference_name": "Денежные средства и денежные эквиваленты", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": null, + "column_id": "c1", + "column_index": 2, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": null, + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + }, + { + "id": "l-t1170", + "origin": "template", + "row_index": null, + "source_ref": null, + "line_status": "ok", + "line_code": "1170", + "line_code_text": null, + "line_code_confidence": null, + "line_code_modified": false, + "line_name": null, + "reference_name": "Финансовые вложения", + "parent_code": null, + "parent_row_index": null, + "needs_review": false, + "is_confirmed": false, + "values": [ + { + "id": "v-t1170-c1", + "column_id": "c1", + "column_index": 2, + "amount": "3.00", + "string_value": null, + "text": "3", + "confidence": null, + "is_recognized": true, + "is_modified": true, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + }, + { + "id": "v-t1170-c2", + "column_id": "c2", + "column_index": 3, + "amount": null, + "string_value": null, + "text": null, + "confidence": null, + "is_recognized": null, + "is_modified": false, + "original_amount": null, + "needs_review": false, + "is_confirmed": false + } + ] + } + ], + "checks": { + "failed": [ + { + "column_index": 2, + "relation": "1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260", + "expected": "155", + "actual": "200", + "codes": ["RELATION_1200"], + "line_ids": ["l-1200", "l-1210", "l-1230", "l-1240"] + } + ], + "soft_failed": [], + "cross_form": null, + "accepted": null + }, + "issues": [ + { + "id": "i1", + "kind": "warning", + "code": "ROW_SPLIT", + "severity": "info", + "message": "Строка 1370 разбита на две строки файла", + "line_ids": ["l-1370a", "l-1370b"], + "column_id": null, + "column_index": null, + "field": null, + "source": "service", + "relation": null, + "codes": [], + "expected": null, + "actual": null + }, + { + "id": "i2", + "kind": "warning", + "code": "COLUMN_WITHOUT_DATE", + "severity": "error", + "message": "Колонка без даты периода", + "line_ids": [], + "column_id": "c2", + "column_index": null, + "field": null, + "source": "service", + "relation": null, + "codes": [], + "expected": null, + "actual": null + }, + { + "id": "i3", + "kind": "warning", + "code": "ORGANIZATION_UNCERTAIN", + "severity": "info", + "message": "Наименование организации распознано неуверенно", + "line_ids": [], + "column_id": null, + "column_index": null, + "field": "organization_name", + "source": "service", + "relation": null, + "codes": [], + "expected": null, + "actual": null + }, + { + "id": "i4", + "kind": "warning", + "code": "COLUMN_SKIPPED", + "severity": "info", + "message": "Колонка 5 пропущена", + "line_ids": [], + "column_id": null, + "column_index": 5, + "field": null, + "source": "service", + "relation": null, + "codes": [], + "expected": null, + "actual": null + }, + { + "id": "i5", + "kind": "warning", + "code": "REPORT_DATE_UNCERTAIN", + "severity": "error", + "message": "Дата отчёта распознана неуверенно", + "line_ids": [], + "column_id": null, + "column_index": null, + "field": "report_date", + "source": "service", + "relation": null, + "codes": [], + "expected": null, + "actual": null + }, + { + "id": "i6", + "kind": "relation", + "code": "RELATION_FAILED", + "severity": "error", + "message": "Не сходится соотношение строки 1200", + "line_ids": ["l-1200", "l-1210", "l-1230", "l-1240"], + "column_id": "c1", + "column_index": 2, + "field": null, + "source": "service", + "relation": "1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260", + "codes": ["RELATION_1200"], + "expected": "155", + "actual": "200" + }, + { + "id": "i7", + "kind": "warning", + "code": "NAME_MISMATCH", + "severity": "info", + "message": "Наименование строки 1230 отличается от справочного", + "line_ids": ["l-1230"], + "column_id": null, + "column_index": null, + "field": null, + "source": "core", + "relation": null, + "codes": [], + "expected": null, + "actual": null + } + ], + "edition_lines": [ + { + "code": "1110", + "name": "Нематериальные активы" + }, + { + "code": "1150", + "name": "Основные средства" + }, + { + "code": "1170", + "name": "Финансовые вложения" + }, + { + "code": "1100", + "name": "Итого по разделу I" + }, + { + "code": "1210", + "name": "Запасы" + }, + { + "code": "1230", + "name": "Дебиторская задолженность" + }, + { + "code": "1240", + "name": "Финансовые вложения" + }, + { + "code": "1250", + "name": "Денежные средства и денежные эквиваленты" + }, + { + "code": "1200", + "name": "Итого по разделу II" + }, + { + "code": "1600", + "name": "БАЛАНС" + }, + { + "code": "1370", + "name": "Нераспределенная прибыль (непокрытый убыток)" + } + ] +} diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index 8fedfce..a73a408 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -10,6 +10,15 @@ const row = (code: string | null, name: string | null = `строка ${code}`): name, parentCode: null, cells: {}, + referenceName: null, + lineStatus: 'ok', + rowIndex: null, + parentRowIndex: null, + codeText: code, + codeConfidence: null, + needsReview: false, + isConfirmed: false, + codeModified: false, }); const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode }); const shape = (rows: ReturnType) => diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index b0a2350..943b52e 100644 --- a/src/widgets/DocumentReview/statementGrid.test.ts +++ b/src/widgets/DocumentReview/statementGrid.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { StatementOut, ValueOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; import type { GridRow } from './statementGrid'; import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid'; @@ -9,13 +10,19 @@ const v = (id: string | null, column_id: string, column_index: number, extra: Pa column_index, amount: '1.00', string_value: null, + text: null, confidence: '100', is_recognized: true, is_modified: false, original_amount: null, + needs_review: false, + is_confirmed: false, ...extra, }); +// Фикстура v5.19 (тип StatementOut): приведение нужно, т.к. импорт JSON расширяет строковые литералы до string +const statementV2 = statementV2Json as StatementOut; + const statement = { columns: [ { id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false }, @@ -32,6 +39,32 @@ describe('buildStatementGrid', () => { expect(grid.rows[0].cells.c1?.id).toBe('v1'); expect(grid.rows[0].cells.c2?.id).toBe('v2'); }); + it('буквально переносит поля v2 из фикстуры', () => { + const { rows } = buildStatementGrid(statementV2); + const byKey = new Map(rows.map((row) => [row.key, row])); + expect(byKey.get('l-u7')).toMatchObject({ + lineStatus: 'unreadable', + rowIndex: 7, + codeText: 'ИЧ,250', + code: null, + key: 'l-u7', + }); + expect(byKey.get('l-d2')?.parentRowIndex).toBe(1); + expect(byKey.get('code-1250')).toMatchObject({ + key: 'code-1250', + rowIndex: null, + referenceName: 'Денежные средства и денежные эквиваленты', + }); + expect(byKey.get('l-1230')).toMatchObject({ needsReview: true, codeConfidence: '50' }); + }); + it('каждая пара «строка × колонка» фикстуры имеет ValueOut', () => { + const { columns, rows } = buildStatementGrid(statementV2); + for (const row of rows) { + for (const column of columns) { + expect(row.cells[column.id], `${row.key} × ${column.id}`).toBeDefined(); + } + } + }); it('достроенная строка: ключ по коду, lineId = null', () => { const withTemplate = { ...statement, @@ -51,22 +84,28 @@ describe('buildStatementGrid', () => { }); }); -describe('cellFlags', () => { - it('внимание: уверенность < 100 или не распознано', () => { - expect(cellFlags(v('a', 'c', 0, { confidence: '87.5' })).attention).toBe(true); - expect(cellFlags(v('a', 'c', 0, { is_recognized: false })).attention).toBe(true); - expect(cellFlags(v('a', 'c', 0)).attention).toBe(false); - expect(cellFlags(v('a', 'c', 0, { confidence: null })).attention).toBe(false); +describe('cellFlags — внимание только по needs_review', () => { + it('низкая уверенность и is_recognized=false без needs_review — не внимание', () => { + expect(cellFlags(v('a', 'c', 0, { confidence: '50', is_recognized: false, needs_review: false })).attention).toBe(false); + }); + it('needs_review=true — внимание, даже при уверенности 100', () => { + expect(cellFlags(v('a', 'c', 0, { confidence: '100', needs_review: true })).attention).toBe(true); + }); + it('is_confirmed — подтверждена, уверенность не меняется', () => { + expect(cellFlags(v('a', 'c', 0, { is_confirmed: true })).confirmed).toBe(true); + }); + it('undefined — все флаги false', () => { + expect(cellFlags(undefined)).toEqual({ attention: false, modified: false, confirmed: false }); }); it('пустая достроенная ячейка не требует внимания', () => { expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({ attention: false, modified: false, + confirmed: false, }); }); it('исправлено', () => { expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true); - expect(cellFlags(undefined)).toEqual({ attention: false, modified: false }); }); }); @@ -129,6 +168,15 @@ describe('parent_code и пустые строки детализации', () = origin: 'source', parentCode, cells, + referenceName: null, + lineStatus: 'ok', + rowIndex: null, + parentRowIndex: null, + codeText: key, + codeConfidence: null, + needsReview: false, + isConfirmed: false, + codeModified: false, }); it('пустая подстрока: все ячейки без суммы и без строки; родитель и нулевая сумма — не пустые', () => { const rows = [ diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 8819844..0638112 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -1,13 +1,26 @@ -import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api'; +import type { CellPatch, ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api'; export type GridRow = { key: string; lineId: string | null; code: string | null; + /** line_name — наименование из файла */ name: string | null; + /** справочное имя строки по коду редакции */ + referenceName: string | null; origin: 'source' | 'template'; /** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */ parentCode: string | null; + lineStatus: LineStatus; + /** порядковый номер строки в файле; null у достроенной и созданной правкой */ + rowIndex: number | null; + parentRowIndex: number | null; + /** код как распознан в файле (например «ИЧ,250»), даже если `code` не распознан */ + codeText: string | null; + codeConfidence: string | null; + needsReview: boolean; + isConfirmed: boolean; + codeModified: boolean; cells: Record; }; @@ -26,21 +39,32 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu lineId: line.id, code: line.line_code, name: line.line_name, + referenceName: line.reference_name, origin: line.origin, parentCode: line.parent_code ?? null, + lineStatus: line.line_status, + rowIndex: line.row_index, + parentRowIndex: line.parent_row_index, + codeText: line.line_code_text, + codeConfidence: line.line_code_confidence, + needsReview: line.needs_review, + isConfirmed: line.is_confirmed, + codeModified: line.line_code_modified, cells, }; }); return { columns, rows }; } -/** Уверенность — строка Decimal «0..100»; сравнение с 100 — это не сумма, float здесь безопасен. */ -export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } { +/** + * Флаги ячейки: внимание — только по `needs_review` (уверенность решает ядро, сама по себе не подсвечивает); + * подтверждённая ячейка не меняет уверенность, это отдельный флаг. + */ +export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean; confirmed: boolean } { if (!value) { - return { attention: false, modified: false }; + return { attention: false, modified: false, confirmed: false }; } - const lowConfidence = value.confidence !== null && Number(value.confidence) < 100; - return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified }; + return { attention: value.needs_review, modified: value.is_modified, confirmed: value.is_confirmed }; } export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`; diff --git a/src/widgets/FilesDrawer/documentRows.test.ts b/src/widgets/FilesDrawer/documentRows.test.ts index 9b20410..5983204 100644 --- a/src/widgets/FilesDrawer/documentRows.test.ts +++ b/src/widgets/FilesDrawer/documentRows.test.ts @@ -14,6 +14,7 @@ const doc = (over: Partial = {}): DocumentBrief => ({ counterparty_check: null, locator: null, comments_count: 0, + issue_counts: { error: 0, info: 0 }, ...over, }); -- 2.47.2 From e4ee7f09b255acbd24f273ea0c9740b87f134352 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:17:41 +0400 Subject: [PATCH 03/22] =?UTF-8?q?fix(fixtures):=20=D0=BA=D0=BE=D0=B4=D1=8B?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D1=82=D0=BD=D0=BE=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F,=20column=5Findex,=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=20edition=5Flines=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue i6 и checks.failed: codes — коды строк соотношения, параллельно line_ids. - issue i2: column_index 3 (индекс колонки c2). - edition_lines: 1370 перед 1600, по порядку бланка. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../DocumentReview/__fixtures__/statement-v2.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/widgets/DocumentReview/__fixtures__/statement-v2.json b/src/widgets/DocumentReview/__fixtures__/statement-v2.json index 8cab5b9..f28b201 100644 --- a/src/widgets/DocumentReview/__fixtures__/statement-v2.json +++ b/src/widgets/DocumentReview/__fixtures__/statement-v2.json @@ -835,7 +835,7 @@ "relation": "1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260", "expected": "155", "actual": "200", - "codes": ["RELATION_1200"], + "codes": ["1200", "1210", "1230", "1240"], "line_ids": ["l-1200", "l-1210", "l-1230", "l-1240"] } ], @@ -868,7 +868,7 @@ "message": "Колонка без даты периода", "line_ids": [], "column_id": "c2", - "column_index": null, + "column_index": 3, "field": null, "source": "service", "relation": null, @@ -936,7 +936,7 @@ "field": null, "source": "service", "relation": "1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260", - "codes": ["RELATION_1200"], + "codes": ["1200", "1210", "1230", "1240"], "expected": "155", "actual": "200" }, @@ -994,13 +994,13 @@ "code": "1200", "name": "Итого по разделу II" }, - { - "code": "1600", - "name": "БАЛАНС" - }, { "code": "1370", "name": "Нераспределенная прибыль (непокрытый убыток)" + }, + { + "code": "1600", + "name": "БАЛАНС" } ] } -- 2.47.2 From c2f33e7af2267d80283a607f0dcafc2434625c48 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:19:37 +0400 Subject: [PATCH 04/22] =?UTF-8?q?feat(statement):=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B0=20=D1=81=D0=B2=D0=BE=D0=B8=D1=85=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D1=85=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/formLayout.test.ts | 131 +++++++++++++++++- src/widgets/DocumentReview/formLayout.ts | 107 +++++++++++--- 2 files changed, 220 insertions(+), 18 deletions(-) diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index a73a408..f031438 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { formLayout } from './formLayout'; +import type { StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { detailCount, filterLayout, formLayout } from './formLayout'; import type { GridRow } from './statementGrid'; +import { buildStatementGrid } from './statementGrid'; const row = (code: string | null, name: string | null = `строка ${code}`): GridRow => ({ key: code ?? 'x', @@ -21,6 +24,8 @@ const row = (code: string | null, name: string | null = `строка ${code}`): codeModified: false, }); const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode }); +const isLineRow = (r: ReturnType[number]): r is Extract[number], { kind: 'line' }> => + r.kind === 'line'; const shape = (rows: ReturnType) => rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`)); @@ -146,3 +151,127 @@ describe('formLayout — строки детализации (parent_code)', () expect(codes.filter((c) => c === '11101')).toHaveLength(1); }); }); + +// --- Вложения: строки без кода и расшифровки (план 6, P6-2/P6-3/P6-4) --- +const at = (key: string, code: string | null, rowIndex: number | null, extra: Partial = {}): GridRow => ({ + ...row(code), + key, + lineId: key, + rowIndex, + ...extra, +}); +const bad = (key: string, rowIndex: number, status: 'unreadable' | 'missing' = 'unreadable', codeText: string | null = 'ИЧ,250'): GridRow => + at(key, null, rowIndex, { lineStatus: status, codeText, name: `имя ${key}` }); +const det = (key: string, rowIndex: number, parentRowIndex: number | null): GridRow => + at(key, null, rowIndex, { lineStatus: 'detail', parentRowIndex, codeText: null, name: key }); +const shapeV2 = (rows: ReturnType) => + rows.map((r) => { + if (r.kind === 'section') return `# ${r.title}`; + if (r.row.lineStatus === 'detail') return `> ${r.row.name}`; + if (r.row.lineStatus !== 'ok') return `?${r.row.codeText ?? '-'}`; + return r.row.code as string; + }); + +describe('formLayout — вложения', () => { + it('строка без кода под ближайшей предыдущей по row_index', () => { + const rows = [at('a', '1230', 0), at('b', '1240', 1), bad('u', 2), at('c', '1250', 3)]; + expect(shapeV2(formLayout('BALANCE', rows)).slice(-4)).toEqual(['1230', '1240', '?ИЧ,250', '1250']); + }); + it('якорь по файлу, а не по коду', () => { + const rows = [at('a', '1250', 0), bad('u', 1), at('c', '1210', 2)]; + expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1210', '1250', '?ИЧ,250']); + }); + it('нет якоря — в начало первого раздела', () => { + const bal = shapeV2(formLayout('BALANCE', [bad('m', 0, 'missing', null), at('a', '1110', 1)])); + expect(bal.slice(0, 4)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '?-', '1110']); + expect(shapeV2(formLayout('INCOME', [bad('m', 0, 'missing', null), at('a', '2110', 1)]))[0]).toBe('?-'); + }); + it('расшифровки под родителем по row_index с отступом', () => { + const layout = formLayout('BALANCE', [at('p', '1150', 4), det('d6', 6, 4), det('d5', 5, 4)]); + expect(shapeV2(layout)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '> d5', '> d6']); + expect(layout.filter(isLineRow).map((r) => r.child)).toEqual([false, true, true]); + }); + it('расшифровка без родителя — в начало первого раздела', () => { + const rows = [at('p', '1150', 4), det('x', 5, null), det('y', 6, 99)]; + expect(shapeV2(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '> x', '> y', '1150']); + }); + it('повтор кода: по row_index, вложение у своего экземпляра', () => { + const rows = [at('b', '2410', 9), at('a', '2410', 6), bad('u', 7)]; + expect(formLayout('INCOME', rows).map((r) => (r.kind === 'line' ? r.key : ''))).toEqual(['a', 'u', 'b']); + }); + it('шаблонная строка не якорь', () => { + const rows = [at('a', '1210', 0), at('t', '1250', null, { origin: 'template', lineId: null }), bad('u', 1)]; + expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1210', '?ИЧ,250', '1250']); + }); + it('якорь — пятизначная подстрока', () => { + const rows = [at('a', '1150', 0), at('s', '11501', 1, { parentCode: '1150' }), bad('u', 2)]; + expect(shapeV2(formLayout('BALANCE', rows)).slice(-3)).toEqual(['1150', '11501', '?ИЧ,250']); + }); + it('фикстура целиком', () => { + const { rows } = buildStatementGrid(statementV2Json as StatementOut); + const layout = formLayout('BALANCE', rows); + expect(shapeV2(layout).map((s, i) => (s === '1370' ? `1370 ${(layout[i] as { key: string }).key}` : s))).toEqual([ + '# АКТИВ', + '# I. Внеоборотные активы', + '> справочно', + '1110', + '1150', + '> в том числе: пригодные к использованию', + '1170', + '1100', + '# II. Оборотные активы', + '1210', + '1230', + '1240', + '?ИЧ,250', + '?-', + '1250', + '1200', + '1600', + '# ПАССИВ', + '# III. Капитал и резервы', + '1370 l-1370a', + '1370 l-1370b', + ]); + }); +}); + +describe('filterLayout и detailCount', () => { + const rows = [at('p', '1150', 0), at('s', '11501', 1, { parentCode: '1150' }), bad('u', 2), det('d', 3, 0)]; + const none = new Set(); + it('showDetails false убирает расшифровки', () => { + const out = filterLayout(formLayout('BALANCE', rows), { showDetails: false, hiddenKeys: none }); + expect(shapeV2(out)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '11501', '?ИЧ,250']); + }); + it('скрытая подстрока уходит, вложение скрытого якоря остаётся', () => { + const out = filterLayout(formLayout('BALANCE', rows), { showDetails: true, hiddenKeys: new Set(['s']) }); + expect(shapeV2(out)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '> d', '?ИЧ,250']); + }); + it('detailCount: фикстура — 2, форма без расшифровок — 0', () => { + expect(detailCount(buildStatementGrid(statementV2Json as StatementOut).rows)).toBe(2); + expect(detailCount([row('1150')])).toBe(0); + }); +}); + +describe('formLayout — имена (P6-4)', () => { + const named = (referenceName: string | null, name: string | null) => { + const [, , line] = formLayout('BALANCE', [{ ...row('1105'), referenceName, name }]); + return line.kind === 'line' ? line : (null as never); + }; + it('перенос слова в файловом имени — это то же имя', () => { + const l = named('Дебиторская задолженность', 'Дебиторская задолжен-ность'); + expect([l.displayName, l.secondaryName]).toEqual(['Дебиторская задолженность', null]); + }); + it('другое файловое имя — второй строкой', () => { + const l = named('Дебиторская задолженность', 'Прочие оборотные'); + expect([l.displayName, l.secondaryName]).toEqual(['Дебиторская задолженность', 'Прочие оборотные']); + }); + it('приклеенные заголовки не дают второго имени, сырое — в fullName', () => { + const l = named('Гудвил', 'АКТИВ I. Внеоборотные активы Гудвил'); + expect([l.displayName, l.secondaryName, l.fullName]).toEqual(['Гудвил', null, 'АКТИВ I. Внеоборотные активы Гудвил']); + }); + it('нет справочного имени — имя из файла, второго нет', () => { + const l = named(null, 'Прочее'); + expect([l.displayName, l.secondaryName]).toEqual(['Прочее', null]); + }); +}); diff --git a/src/widgets/DocumentReview/formLayout.ts b/src/widgets/DocumentReview/formLayout.ts index 5a2d39f..f949df1 100644 --- a/src/widgets/DocumentReview/formLayout.ts +++ b/src/widgets/DocumentReview/formLayout.ts @@ -13,9 +13,13 @@ export type LayoutRow = row: GridRow; total: boolean; grand: boolean; - /** строка детализации, выведенная под своим родителем (отступ в названии) */ + /** подстрока под родителем или расшифровка — отступ в названии */ child: boolean; + /** справочное имя, иначе файловое без приклеенных заголовков */ displayName: string; + /** файловое имя, если отличается от справочного (P6-4) */ + secondaryName: string | null; + /** сырое line_name для подсказки, если показано не оно */ fullName: string | null; }; @@ -32,6 +36,9 @@ const BALANCE_LIABILITIES: Section[] = [ ]; const BALANCE_PREFIXES = ['актив', 'пассив', ...[...BALANCE_ASSETS, ...BALANCE_LIABILITIES].map((s) => s.title.toLowerCase())]; +/** По номеру строки в файле, null (достроенная, созданная правкой) — последней. */ +const byRowIndex = (a: GridRow, b: GridRow) => (a.rowIndex ?? Number.POSITIVE_INFINITY) - (b.rowIndex ?? Number.POSITIVE_INFINITY) || 0; + const codeNum = (row: GridRow): number | null => (row.code && /^\d{4}$/.test(row.code) ? Number(row.code) : null); // 2900 «Базовая прибыль (убыток) на акцию» — справочная строка ОФР, а не итог своей сотни const isTotal = (code: number) => code % 100 === 0 && code !== 2900; @@ -39,14 +46,20 @@ const isTotal = (code: number) => code % 100 === 0 && code !== 2900; const byFormOrder = (a: GridRow, b: GridRow) => { const ca = codeNum(a) as number; const cb = codeNum(b) as number; - return Number(isTotal(ca)) - Number(isTotal(cb)) || ca - cb; + return Number(isTotal(ca)) - Number(isTotal(cb)) || ca - cb || byRowIndex(a, b); }; const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim(); +/** Для сравнения имён: без регистра, ё = е, только буквы и цифры (переносы и пробелы OCR не мешают). */ +const comparable = (s: string) => + s + .toLowerCase() + .replace(/ё/g, 'е') + .replace(/[^\p{L}\p{N}]/gu, ''); /** Срезает приклеенные OCR заголовки разделов («АКТИВ I. Внеоборотные активы Гудвил» → «Гудвил»). */ -function stripSectionPrefixes(name: string | null, prefixes: string[]): { displayName: string; fullName: string | null } { - if (!name) return { displayName: '', fullName: null }; +function stripSectionPrefixes(name: string | null, prefixes: string[]): string { + if (!name) return ''; let rest = name.replace(/\s+/g, ' ').trim(); for (let changed = true; changed; ) { changed = false; @@ -57,7 +70,17 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa } } } - return rest === name.trim() || rest === '' ? { displayName: name, fullName: null } : { displayName: rest, fullName: name }; + if (rest === '' || rest === name.trim()) return name; + return rest; +} + +/** Имена строки (P6-4): справочное — основное; файловое — вторым, если отличается; сырое — в подсказку. */ +function lineNames(row: GridRow, prefixes: string[]): { displayName: string; secondaryName: string | null; fullName: string | null } { + const stripped = stripSectionPrefixes(row.name, prefixes); + const displayName = row.referenceName ?? stripped; + const secondaryName = row.referenceName && stripped && comparable(stripped) !== comparable(row.referenceName) ? stripped : null; + const shown = secondaryName ?? displayName; + return { displayName, secondaryName, fullName: row.name && row.name.trim() !== shown ? row.name : null }; } // строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026) @@ -72,7 +95,7 @@ function lineRow(row: GridRow, prefixes: string[], balance: boolean): Extract codeNum(r) === null)].map((r) => lineRow(r, [], false)); } +const isLine = (item: LayoutRow): item is Extract => item.kind === 'line'; + /** - * Строка детализации (parent_code от ядра) выводится сразу под родителем, по коду; родитель — только обычная строка формы - * (parent_code = null) из этого же набора. Без родителя в форме строка остаётся там, где была бы без parent_code. + * Вложения: строки без кода (unreadable/missing) и расшифровки (detail) стоят на своих местах. Якорь — ключ строки `ok` + * из файла (не шаблонной): у расшифровки — строка с `rowIndex === parentRowIndex`, у остальных — ближайшая предыдущая + * по `rowIndex`. Якорь ищем по ключу строки, не по коду: код может повторяться, а строку скрыть. + */ +function insertAttachments(base: LayoutRow[], attachments: GridRow[], okRows: GridRow[], prefixes: string[]): LayoutRow[] { + if (attachments.length === 0) return base; + const anchors = okRows.filter((r) => r.rowIndex !== null).sort(byRowIndex); + const anchorKey = (a: GridRow): string | null => { + if (a.lineStatus === 'detail') return anchors.find((r) => r.rowIndex === a.parentRowIndex)?.key ?? null; + if (a.rowIndex === null) return null; + let found: string | null = null; + for (const r of anchors) { + if ((r.rowIndex as number) < a.rowIndex) found = r.key; + } + return found; + }; + const groups = new Map(); + for (const a of attachments) { + const k = anchorKey(a); + groups.set(k, [...(groups.get(k) ?? []), a]); + } + const make = (a: GridRow): LayoutRow => ({ ...lineRow(a, prefixes, false), child: a.lineStatus === 'detail' }); + const emit = (k: string | null): LayoutRow[] => + (groups.get(k) ?? []).sort((a, b) => byRowIndex(a, b) || a.key.localeCompare(b.key)).map(make); + const out: LayoutRow[] = []; + let head = 0; + while (head < base.length && base[head].kind === 'section') out.push(base[head++]); + out.push(...emit(null)); + for (const item of base.slice(head)) { + out.push(item); + if (isLine(item) && groups.has(item.key)) out.push(...emit(item.key)); + } + return out; +} + +/** + * Раскладывает по разделам только строки `ok`. Подстрока (parent_code от ядра) идёт сразу под родителем — обычной строкой + * формы (parent_code = null) из этого же набора; без родителя в форме остаётся там, где была бы без parent_code. + * Затем вставляются вложения без кода и расшифровки (insertAttachments). Расшифровки возвращаются всегда, скрывает filterLayout. */ export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { - const parents = new Set(rows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string)); + const okRows = rows.filter((r) => r.lineStatus === 'ok'); + const attachments = rows.filter((r) => r.lineStatus !== 'ok'); + const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : []; + const parents = new Set(okRows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string)); const children = new Map(); const top: GridRow[] = []; - for (const r of rows) { + for (const r of okRows) { if (r.parentCode !== null && parents.has(r.parentCode)) { children.set(r.parentCode, [...(children.get(r.parentCode) ?? []), r]); } else { @@ -138,17 +203,25 @@ export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] { } } const base = type === 'BALANCE' ? balanceLayout(top) : incomeLayout(top); - if (children.size === 0) return base; - const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : []; - const out: LayoutRow[] = []; + const withKids: LayoutRow[] = []; for (const item of base) { - out.push(item); + withKids.push(item); if (item.kind !== 'line' || !item.row.code) continue; const kids = children.get(item.row.code); if (!kids) continue; children.delete(item.row.code); // два ряда с одним кодом родителя: подстроки выводим один раз - const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || a.key.localeCompare(b.key)); - for (const kid of sorted) out.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true }); + const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || byRowIndex(a, b) || a.key.localeCompare(b.key)); + for (const kid of sorted) withKids.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true }); } - return out; + return insertAttachments(withKids, attachments, okRows, prefixes); } + +/** Скрытие — после раскладки: вложение скрытого якоря остаётся, потому что якорь выбран по ключу строки. */ +export function filterLayout(layout: LayoutRow[], opts: { showDetails: boolean; hiddenKeys: ReadonlySet }): LayoutRow[] { + return layout.filter( + (item) => item.kind === 'section' || ((opts.showDetails || item.row.lineStatus !== 'detail') && !opts.hiddenKeys.has(item.key)), + ); +} + +/** Сколько в форме расшифровок (для «Показать расшифровки (N)»). */ +export const detailCount = (rows: GridRow[]): number => rows.filter((r) => r.lineStatus === 'detail').length; -- 2.47.2 From 34d523afc7f1a6d2d813579622c2e5cd5c3627dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:22:26 +0400 Subject: [PATCH 05/22] =?UTF-8?q?feat(statement):=20=D0=B2=D0=B8=D0=B4=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D0=B0=D0=BC,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=20needs=5Fr?= =?UTF-8?q?eview,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/CodeCell.tsx | 20 +++ .../DocumentReview/StatementReview.tsx | 23 +++- src/widgets/DocumentReview/StatementTable.tsx | 103 ++++++-------- src/widgets/DocumentReview/ValueCell.tsx | 42 ++++++ src/widgets/DocumentReview/cellView.test.ts | 126 ++++++++++++++++++ src/widgets/DocumentReview/cellView.ts | 64 +++++++++ 6 files changed, 313 insertions(+), 65 deletions(-) create mode 100644 src/widgets/DocumentReview/CodeCell.tsx create mode 100644 src/widgets/DocumentReview/ValueCell.tsx create mode 100644 src/widgets/DocumentReview/cellView.test.ts create mode 100644 src/widgets/DocumentReview/cellView.ts diff --git a/src/widgets/DocumentReview/CodeCell.tsx b/src/widgets/DocumentReview/CodeCell.tsx new file mode 100644 index 0000000..251faa7 --- /dev/null +++ b/src/widgets/DocumentReview/CodeCell.tsx @@ -0,0 +1,20 @@ +import { Tooltip, Typography } from 'antd'; +import { codeCellView } from './cellView'; +import type { GridRow } from './statementGrid'; + +/** Ячейка кода (просмотр): код, у нераспознанного — серый сырой текст; подсказка — по `codeCellView`. */ +export function CodeCell({ row, fontWeight }: { row: GridRow; fontWeight?: number }) { + const view = codeCellView(row); + const content = view.muted ? ( + {view.text} + ) : ( + {view.text} + ); + if (!view.tip) return content; + return ( + {view.tip}}> + {/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */} + {content} + + ); +} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 7123992..5bcc742 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -11,6 +11,7 @@ import { SourcePreview } from './SourcePreview'; import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock'; import { StatementTable } from './StatementTable'; import { confirmErrorInfo } from './confirmError'; +import { detailCount, filterLayout, formLayout } from './formLayout'; import { reviewActions } from './reviewView'; import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid'; @@ -26,6 +27,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [confirmFailures, setConfirmFailures] = useState([]); const [confirmMessage, setConfirmMessage] = useState(null); const [showEmptySublines, setShowEmptySublines] = useState(false); + const [showDetails, setShowDetails] = useState(false); const load = useCallback(async () => { try { @@ -43,10 +45,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set()), [grid, draft]); - const shownRows = useMemo(() => { - if (!grid) return []; - return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key)); - }, [grid, showEmptySublines, emptySublines]); + const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]); + const layout = useMemo( + () => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set() : emptySublines }), + [fullLayout, showDetails, showEmptySublines, emptySublines], + ); + const details = grid ? detailCount(grid.rows) : 0; const actions = reviewActions(statement?.document.status ?? 'ERROR', editing); // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR'; @@ -206,10 +210,17 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} + {details > 0 && ( + + + + + )} setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 94400a4..7e8e4d6 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -1,51 +1,33 @@ -import { Input, Table, Tooltip } from 'antd'; +import { Table, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import type { ColumnOut, DocumentType, ValueOut } from '../../types/api'; -import { formatAmount, parseAmountInput } from '../../utils/money'; -import { type LayoutRow, formLayout } from './formLayout'; +import type { ColumnOut } from '../../types/api'; +import { CodeCell } from './CodeCell'; +import { ValueCell } from './ValueCell'; +import { COLORS, rowBackground, valueBackground } from './cellView'; +import type { LayoutRow } from './formLayout'; import { columnTitle } from './reviewView'; -import { type GridRow, cellFlags, cellKey } from './statementGrid'; +import { cellKey } from './statementGrid'; type Props = { - documentType: DocumentType; columns: ColumnOut[]; - rows: GridRow[]; + layout: LayoutRow[]; editable: boolean; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; }; -const ATTENTION_BG = '#fff7e6'; -const MODIFIED_BG = '#e6f4ff'; - -function cellBackground(value: ValueOut | undefined): string | undefined { - const flags = cellFlags(value); - if (flags.modified) { - return MODIFIED_BG; - } - return flags.attention ? ATTENTION_BG : undefined; -} - -function cellTip(value: ValueOut): string | undefined { - if (value.is_modified) { - return `Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`; - } - return value.confidence ? `Уверенность: ${value.confidence}%` : undefined; -} - const CHILD_INDENT = 20; // отступ названия строки детализации под родителем -const SECTION_BG = '#f2f4f7'; -const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание) function lineWeight(r: LayoutRow): number | undefined { if (r.kind !== 'line') return undefined; if (r.grand) return 700; return r.total ? 600 : undefined; } -const lineBackground = (r: LayoutRow) => (r.kind === 'line' && r.grand ? GRAND_BG : undefined); +const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== 'ok' && rowBackground(r.row) !== undefined; +// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС») +const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined); -export function StatementTable({ documentType, columns, rows, editable, draft, onDraftChange }: Props) { - const layout = formLayout(documentType, rows); +export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) { const span = columns.length + 2; // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); @@ -57,14 +39,19 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o ...sectionSpan(r, true), style: r.kind === 'section' - ? { background: SECTION_BG, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } - : { background: lineBackground(r) }, + ? { background: COLORS.section, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 } + : { background: labelBackground(r) }, }), render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return r.title; const text = ( {r.displayName || '—'} + {r.secondaryName && ( + + {r.secondaryName} + + )} ); return r.fullName ? {text} : text; @@ -75,8 +62,8 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o title: 'Код', width: 60, align: 'center' as const, - onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }), - render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? {r.row.code} : null), + onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }), + render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? : null), }, ...columns.map((col) => ({ key: col.id, @@ -86,34 +73,32 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o onCell: (r: LayoutRow) => r.kind === 'section' ? sectionSpan(r, false) - : { style: { background: cellBackground(r.row.cells[col.id]) ?? lineBackground(r), fontWeight: lineWeight(r) } }, + : { + style: { + background: isUnresolved(r) + ? rowBackground(r.row) + : (valueBackground(r.row.cells[col.id]) ?? (r.grand ? COLORS.grand : undefined)), + fontWeight: lineWeight(r), + }, + }, render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return null; - const row = r.row; - const value = row.cells[col.id]; - if (!value) { - return null; - } - const key = cellKey(row.key, col.id); - const tip = cellTip(value); - if (!editable) { - return {formatAmount(value.amount, { fraction: 'auto', empty: '-' })}; - } - const text = draft[key] ?? value.amount ?? ''; - const invalid = key in draft && !parseAmountInput(text).ok; - return ( - - onDraftChange(key, e.target.value, value.amount)} - style={{ textAlign: 'right' }} - /> - - ); + const value = r.row.cells[col.id]; + if (!value) return null; + const draftKey = cellKey(r.row.key, col.id); + return ; }, })), ]; - return size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />; + return ( + + size='small' + bordered + rowKey='key' + columns={tableColumns} + dataSource={layout} + pagination={false} + onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})} + /> + ); } diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx new file mode 100644 index 0000000..d1ba587 --- /dev/null +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -0,0 +1,42 @@ +import { CheckOutlined } from '@ant-design/icons'; +import { Input, Tooltip } from 'antd'; +import type { ValueOut } from '../../types/api'; +import { formatAmount, parseAmountInput } from '../../utils/money'; +import { cellTipLines } from './cellView'; + +type Props = { + value: ValueOut; + editable: boolean; + /** ключ ячейки в черновике и сам черновик (текст, пока аналитик правит) */ + draftKey: string; + draft: Record; + onDraftChange: (key: string, text: string, originalAmount: string | null) => void; +}; + +/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ +export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) { + const lines = cellTipLines(value); + const tip = lines.length > 0 ? lines.map((line, i) =>
{line}
) : undefined; + if (!editable) { + return ( + + {value.is_confirmed && } + {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} + + ); + } + const text = draft[draftKey] ?? value.amount ?? ''; + const invalid = draftKey in draft && !parseAmountInput(text).ok; + return ( + + : undefined} + value={text} + onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} + style={{ textAlign: 'right' }} + /> + + ); +} diff --git a/src/widgets/DocumentReview/cellView.test.ts b/src/widgets/DocumentReview/cellView.test.ts new file mode 100644 index 0000000..1e85ecb --- /dev/null +++ b/src/widgets/DocumentReview/cellView.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import type { ValueOut } from '../../types/api'; +import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView'; +import type { GridRow } from './statementGrid'; + +const val = (extra: Partial = {}): ValueOut => ({ + id: 'v1', + column_id: 'c1', + column_index: 0, + amount: '1.00', + string_value: null, + text: null, + confidence: null, + is_recognized: true, + is_modified: false, + original_amount: null, + needs_review: false, + is_confirmed: false, + ...extra, +}); + +const row = (extra: Partial = {}): GridRow => ({ + key: 'l1', + lineId: 'l1', + code: '1240', + name: 'Финансовые вложения', + referenceName: null, + origin: 'source', + parentCode: null, + lineStatus: 'ok', + rowIndex: 1, + parentRowIndex: null, + codeText: '1240', + codeConfidence: '100', + needsReview: false, + isConfirmed: false, + codeModified: false, + cells: {}, + ...extra, +}); + +describe('cellTipLines', () => { + it('распознанный текст и уверенность', () => { + expect(cellTipLines(val({ text: '42831 1', confidence: '100', is_recognized: false }))).toEqual([ + 'Распознано: «42831 1»', + 'Уверенность: 100%', + ]); + }); + it('исправленное: первой строкой исходное значение', () => { + const lines = cellTipLines(val({ is_modified: true, original_amount: '293709' })); + expect(lines[0]).toMatch(/^Исходное: /); + expect(lines[0]).toContain('293'); + }); + it('подтверждённое', () => { + expect(cellTipLines(val({ is_confirmed: true }))).toContain('Подтверждено аналитиком'); + }); + it('нет текста и уверенности — пусто', () => { + expect(cellTipLines(val({ text: null, confidence: null }))).toEqual([]); + }); + it('расшифровка без needs_review тоже получает подсказку', () => { + expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([ + 'Распознано: «70045»', + 'Уверенность: 67%', + ]); + }); +}); + +describe('codeCellView', () => { + it('ok: код и уверенность', () => { + expect(codeCellView(row({ codeConfidence: '50' }))).toEqual({ text: '1240', muted: false, tip: 'Уверенность кода: 50%' }); + }); + it('ok: уверенность 100 — без подсказки', () => { + expect(codeCellView(row()).tip).toBeUndefined(); + }); + it('ok: назначен аналитиком — уверенности нет', () => { + expect(codeCellView(row({ codeModified: true, codeConfidence: '50' })).tip).toBe('Код назначен аналитиком'); + }); + it('ok: подтверждён', () => { + expect(codeCellView(row({ isConfirmed: true })).tip).toBe('Код подтверждён'); + }); + it('unreadable', () => { + expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: 'ИЧ,250' }))).toEqual({ + text: 'ИЧ,250', + muted: true, + tip: 'Код не распознан: «ИЧ,250»', + }); + }); + it('unreadable без текста — тире', () => { + expect(codeCellView(row({ lineStatus: 'unreadable', code: null, codeText: null })).text).toBe('—'); + }); + it('missing', () => { + expect(codeCellView(row({ lineStatus: 'missing', code: null, codeText: null }))).toEqual({ + text: '—', + muted: true, + tip: 'Кода в строке нет', + }); + }); + it('detail', () => { + expect(codeCellView(row({ lineStatus: 'detail', code: null }))).toEqual({ + text: '', + muted: true, + tip: 'Расшифровка — в суммы формы не входит', + }); + }); +}); + +describe('фон', () => { + it('needs_review — внимание', () => { + expect(valueBackground(val({ needs_review: true }))).toBe(COLORS.attention); + }); + it('исправлено перекрывает внимание', () => { + expect(valueBackground(val({ needs_review: true, is_modified: true }))).toBe(COLORS.modified); + }); + it('низкая уверенность без needs_review — без фона', () => { + expect(valueBackground(val({ confidence: '50' }))).toBeUndefined(); + expect(valueBackground(undefined)).toBeUndefined(); + }); + it('строка unreadable/missing — красноватая', () => { + expect(rowBackground(row({ lineStatus: 'unreadable' }))).toBe(COLORS.unresolved); + expect(rowBackground(row({ lineStatus: 'missing' }))).toBe(COLORS.unresolved); + }); + it('ok с needsReview — внимание; обычная — без фона', () => { + expect(rowBackground(row({ needsReview: true }))).toBe(COLORS.attention); + expect(rowBackground(row())).toBeUndefined(); + }); +}); diff --git a/src/widgets/DocumentReview/cellView.ts b/src/widgets/DocumentReview/cellView.ts new file mode 100644 index 0000000..e63450b --- /dev/null +++ b/src/widgets/DocumentReview/cellView.ts @@ -0,0 +1,64 @@ +// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются. +import type { IssueOut, ValueOut } from '../../types/api'; +import { formatAmount } from '../../utils/money'; +import { type GridRow, cellFlags } from './statementGrid'; + +export const COLORS = { + attention: '#fff7e6', // требует внимания (needs_review) + modified: '#e6f4ff', // исправлено аналитиком + unresolved: '#fff1f0', // строка без разобранного кода (unreadable / missing) + section: '#f2f4f7', // заголовок раздела + grand: '#f9f0ff', // «БАЛАНС» +} as const; + +/** Фон ячейки: исправлено перекрывает «внимание»; сама по себе низкая уверенность не подсвечивается. */ +export function valueBackground(value: ValueOut | undefined): string | undefined { + const flags = cellFlags(value); + if (flags.modified) return COLORS.modified; + return flags.attention ? COLORS.attention : undefined; +} + +/** Фон строки: без кода — вся строка; ok с needsReview — ячейки наименования и кода (суммы красит valueBackground). */ +export function rowBackground(row: GridRow): string | undefined { + if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return COLORS.unresolved; + 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.is_confirmed) lines.push('Подтверждено аналитиком'); + for (const issue of issues) lines.push(issue.message); + return lines; +} + +/** Ячейка кода (P6-16): текст, серый ли он, подсказка. */ +export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } { + switch (row.lineStatus) { + case 'unreadable': { + const raw = row.codeText?.trim() ?? ''; + return { text: raw || '—', muted: true, tip: `Код не распознан: «${raw}»` }; + } + case 'missing': + return { text: '—', muted: true, tip: 'Кода в строке нет' }; + case 'detail': + return { text: '', muted: true, tip: 'Расшифровка — в суммы формы не входит' }; + default: { + const tips: string[] = []; + if (row.codeModified) { + tips.push('Код назначен аналитиком'); + } else if (row.codeConfidence !== null && Number(row.codeConfidence) < 100) { + tips.push(`Уверенность кода: ${percent(row.codeConfidence)}`); + } + if (row.isConfirmed) tips.push('Код подтверждён'); + return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined }; + } + } +} -- 2.47.2 From 4ae8643d78a5018f3e2a58c495a31cefbb8abf07 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:25:51 +0400 Subject: [PATCH 06/22] =?UTF-8?q?feat(statement):=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=BD=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA,=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B5=D0=BA,=20=D0=BA=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=BE=D0=BA=20=D0=B8=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/ChecksPanel.tsx | 25 +++- src/widgets/DocumentReview/IssuesPanel.tsx | 43 +++++++ .../DocumentReview/StatementReview.tsx | 37 +++++- src/widgets/DocumentReview/StatementTable.tsx | 63 +++++++++- src/widgets/DocumentReview/ValueCell.tsx | 39 ++++-- .../DocumentReview/focusTarget.test.ts | 24 ++++ src/widgets/DocumentReview/focusTarget.ts | 21 ++++ .../DocumentReview/issuePlaces.test.ts | 84 +++++++++++++ src/widgets/DocumentReview/issuePlaces.ts | 119 ++++++++++++++++++ 9 files changed, 427 insertions(+), 28 deletions(-) create mode 100644 src/widgets/DocumentReview/IssuesPanel.tsx create mode 100644 src/widgets/DocumentReview/focusTarget.test.ts create mode 100644 src/widgets/DocumentReview/focusTarget.ts create mode 100644 src/widgets/DocumentReview/issuePlaces.test.ts create mode 100644 src/widgets/DocumentReview/issuePlaces.ts diff --git a/src/widgets/DocumentReview/ChecksPanel.tsx b/src/widgets/DocumentReview/ChecksPanel.tsx index 082aa38..5fff10c 100644 --- a/src/widgets/DocumentReview/ChecksPanel.tsx +++ b/src/widgets/DocumentReview/ChecksPanel.tsx @@ -6,20 +6,33 @@ import { formatAmount } from '../../utils/money'; export const failureText = (f: FailedCheckOut) => `Колонка ${f.column_index + 1}: ${f.relation} — ожидалось ${formatAmount(f.expected)}, получено ${formatAmount(f.actual)}`; -type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise }; +type Props = { + checks: ChecksOut; + editable: boolean; + dirty: boolean; + onAccept: (comment: string) => Promise; + onShow?: (key: string) => void; +}; // soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления; // подтверждение они не блокируют // длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран -const failureList = (list: FailedCheckOut[]) => ( +const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
{list.map((f) => ( -
{failureText(f)}
+
+ {failureText(f)} + {onShow && f.line_ids.length > 0 && ( + + )} +
))}
); -export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { +export function ChecksPanel({ checks, editable, dirty, onAccept, onShow }: Props) { const [open, setOpen] = useState(false); const [comment, setComment] = useState(''); const [saving, setSaving] = useState(false); @@ -44,14 +57,14 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { )} {checks.failed.length > 0 && ( - + )} {checks.soft_failed.length > 0 && ( )} {checks.cross_form && ( diff --git a/src/widgets/DocumentReview/IssuesPanel.tsx b/src/widgets/DocumentReview/IssuesPanel.tsx new file mode 100644 index 0000000..e95152d --- /dev/null +++ b/src/widgets/DocumentReview/IssuesPanel.tsx @@ -0,0 +1,43 @@ +import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { Alert, Button, Tag } from 'antd'; +import type { Severity } from '../../types/api'; +import { severityLabel } from '../../utils/labels'; +import type { IssueListItem } from './issuePlaces'; + +/** Значок уровня (P6-15): error — красный, info — синий. */ +export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) { + return severity === 'error' ? ( + + ) : ( + + ); +} + +type Props = { items: IssueListItem[]; onShow?: (key: string) => void }; + +/** Список предупреждений документа; пустой — не показывается. Длинный список прокручивается, как список нарушений. */ +export function IssuesPanel({ items, onShow }: Props) { + if (items.length === 0) return null; + return ( + i.severity === 'error') ? 'error' : 'info'} + showIcon + title={`Предупреждения (${items.length})`} + description={ +
+ {items.map((item) => ( +
+ {severityLabel(item.severity)} + {item.message} ({item.place}) + {item.targetKey !== null && onShow && ( + + )} +
+ ))} +
+ } + /> + ); +} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 5bcc742..608abc6 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -1,17 +1,20 @@ import { CheckOutlined, EditOutlined } from '@ant-design/icons'; import { Alert, Button, Space, Splitter, Switch, message } from 'antd'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents'; import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api'; import { parseAmountInput } from '../../utils/money'; import { ChecksPanel, failureText } from './ChecksPanel'; +import { IssuesPanel } from './IssuesPanel'; import { ReviewCard, confirmButtonStyle } from './ReviewCard'; import { SourcePreview } from './SourcePreview'; import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock'; import { StatementTable } from './StatementTable'; import { confirmErrorInfo } from './confirmError'; +import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; +import { issueListItems, issuePlaces } from './issuePlaces'; import { reviewActions } from './reviewView'; import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid'; @@ -28,6 +31,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [confirmMessage, setConfirmMessage] = useState(null); const [showEmptySublines, setShowEmptySublines] = useState(false); const [showDetails, setShowDetails] = useState(false); + const [highlightKey, setHighlightKey] = useState(null); + const [pendingFocus, setPendingFocus] = useState(null); + const highlightTimer = useRef(undefined); const load = useCallback(async () => { try { @@ -50,6 +56,28 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut () => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set() : emptySublines }), [fullLayout, showDetails, showEmptySublines, emptySublines], ); + 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], + ); + // «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; setState-ы одного обработчика применяются одним рендером, строка к тому времени уже в DOM + const focusRow = (key: string) => { + if (!grid) return; + const need = switchesFor(key, grid.rows, { showDetails, showEmptySublines, emptySublines }); + if (need.details) setShowDetails(true); + if (need.emptySublines) setShowEmptySublines(true); + setPendingFocus(key); + }; + useEffect(() => { + if (pendingFocus === null) return; + window.document.getElementById(`row-${pendingFocus}`)?.scrollIntoView({ block: 'center' }); + setHighlightKey(pendingFocus); + setPendingFocus(null); + window.clearTimeout(highlightTimer.current); + highlightTimer.current = window.setTimeout(() => setHighlightKey(null), 2000); + }, [pendingFocus]); + useEffect(() => () => window.clearTimeout(highlightTimer.current), []); const details = grid ? detailCount(grid.rows) : 0; const actions = reviewActions(statement?.document.status ?? 'ERROR', editing); // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён @@ -138,7 +166,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut if (loadError) { return void load()}>Повторить} />; } - if (!statement || !grid) { + if (!statement || !grid || !places) { return null; } @@ -189,7 +217,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut draft={header} onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} /> - + + {confirmMessage && ( setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 7e8e4d6..45d7758 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -1,10 +1,12 @@ import { Table, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import type { ColumnOut } from '../../types/api'; +import type { ColumnOut, IssueOut } from '../../types/api'; import { CodeCell } from './CodeCell'; +import { SeverityIcon } from './IssuesPanel'; import { ValueCell } from './ValueCell'; import { COLORS, rowBackground, valueBackground } from './cellView'; import type { LayoutRow } from './formLayout'; +import { type IssuePlaces, worstSeverity } from './issuePlaces'; import { columnTitle } from './reviewView'; import { cellKey } from './statementGrid'; @@ -14,6 +16,10 @@ type Props = { editable: boolean; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** предупреждения и нарушения у мест таблицы */ + places: IssuePlaces; + /** строка, которую нужно подсветить рамкой после перехода «Показать» */ + highlightKey?: string | null; }; const CHILD_INDENT = 20; // отступ названия строки детализации под родителем @@ -27,7 +33,23 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== // наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС») const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined); -export function StatementTable({ columns, layout, editable, draft, onDraftChange }: Props) { +function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) { + const worst = worstSeverity(issues); + if (!worst) return <>{columnTitle(col)}; + return ( + ( +
{i.message}
+ ))}> + + + {columnTitle(col)} + +
+ ); +} + +export function StatementTable({ columns, layout, editable, draft, onDraftChange, places, highlightKey = null }: Props) { const span = columns.length + 2; // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); @@ -44,6 +66,8 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange }), render: (_: unknown, r: LayoutRow) => { if (r.kind === 'section') return r.title; + const rowIssues = places.byRow.get(r.key) ?? []; + const rowWorst = worstSeverity(rowIssues); const text = ( {r.displayName || '—'} @@ -54,7 +78,21 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange )} ); - return r.fullName ? {text} : text; + const named = r.fullName ? {text} : text; + if (!rowWorst) return named; + return ( + <> + ( +
{i.message}
+ ))}> + + + +
+ {named} + + ); }, }, { @@ -67,7 +105,7 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange }, ...columns.map((col) => ({ key: col.id, - title: columnTitle(col), + title: , width: 112, align: 'right' as const, onCell: (r: LayoutRow) => @@ -86,7 +124,16 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange const value = r.row.cells[col.id]; if (!value) return null; const draftKey = cellKey(r.row.key, col.id); - return ; + return ( + + ); }, })), ]; @@ -98,7 +145,11 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange columns={tableColumns} dataSource={layout} pagination={false} - onRow={(r) => (r.kind === 'line' ? { id: `row-${r.key}` } : {})} + onRow={(r) => + r.kind === 'line' + ? { id: `row-${r.key}`, style: r.key === highlightKey ? { outline: '2px solid #1677ff', outlineOffset: -2 } : undefined } + : {} + } /> ); } diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index d1ba587..6f3d315 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -1,8 +1,10 @@ import { CheckOutlined } from '@ant-design/icons'; import { Input, Tooltip } from 'antd'; -import type { ValueOut } from '../../types/api'; +import type { IssueOut, ValueOut } from '../../types/api'; import { formatAmount, parseAmountInput } from '../../utils/money'; +import { SeverityIcon } from './IssuesPanel'; import { cellTipLines } from './cellView'; +import { worstSeverity } from './issuePlaces'; type Props = { value: ValueOut; @@ -11,17 +13,25 @@ type Props = { draftKey: string; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** записи issues, привязанные к этой ячейке */ + issues?: IssueOut[]; }; /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ -export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: Props) { - const lines = cellTipLines(value); +export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) { + const lines = cellTipLines(value, issues); + const worst = worstSeverity(issues); + // значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы + const corner = worst && ; const tip = lines.length > 0 ? lines.map((line, i) =>
{line}
) : undefined; if (!editable) { return ( - {value.is_confirmed && } - {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} + + {corner} + {value.is_confirmed && } + {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} + ); } @@ -29,14 +39,17 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange }: P const invalid = draftKey in draft && !parseAmountInput(text).ok; return ( - : undefined} - value={text} - onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} - style={{ textAlign: 'right' }} - /> + + {corner} + : undefined} + value={text} + onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} + style={{ textAlign: 'right' }} + /> + ); } diff --git a/src/widgets/DocumentReview/focusTarget.test.ts b/src/widgets/DocumentReview/focusTarget.test.ts new file mode 100644 index 0000000..707ba34 --- /dev/null +++ b/src/widgets/DocumentReview/focusTarget.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import type { StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { switchesFor } from './focusTarget'; +import { buildStatementGrid } from './statementGrid'; + +const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut); +const off = { showDetails: false, showEmptySublines: false, emptySublines: new Set() }; + +describe('switchesFor', () => { + it('скрытая расшифровка — включить расшифровки', () => { + expect(switchesFor('l-d13', rows, off)).toEqual({ details: true, emptySublines: false }); + expect(switchesFor('l-d13', rows, { ...off, showDetails: true })).toEqual({ details: false, emptySublines: false }); + }); + it('скрытая пустая подстрока — включить пустые строки', () => { + const state = { ...off, emptySublines: new Set(['l-1210']) }; + expect(switchesFor('l-1210', rows, state)).toEqual({ details: false, emptySublines: true }); + expect(switchesFor('l-1210', rows, { ...state, showEmptySublines: true })).toEqual({ details: false, emptySublines: false }); + }); + it('обычная и неизвестная строка — ничего', () => { + expect(switchesFor('l-1110', rows, off)).toEqual({ details: false, emptySublines: false }); + expect(switchesFor('нет-такой', rows, off)).toEqual({ details: false, emptySublines: false }); + }); +}); diff --git a/src/widgets/DocumentReview/focusTarget.ts b/src/widgets/DocumentReview/focusTarget.ts new file mode 100644 index 0000000..e09ff50 --- /dev/null +++ b/src/widgets/DocumentReview/focusTarget.ts @@ -0,0 +1,21 @@ +// Что нужно включить, чтобы строку с ключом key можно было показать на экране (переход «Показать»). +import type { GridRow } from './statementGrid'; + +export interface FocusSwitches { + details: boolean; + emptySublines: boolean; +} + +/** Какие переключатели нужно включить для строки key; уже включённые и неизвестный ключ — false. */ +export function switchesFor( + key: string, + rows: GridRow[], + state: { showDetails: boolean; showEmptySublines: boolean; emptySublines: ReadonlySet }, +): FocusSwitches { + const row = rows.find((r) => r.key === key); + if (!row) return { details: false, emptySublines: false }; + return { + details: row.lineStatus === 'detail' && !state.showDetails, + emptySublines: state.emptySublines.has(key) && !state.showEmptySublines, + }; +} diff --git a/src/widgets/DocumentReview/issuePlaces.test.ts b/src/widgets/DocumentReview/issuePlaces.test.ts new file mode 100644 index 0000000..111cd9f --- /dev/null +++ b/src/widgets/DocumentReview/issuePlaces.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import type { IssueOut, StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces'; +import { buildStatementGrid, cellKey } from './statementGrid'; + +const statement = statementV2Json as unknown as StatementOut; +const { rows, columns } = buildStatementGrid(statement); +const ids = (list: IssueOut[] | undefined) => (list ?? []).map((i) => i.id); +const issue = (patch: Partial): IssueOut => ({ + id: 'x', + kind: 'warning', + code: 'X', + severity: 'info', + message: 'm', + line_ids: [], + column_id: null, + column_index: null, + field: null, + source: 'service', + relation: null, + codes: [], + expected: null, + actual: null, + ...patch, +}); + +describe('issuePlaces', () => { + it('привязка фикстуры', () => { + const p = issuePlaces(statement.issues, rows, columns); + expect(ids(p.byRow.get('l-1370a'))).toContain('i1'); + expect(ids(p.byRow.get('l-1370b'))).toContain('i1'); + for (const k of ['l-1200', 'l-1210', 'l-1230', 'l-1240']) expect(ids(p.byCell.get(cellKey(k, 'c1')))).toEqual(['i6']); + expect(ids(p.byColumn.get('c2'))).toEqual(['i2']); + expect(ids(p.byField.get('organization_name'))).toEqual(['i3']); + expect(ids(p.byField.get('report_date'))).toEqual(['i5']); + expect(ids(p.document)).toEqual(['i4']); + expect(ids(p.byRow.get('l-1230'))).toEqual(['i7']); + }); + it('неизвестные строки и поля — у документа', () => { + const gone = issue({ id: 'g', line_ids: ['l-gone'] }); + const mixed = issue({ id: 'm', line_ids: ['l-gone', 'l-1210'] }); + const okpo = issue({ id: 'o', field: 'okpo' }); + const p = issuePlaces([gone, mixed, okpo], rows, columns); + expect(ids(p.document)).toEqual(['g', 'o']); + expect(ids(p.byRow.get('l-1210'))).toEqual(['m']); + expect(p.byRow.size).toBe(1); + }); +}); + +describe('issueListItems', () => { + const p = issuePlaces(statement.issues, rows, columns); + const items = issueListItems(statement.issues, p, rows, columns); + it('6 пунктов без relation, error первыми', () => { + expect(items).toHaveLength(6); + expect(items.slice(0, 2).map((i) => i.id)).toEqual(['i2', 'i5']); + expect(items.every((i) => i.id !== 'i6')).toBe(true); + }); + it('места и цели', () => { + const by = (id: string) => items.find((i) => i.id === id); + expect(by('i4')).toMatchObject({ place: 'столбец 6 вне колонок формы', targetKey: null }); + expect(by('i1')).toMatchObject({ place: 'строка 1370', targetKey: 'l-1370a' }); + expect(by('i3')?.place).toBe('поле «Организация»'); + expect(by('i2')?.place).toMatch(/^колонка «/); + }); + it('расшифровка', () => { + const d = issue({ id: 'd', line_ids: ['l-d13'] }); + const list = issueListItems([d], issuePlaces([d], rows, columns), rows, columns); + expect(list[0]).toMatchObject({ place: 'расшифровка «справочно»', targetKey: 'l-d13' }); + }); + it('строка без кода', () => { + const u = issue({ id: 'u', line_ids: ['l-u7'] }); + const m = issue({ id: 'mm', line_ids: ['l-m8'] }); + const list = issueListItems([u, m], issuePlaces([u, m], rows, columns), rows, columns); + expect(list.map((i) => i.place)).toEqual(['строка без кода «ИЧ,250»', 'строка без кода']); + }); +}); + +describe('worstSeverity', () => { + it('уровни', () => { + expect(worstSeverity([issue({ severity: 'info' }), issue({ severity: 'error' })])).toBe('error'); + expect(worstSeverity([])).toBeNull(); + }); +}); diff --git a/src/widgets/DocumentReview/issuePlaces.ts b/src/widgets/DocumentReview/issuePlaces.ts new file mode 100644 index 0000000..3a126f2 --- /dev/null +++ b/src/widgets/DocumentReview/issuePlaces.ts @@ -0,0 +1,119 @@ +// Куда в интерфейсе положить каждую запись issues (P6-5): у строки, ячейки, колонки, поля шапки или у документа. +// Только показ: данные не меняются. +import type { ColumnOut, IssueOut, Severity } from '../../types/api'; +import { columnTitle } from './reviewView'; +import { type GridRow, cellKey } from './statementGrid'; + +export const HEADER_BLOCK_FIELDS = ['organization_name', 'inn', 'report_date', 'units'] as const; + +export interface IssuePlaces { + byRow: Map; + /** ключ — cellKey(rowKey, columnId) */ + byCell: Map; + byColumn: Map; + byField: Map; + document: IssueOut[]; +} + +export interface IssueListItem { + id: string; + severity: Severity; + message: string; + place: string; + targetKey: string | null; +} + +const push = (map: Map, key: string, issue: IssueOut) => { + const list = map.get(key); + if (list) list.push(issue); + else map.set(key, [issue]); +}; + +export function issuePlaces( + issues: IssueOut[], + rows: GridRow[], + columns: ColumnOut[], + fields: readonly string[] = HEADER_BLOCK_FIELDS, +): IssuePlaces { + const places: IssuePlaces = { byRow: new Map(), byCell: new Map(), byColumn: new Map(), byField: new Map(), document: [] }; + const rowByLineId = new Map(); + 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) { + 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 columnId = issue.column_id !== null && columnIds.has(issue.column_id) ? issue.column_id : null; + if (issue.line_ids.length > 0 && known.length > 0) { + for (const r of known) { + if (columnId !== null) push(places.byCell, cellKey(r.key, columnId), issue); + else push(places.byRow, r.key, issue); + } + } else if (issue.line_ids.length === 0 && columnId !== null) { + push(places.byColumn, columnId, issue); + } else { + places.document.push(issue); + } + } + return places; +} + +const FIELD_LABELS: Record = { + organization_name: 'Организация', + inn: 'ИНН', + report_date: 'Дата отчёта', + units: 'Единица измерения', +}; + +const rowTitle = (row: GridRow): string => { + switch (row.lineStatus) { + case 'ok': + return `строка ${row.code ?? ''}`.trim(); + case 'detail': + return `расшифровка «${row.name ?? ''}»`; + default: { + const text = row.codeText?.trim(); + return text ? `строка без кода «${text}»` : 'строка без кода'; + } + } +}; + +const SEVERITY_ORDER: Record = { error: 0, info: 1 }; + +/** Список предупреждений документа: только kind = warning, сначала error, затем info, внутри — как пришло. */ +export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[] { + const rowByLineId = new Map(); + for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r); + const columnById = new Map(columns.map((c) => [c.id, c])); + const inDocument = new Set(places.document); + 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 column = issue.column_id !== null ? columnById.get(issue.column_id) : undefined; + let place: string; + let targetKey: string | null = null; + if (inDocument.has(issue)) { + place = + issue.column_index !== null && issue.column_id === null ? `столбец ${issue.column_index + 1} вне колонок формы` : 'документ'; + } else if (issue.field !== null) { + place = `поле «${FIELD_LABELS[issue.field] ?? issue.field}»`; + } else if (first) { + targetKey = first.key; + place = column ? `${rowTitle(first)}, колонка «${columnTitle(column)}»` : rowTitle(first); + } else { + place = column ? `колонка «${columnTitle(column)}»` : 'документ'; + } + return { id: issue.id, severity: issue.severity, message: issue.message, place, targetKey }; + }); + // сортировка устойчивая: порядок issues внутри уровня сохраняется + return items.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} + +export function worstSeverity(issues: IssueOut[]): Severity | null { + if (issues.length === 0) return null; + return issues.some((i) => i.severity === 'error') ? 'error' : 'info'; +} -- 2.47.2 From 8ab116fdba8f3bb545716f8450a1c0a1cddd3bad Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:28:41 +0400 Subject: [PATCH 07/22] =?UTF-8?q?feat(statement):=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BF=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../DocumentReview/StatementHeaderBlock.tsx | 49 +++++++++---- .../DocumentReview/StatementReview.tsx | 3 +- .../DocumentReview/headerFields.test.ts | 72 ++++++++++++++----- src/widgets/DocumentReview/headerFields.ts | 43 +++++++---- 4 files changed, 123 insertions(+), 44 deletions(-) diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 90c2b2f..25aab59 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -1,6 +1,8 @@ -import { Input, Tooltip } from 'antd'; -import type { DocumentType, StatementHeaderOut } from '../../types/api'; +import { Button, Input, Tooltip } from 'antd'; +import type { DocumentType, IssueOut, StatementHeaderOut } from '../../types/api'; +import { SeverityIcon } from './IssuesPanel'; import { type HeaderFieldKey, headerFields } from './headerFields'; +import { worstSeverity } from './issuePlaces'; import { formTitle, formatReportDate } from './reviewView'; export type HeaderDraft = Partial>; @@ -11,11 +13,20 @@ type Props = { editing: boolean; draft: HeaderDraft; onDraftChange: (field: HeaderFieldKey, text: string) => void; + /** предупреждения по полям шапки (IssuePlaces.byField) */ + byField?: Map; + /** «Подтвердить дату»; черновик подтверждения подключает T6 */ + onConfirmDate?: () => void; }; const ATTENTION_BG = '#fff7e6'; const MODIFIED_BG = '#e6f4ff'; -const PLACEHOLDER: Record = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' }; +const PLACEHOLDER: Record = { + organization_name: 'Наименование организации', + inn: 'ИНН', + report_date: 'ГГГГ-ММ-ДД', + units: 'тыс. руб.', +}; const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' }; function fieldBackground(modified: boolean, attention: boolean): string | undefined { @@ -28,29 +39,43 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. */ -export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) { +export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, onConfirmDate }: Props) { const reportDate = formatReportDate(header.report_date.value); return (
- {headerFields(header).map((r) => ( + {headerFields(header, byField).map((r) => (
{r.label} + {r.issues.length > 0 && ( + i.message).join('; ')}> + + + + + )} {editing ? ( - onDraftChange(r.field, e.target.value)} - /> + <> + onDraftChange(r.field, e.target.value)} + /> + {r.canConfirm && onConfirmDate && ( + + )} + ) : ( {r.text} )} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 608abc6..466965f 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -99,7 +99,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut body.values = draftToPatch(values, grid?.rows ?? []); } const headerPatch: HeaderDraft = {}; - for (const field of ['inn', 'report_date', 'units'] as const) { + for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) { const text = header[field]?.trim(); if (text) { headerPatch[field] = text; @@ -215,6 +215,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut documentType={statement.document.document_type} editing={editing} draft={header} + byField={places.byField} onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} /> diff --git a/src/widgets/DocumentReview/headerFields.test.ts b/src/widgets/DocumentReview/headerFields.test.ts index 0da2da1..ee5cb15 100644 --- a/src/widgets/DocumentReview/headerFields.test.ts +++ b/src/widgets/DocumentReview/headerFields.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import type { HeaderFieldOut, IssueOut, StatementHeaderOut, StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; import { headerFields } from './headerFields'; const f = (value: string | null, extra: Partial = {}): HeaderFieldOut => ({ @@ -15,7 +16,11 @@ const header = (over: Partial = {}) => inn: f('3528104476'), report_date: f('2024-12-31'), units: f('тыс. руб.'), - organization_name: 'мусор', + organization_name: 'ООО Ромашка', + organization_name_modified: false, + okei: null, + needs_review_fields: [], + source: {}, okud: null, okpo: null, okopf: null, @@ -24,24 +29,53 @@ const header = (over: Partial = {}) => ...over, }) as StatementHeaderOut; -describe('headerFields — шапка как в старом АРМ: только надёжно извлекаемые поля', () => { - it('три поля по порядку, дата — dd.mm.yyyy', () => { - expect(headerFields(header()).map((r) => [r.field, r.label, r.text])).toEqual([ - ['inn', 'ИНН', '3528104476'], - ['report_date', 'Дата отчёта', '31.12.2024'], - ['units', 'Единица измерения', 'тыс. руб.'], +const fixtureHeader = (statementV2Json as unknown as StatementOut).header; +const fixtureIssue = (statementV2Json as unknown as StatementOut).issues.find((i) => i.id === 'i3') as IssueOut; + +describe('headerFields — шапка: организация, поля на проверку, подсказки источника', () => { + it('четыре поля по порядку', () => { + const rows = headerFields(fixtureHeader); + expect(rows.map((r) => [r.field, r.label])).toEqual([ + ['organization_name', 'Организация'], + ['inn', 'ИНН'], + ['report_date', 'Дата отчёта'], + ['units', 'Единица измерения'], ]); + expect(rows[2].text).toBe('30.09.2025'); }); - it('низкая уверенность — внимание; правка — исходное значение в подсказке; пусто — прочерк', () => { - const rows = headerFields( - header({ - inn: f('312120035', { confidence: '61.54' }), - units: f('руб.', { modified: true, original_value: 'тыс. руб.' }), - report_date: f(null), - }), - ); - expect(rows[0]).toMatchObject({ attention: true, modified: false, tip: 'Уверенность: 61.54%' }); - expect(rows[1]).toMatchObject({ text: '—', attention: true }); - expect(rows[2]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); + it('внимание только по needs_review_fields', () => { + const rows = headerFields(fixtureHeader); + expect(rows[2]).toMatchObject({ attention: true, canConfirm: true }); + expect(rows[0]).toMatchObject({ attention: false, canConfirm: false }); + const low = headerFields(header({ inn: f('312120035', { confidence: '50' }) })); + expect(low[1].attention).toBe(false); + const empty = headerFields(header({ inn: f(null) })); + expect(empty[1]).toMatchObject({ attention: false, text: '—' }); + }); + it('подтвердить можно только дату с известным значением', () => { + const rows = headerFields(header({ needs_review_fields: ['report_date'], report_date: f(null) })); + expect(rows[2].canConfirm).toBe(false); + }); + it('подсказка источника', () => { + expect(headerFields(fixtureHeader)[0].tip).toBe('Распознано: «ООО «Ромашкa»» · уверенность 67%'); + const h = header({ source: { organization_name: { text: 'ООО Р', confidence: '67.00' } } }); + expect(headerFields(h)[0].tip).toBe('Распознано: «ООО Р» · уверенность 67%'); + expect(headerFields(header())[0].tip).toBeUndefined(); + }); + it('исправленное поле — исходное в подсказке', () => { + const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) })); + expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' }); + }); + it('исправленная организация', () => { + const h = header({ + organization_name_modified: true, + source: { organization_name: { text: 'ООО Р', confidence: '67.00' } }, + }); + expect(headerFields(h)[0]).toMatchObject({ modified: true, tip: 'Распознано: «ООО Р»' }); + }); + it('предупреждения поля', () => { + const rows = headerFields(fixtureHeader, new Map([['organization_name', [fixtureIssue]]])); + expect(rows[0].issues).toEqual([fixtureIssue]); + expect(rows[1].issues).toEqual([]); }); }); diff --git a/src/widgets/DocumentReview/headerFields.ts b/src/widgets/DocumentReview/headerFields.ts index 1e96d9a..cd66223 100644 --- a/src/widgets/DocumentReview/headerFields.ts +++ b/src/widgets/DocumentReview/headerFields.ts @@ -1,7 +1,7 @@ -import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api'; +import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api'; import { formatReportDate } from './reviewView'; -export type HeaderFieldKey = 'inn' | 'report_date' | 'units'; +export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units'; export type HeaderFieldRow = { field: HeaderFieldKey; @@ -11,27 +11,46 @@ export type HeaderFieldRow = { attention: boolean; modified: boolean; tip: string | undefined; + issues: IssueOut[]; + canConfirm: boolean; }; const FIELDS: [HeaderFieldKey, string][] = [ + ['organization_name', 'Организация'], ['inn', 'ИНН'], ['report_date', 'Дата отчёта'], ['units', 'Единица измерения'], ]; /** - * Шапка формы — только поля, которые распознавание извлекает надёжно (как в старом АРМ, PDFResultViewer): - * ИНН, дата отчёта, единица измерения. Пустое или с уверенностью < 100 — «требует внимания», правка — исходное в подсказке. + * Шапка формы: организация, ИНН, дата отчёта, единица измерения. «Требует внимания» — только поля из + * `needs_review_fields` (решает ядро), подсказка — что именно распознано в файле (`source`) или исходное до правки. */ -export function headerFields(header: StatementHeaderOut): HeaderFieldRow[] { +export function headerFields(header: StatementHeaderOut, byField?: Map): HeaderFieldRow[] { return FIELDS.map(([field, label]) => { - const h: HeaderFieldOut = header[field]; - const text = (field === 'report_date' ? formatReportDate(h.value) : h.value) ?? '—'; - const lowConfidence = h.confidence !== null && h.confidence !== undefined && Number(h.confidence) < 100; - const modified = h.modified === true; + const isOrg = field === 'organization_name'; + const h: HeaderFieldOut | null = isOrg ? null : header[field]; + 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; let tip: string | undefined; - if (modified) tip = `Исходное: ${h.original_value ?? '—'}`; - else if (lowConfidence) tip = `Уверенность: ${h.confidence}%`; - return { field, label, text, raw: h.value, attention: !modified && (h.value === null || lowConfidence), modified, tip }; + if (modified) tip = isOrg ? recognized : `Исходное: ${h?.original_value ?? '—'}`; + else if (recognized) { + tip = src?.confidence != null ? `${recognized} · уверенность ${Number(src.confidence)}%` : recognized; + } + return { + field, + label, + text, + raw, + attention, + modified, + tip, + issues: byField?.get(field) ?? [], + canConfirm: field === 'report_date' && attention && raw !== null, + }; }); } -- 2.47.2 From ef4d32592e2df0d7583f7b10cf6bf0ffbadec573 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:31:51 +0400 Subject: [PATCH 08/22] =?UTF-8?q?refactor(statement):=20=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA=20=D0=B8=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B0=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20PATCH=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/utils/dates.test.ts | 14 +- src/utils/dates.ts | 9 ++ .../DocumentReview/StatementHeaderBlock.tsx | 10 +- .../DocumentReview/StatementReview.tsx | 73 +++++----- src/widgets/DocumentReview/StatementTable.tsx | 5 +- src/widgets/DocumentReview/ValueCell.tsx | 6 +- .../DocumentReview/statementDraft.test.ts | 134 ++++++++++++++++++ src/widgets/DocumentReview/statementDraft.ts | 133 +++++++++++++++++ .../DocumentReview/statementGrid.test.ts | 18 +-- src/widgets/DocumentReview/statementGrid.ts | 16 +-- 10 files changed, 339 insertions(+), 79 deletions(-) create mode 100644 src/widgets/DocumentReview/statementDraft.test.ts create mode 100644 src/widgets/DocumentReview/statementDraft.ts diff --git a/src/utils/dates.test.ts b/src/utils/dates.test.ts index e77b4d6..ab6ad08 100644 --- a/src/utils/dates.test.ts +++ b/src/utils/dates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatMoscowDate } from './dates'; +import { formatMoscowDate, isIsoDate } from './dates'; describe('formatMoscowDate', () => { it('момент времени из API (UTC) — дата по Москве', () => { @@ -11,3 +11,15 @@ describe('formatMoscowDate', () => { expect(formatMoscowDate('не дата')).toBeNull(); }); }); + +describe('isIsoDate', () => { + it('строго ГГГГ-ММ-ДД и существующая дата', () => { + expect(isIsoDate('2024-12-31')).toBe(true); + expect(isIsoDate('2024-02-29')).toBe(true); + }); + it('иной формат и несуществующая дата — нет', () => { + for (const text of ['2024-2-1', '2024-02-30', '2023-02-29', '31.12.2024', '', ' 2024-12-31', '2024-13-01']) { + expect(isIsoDate(text), text).toBe(false); + } + }); +}); diff --git a/src/utils/dates.ts b/src/utils/dates.ts index eb48df4..21c345d 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -6,3 +6,12 @@ export function formatMoscowDate(iso: string | null | undefined): string | null const date = new Date(iso); return Number.isNaN(date.getTime()) ? null : MOSCOW.format(date); } + +/** Строго «ГГГГ-ММ-ДД» и существующая календарная дата (30 февраля — нет). */ +export function isIsoDate(text: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); + if (!match) return false; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 25aab59..c4411a1 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -4,8 +4,7 @@ import { SeverityIcon } from './IssuesPanel'; import { type HeaderFieldKey, headerFields } from './headerFields'; import { worstSeverity } from './issuePlaces'; import { formTitle, formatReportDate } from './reviewView'; - -export type HeaderDraft = Partial>; +import type { HeaderDraft } from './statementDraft'; type Props = { header: StatementHeaderOut; @@ -15,7 +14,9 @@ type Props = { onDraftChange: (field: HeaderFieldKey, text: string) => void; /** предупреждения по полям шапки (IssuePlaces.byField) */ byField?: Map; - /** «Подтвердить дату»; черновик подтверждения подключает T6 */ + /** ключи полей шапки с ошибкой черновика (красная рамка) */ + errorKeys?: Set; + /** «Подтвердить дату»: кладёт в черновик confirm_report_date */ onConfirmDate?: () => void; }; @@ -39,7 +40,7 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi * ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41] * (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем. */ -export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, onConfirmDate }: Props) { +export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) { const reportDate = formatReportDate(header.report_date.value); return (
@@ -66,6 +67,7 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD <> onDraftChange(r.field, e.target.value)} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 466965f..e07df9c 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -3,28 +3,30 @@ import { Alert, Button, Space, Splitter, Switch, message } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents'; -import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api'; +import type { DocumentOut, FailedCheckOut, StatementOut } from '../../types/api'; import { parseAmountInput } from '../../utils/money'; import { ChecksPanel, failureText } from './ChecksPanel'; import { IssuesPanel } from './IssuesPanel'; import { ReviewCard, confirmButtonStyle } from './ReviewCard'; import { SourcePreview } from './SourcePreview'; -import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock'; +import { StatementHeaderBlock } from './StatementHeaderBlock'; import { StatementTable } from './StatementTable'; import { confirmErrorInfo } from './confirmError'; import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; import { issueListItems, issuePlaces } from './issuePlaces'; import { reviewActions } from './reviewView'; -import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid'; +import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; +import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid'; const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback); export function StatementReview({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { const [statement, setStatement] = useState(null); const [loadError, setLoadError] = useState(null); - const [draft, setDraft] = useState>({}); - const [header, setHeader] = useState({}); + const [draft, setDraftState] = useState(emptyDraft); + // ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик + const [draftErrors, setDraftErrors] = useState([]); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [confirmFailures, setConfirmFailures] = useState([]); @@ -35,6 +37,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const [pendingFocus, setPendingFocus] = useState(null); const highlightTimer = useRef(undefined); + const setDraft = (update: (prev: StatementDraft) => StatementDraft) => { + setDraftState(update); + setDraftErrors([]); + }; + const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]); + const load = useCallback(async () => { try { setStatement(await getStatement(document.id)); @@ -50,7 +58,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) - const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set()), [grid, draft]); + const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts) : new Set()), [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() : emptySublines }), @@ -82,42 +90,25 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut const actions = reviewActions(statement?.document.status ?? 'ERROR', editing); // ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR'; - const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0; + const dirty = !isDraftEmpty(draft); const save = async () => { - const values: Record = {}; - for (const [key, text] of Object.entries(draft)) { - const parsed = parseAmountInput(text); - if (!parsed.ok) { - message.error('Исправьте суммы с неверным форматом'); - return; - } - values[key] = parsed.value; + if (!statement || !grid) return; + const built = buildStatementPatch(draft, { rows: grid.rows, header: statement.header }); + if (!built.ok) { + setDraftErrors(built.errors); + message.error(built.errors[0].message); + return; } - const body: StatementPatch = {}; - if (Object.keys(values).length > 0) { - body.values = draftToPatch(values, grid?.rows ?? []); - } - const headerPatch: HeaderDraft = {}; - for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) { - const text = header[field]?.trim(); - if (text) { - headerPatch[field] = text; - } - } - if (Object.keys(headerPatch).length > 0) { - body.header = headerPatch; - } - if (!body.values && !body.header) { + if (built.body === null) { cancel(); return; } setSaving(true); try { // ответ сервера целиком заменяет состояние: он пересчитал проверки и мог сбросить акцепт - setStatement(await patchStatement(document.id, body)); - setDraft({}); - setHeader({}); + setStatement(await patchStatement(document.id, built.body)); + setDraft(() => emptyDraft()); setEditing(false); setConfirmFailures([]); setConfirmMessage(null); @@ -131,8 +122,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut }; const cancel = () => { - setDraft({}); - setHeader({}); + setDraft(() => emptyDraft()); setEditing(false); }; @@ -214,9 +204,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut header={statement.header} documentType={statement.document.document_type} editing={editing} - draft={header} + draft={draft.header} byField={places.byField} - onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))} + errorKeys={errorKeys} + onDraftChange={(field, text) => setDraft((prev) => ({ ...prev, header: { ...prev.header, [field]: text } }))} + onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))} /> @@ -254,8 +246,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut places={places} highlightKey={highlightKey} editable={editing && actions.canSave} - draft={draft} - onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))} + draft={draft.amounts} + errorKeys={errorKeys} + onDraftChange={(key, text, original) => + setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) })) + } /> diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 45d7758..feb04f1 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -16,6 +16,8 @@ type Props = { editable: boolean; draft: Record; onDraftChange: (key: string, text: string, originalAmount: string | null) => void; + /** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */ + errorKeys?: Set; /** предупреждения и нарушения у мест таблицы */ places: IssuePlaces; /** строка, которую нужно подсветить рамкой после перехода «Показать» */ @@ -49,7 +51,7 @@ function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) { ); } -export function StatementTable({ columns, layout, editable, draft, onDraftChange, places, highlightKey = null }: Props) { +export function StatementTable({ columns, layout, editable, draft, onDraftChange, errorKeys, places, highlightKey = null }: Props) { const span = columns.length + 2; // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {}); @@ -131,6 +133,7 @@ export function StatementTable({ columns, layout, editable, draft, onDraftChange draftKey={draftKey} draft={draft} onDraftChange={onDraftChange} + hasError={errorKeys?.has(draftKey)} issues={places.byCell.get(draftKey)} /> ); diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index 6f3d315..e1e8ce4 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -15,10 +15,12 @@ type Props = { onDraftChange: (key: string, text: string, originalAmount: string | null) => void; /** записи issues, привязанные к этой ячейке */ issues?: IssueOut[]; + /** ошибка черновика, найденная при сохранении */ + hasError?: boolean; }; /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ -export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [] }: Props) { +export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [], hasError = false }: Props) { const lines = cellTipLines(value, issues); const worst = worstSeverity(issues); // значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы @@ -36,7 +38,7 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss ); } const text = draft[draftKey] ?? value.amount ?? ''; - const invalid = draftKey in draft && !parseAmountInput(text).ok; + const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok); return ( diff --git a/src/widgets/DocumentReview/statementDraft.test.ts b/src/widgets/DocumentReview/statementDraft.test.ts new file mode 100644 index 0000000..93af6fc --- /dev/null +++ b/src/widgets/DocumentReview/statementDraft.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import type { StatementOut } from '../../types/api'; +import statementV2Json from './__fixtures__/statement-v2.json'; +import { type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; +import { buildStatementGrid, cellKey } from './statementGrid'; + +const statement = statementV2Json as StatementOut; +const { rows } = buildStatementGrid(statement); +const ctx = { rows, header: statement.header }; +const draftOf = (part: Partial): StatementDraft => ({ ...emptyDraft(), ...part }); +const build = (part: Partial) => buildStatementPatch(draftOf(part), ctx); +const bodyOf = (part: Partial) => { + const result = build(part); + if (!result.ok) throw new Error(`ожидали тело, получили ошибки: ${JSON.stringify(result.errors)}`); + return result.body; +}; +const errorsOf = (part: Partial) => { + const result = build(part); + if (result.ok) throw new Error('ожидали ошибки'); + return result.errors; +}; + +describe('суммы как раньше', () => { + it('строка из БД — line_id, шаблонная — line_code', () => { + expect(bodyOf({ amounts: { [cellKey('l-1210', 'c1')]: '120' } })?.values).toEqual([ + { line_id: 'l-1210', column_id: 'c1', amount: '120' }, + ]); + expect(bodyOf({ amounts: { [cellKey('code-1250', 'c1')]: '5' } })?.values).toEqual([ + { line_code: '1250', column_id: 'c1', amount: '5' }, + ]); + }); + it('пустой текст — очистка суммы (null)', () => { + expect(bodyOf({ amounts: { [cellKey('l-1210', 'c1')]: '' } })?.values).toEqual([{ line_id: 'l-1210', column_id: 'c1', amount: null }]); + }); + it('неверный формат — ошибка amount с ключом ячейки', () => { + const key = cellKey('l-1210', 'c1'); + expect(errorsOf({ amounts: { [key]: '12,3,4' } })).toEqual([expect.objectContaining({ kind: 'amount', key })]); + }); + it('неизвестная строка — ошибка, а не тихий пропуск', () => { + expect(errorsOf({ amounts: { [cellKey('ghost', 'c1')]: '1' } })[0].kind).toBe('amount'); + }); +}); + +describe('подтверждение ячейки', () => { + it('confirms → элемент confirmed', () => { + expect(bodyOf({ confirms: { [cellKey('l-u7', 'c1')]: true } })?.values).toEqual([ + { line_id: 'l-u7', column_id: 'c1', confirmed: true }, + ]); + }); + it('сумма той же ячейки отменяет подтверждение (в элементе ровно одно из amount / confirmed)', () => { + const key = cellKey('l-u7', 'c1'); + expect(bodyOf({ amounts: { [key]: '7' }, confirms: { [key]: true } })?.values).toEqual([ + { line_id: 'l-u7', column_id: 'c1', amount: '7' }, + ]); + }); +}); + +describe('действия строки', () => { + it('код, расшифровка, подтверждение кода', () => { + expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]); + expect(bodyOf({ lines: { 'l-m8': { kind: 'detail' } } })?.lines).toEqual([{ line_id: 'l-m8', line_status: 'detail' }]); + expect(bodyOf({ lines: { 'l-1230': { kind: 'confirm', confirmed: true } } })?.lines).toEqual([{ line_id: 'l-1230', confirmed: true }]); + }); + it('пустой код и код, равный текущему, — не действие', () => { + expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '' } } })).toBeNull(); + expect(bodyOf({ lines: { 'l-1230': { kind: 'code', code: '1230' } } })).toBeNull(); + }); + it('код — 4 или 5 цифр', () => { + for (const code of ['12a4', '123']) { + expect(errorsOf({ lines: { 'l-u7': { kind: 'code', code } } })).toEqual([ + { kind: 'code', key: 'l-u7', message: 'Код — 4 или 5 цифр' }, + ]); + } + expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '11501' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '11501' }]); + }); + it('действие строки и сумма той же строки — оба в теле', () => { + const body = bodyOf({ lines: { 'l-m8': { kind: 'detail' } }, amounts: { [cellKey('l-m8', 'c1')]: '8' } }); + expect(body?.lines).toEqual([{ line_id: 'l-m8', line_status: 'detail' }]); + expect(body?.values).toEqual([{ line_id: 'l-m8', column_id: 'c1', amount: '8' }]); + }); + it('сумма в шаблонную строку 1250 и назначение 1250 другой строке — конфликт', () => { + expect(errorsOf({ amounts: { [cellKey('code-1250', 'c1')]: '5' }, lines: { 'l-u7': { kind: 'code', code: '1250' } } })).toEqual([ + { + kind: 'conflict', + key: 'l-u7', + message: 'Код 1250 назначается строке, а в шаблонную строку 1250 введены суммы — сохраните по очереди', + }, + ]); + }); +}); + +describe('колонки', () => { + it('дата колонки ГГГГ-ММ-ДД', () => { + expect(bodyOf({ columns: { c2: '2024-12-31' } })?.columns).toEqual([{ column_id: 'c2', period_date: '2024-12-31' }]); + }); + it('другой формат и несуществующая дата — ошибка date; пусто — не отправляется', () => { + for (const text of ['31.12.2024', '2024-02-30']) { + expect(errorsOf({ columns: { c2: text } })).toEqual([{ kind: 'date', key: 'c2', message: 'Дата в формате ГГГГ-ММ-ДД' }]); + } + expect(bodyOf({ columns: { c2: '' } })).toBeNull(); + }); +}); + +describe('шапка', () => { + it('organization_name обрезается', () => { + expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })?.header).toEqual({ organization_name: 'ООО Ромашка' }); + }); + it('длиннее 1000 символов — ошибка header', () => { + expect(errorsOf({ header: { organization_name: 'я'.repeat(1001) } })[0]).toMatchObject({ kind: 'header', key: 'organization_name' }); + expect(bodyOf({ header: { organization_name: 'я'.repeat(1000) } })).not.toBeNull(); + }); + it('подтверждение даты отчёта отправляет текущее значение', () => { + expect(bodyOf({ header: { confirm_report_date: true } })?.header).toEqual({ report_date: '2025-09-30' }); + expect(bodyOf({ header: { confirm_report_date: true, report_date: '2025-06-30' } })?.header).toEqual({ report_date: '2025-06-30' }); + }); + it('пустое поле не отправляется', () => { + expect(bodyOf({ header: { inn: '' } })).toBeNull(); + expect(bodyOf({ header: { inn: ' 7700000001 ', units: '', report_date: '2025-09-30' } })?.header).toEqual({ + inn: '7700000001', + report_date: '2025-09-30', + }); + }); +}); + +describe('пустой черновик', () => { + it('isDraftEmpty и body null', () => { + expect(isDraftEmpty(emptyDraft())).toBe(true); + expect(buildStatementPatch(emptyDraft(), ctx)).toEqual({ ok: true, body: null }); + }); + it('любая часть делает черновик непустым', () => { + expect(isDraftEmpty(draftOf({ header: { inn: '1' } }))).toBe(false); + expect(isDraftEmpty(draftOf({ lines: { 'l-m8': { kind: 'detail' } } }))).toBe(false); + }); +}); diff --git a/src/widgets/DocumentReview/statementDraft.ts b/src/widgets/DocumentReview/statementDraft.ts new file mode 100644 index 0000000..e8edac9 --- /dev/null +++ b/src/widgets/DocumentReview/statementDraft.ts @@ -0,0 +1,133 @@ +import type { CellPatch, ColumnPatch, LinePatch, StatementHeaderOut, StatementPatch } from '../../types/api'; +import { isIsoDate } from '../../utils/dates'; +import { parseAmountInput } from '../../utils/money'; +import type { HeaderFieldKey } from './headerFields'; +import type { GridRow } from './statementGrid'; + +/** Действие над строкой (одно на строку, новое заменяет прежнее — P6-7). */ +export type LineAction = { kind: 'code'; code: string } | { kind: 'detail' } | { kind: 'confirm'; confirmed: boolean }; +export type HeaderDraft = Partial> & { confirm_report_date?: boolean }; + +/** Все несохранённые правки аналитика; уходят одним PATCH. */ +export interface StatementDraft { + /** cellKey → введённый текст суммы */ + amounts: Record; + /** cellKey → подтверждена ли ячейка */ + confirms: Record; + /** lineId → действие над строкой */ + lines: Record; + /** columnId → введённая дата колонки */ + columns: Record; + header: HeaderDraft; +} + +export const emptyDraft = (): StatementDraft => ({ amounts: {}, confirms: {}, lines: {}, columns: {}, header: {} }); + +export function isDraftEmpty(draft: StatementDraft): boolean { + return [draft.amounts, draft.confirms, draft.lines, draft.columns, draft.header].every((part) => Object.keys(part).length === 0); +} + +/** `key` — по виду: amount — cellKey, code/conflict — lineId, date — columnId, header — имя поля шапки. */ +export type DraftError = { kind: 'amount' | 'code' | 'date' | 'header' | 'conflict'; key: string; message: string }; + +const CODE_RE = /^\d{4,5}$/; +const ORG_NAME_MAX = 1000; + +const splitKey = (key: string) => { + const sep = key.lastIndexOf(':'); + return { rowKey: key.slice(0, sep), columnId: key.slice(sep + 1) }; +}; + +/** + * Черновик → тело единственного PATCH. Строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). + * Пустое и «без изменений» не отправляется; нет ни одной части — `body: null`. + */ +export function buildStatementPatch( + draft: StatementDraft, + ctx: { rows: GridRow[]; header: StatementHeaderOut }, +): { ok: true; body: StatementPatch | null } | { ok: false; errors: DraftError[] } { + const errors: DraftError[] = []; + const byKey = new Map(ctx.rows.map((row) => [row.key, row])); + + // values: суммы, затем подтверждения ячеек без суммы в черновике (P6-6) + const values: CellPatch[] = []; + const templateAmountCodes = new Set(); + for (const [key, text] of Object.entries(draft.amounts)) { + const { rowKey, columnId } = splitKey(key); + const row = byKey.get(rowKey); + const parsed = parseAmountInput(text); + if (!row) { + errors.push({ kind: 'amount', key, message: 'Строка не найдена в форме' }); + } else if (!parsed.ok) { + errors.push({ kind: 'amount', key, message: 'Исправьте суммы с неверным форматом' }); + } else if (row.lineId) { + values.push({ line_id: row.lineId, column_id: columnId, amount: parsed.value }); + } else { + values.push({ line_code: row.code ?? '', column_id: columnId, amount: parsed.value }); + if (row.code) templateAmountCodes.add(row.code); + } + } + for (const [key, confirmed] of Object.entries(draft.confirms)) { + 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 }); + } + + // lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8) + const lines: LinePatch[] = []; + for (const [lineId, action] of Object.entries(draft.lines)) { + const row = byKey.get(lineId); + if (!row?.lineId) continue; + if (action.kind === 'detail') { + lines.push({ line_id: lineId, line_status: 'detail' }); + } else if (action.kind === 'confirm') { + lines.push({ line_id: lineId, confirmed: action.confirmed }); + } else { + const code = action.code.trim(); + if (code === '' || code === row.code) continue; + if (!CODE_RE.test(code)) { + errors.push({ kind: 'code', key: lineId, message: 'Код — 4 или 5 цифр' }); + } else if (templateAmountCodes.has(code)) { + errors.push({ + kind: 'conflict', + key: lineId, + message: `Код ${code} назначается строке, а в шаблонную строку ${code} введены суммы — сохраните по очереди`, + }); + } else { + lines.push({ line_id: lineId, line_code: code }); + } + } + } + + const columns: ColumnPatch[] = []; + for (const [columnId, raw] of Object.entries(draft.columns)) { + const text = raw.trim(); + if (text === '') continue; + if (isIsoDate(text)) { + columns.push({ column_id: columnId, period_date: text }); + } else { + errors.push({ kind: 'date', key: columnId, message: 'Дата в формате ГГГГ-ММ-ДД' }); + } + } + + const header: NonNullable = {}; + for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) { + const text = draft.header[field]?.trim(); + if (text) header[field] = text; + } + if (draft.header.confirm_report_date && !header.report_date && ctx.header.report_date.value) { + header.report_date = ctx.header.report_date.value; + } + if ((header.organization_name?.length ?? 0) > ORG_NAME_MAX) { + errors.push({ kind: 'header', key: 'organization_name', message: `Наименование организации — не длиннее ${ORG_NAME_MAX} символов` }); + } + + if (errors.length > 0) return { ok: false, errors }; + const body: StatementPatch = {}; + if (values.length > 0) body.values = values; + if (lines.length > 0) body.lines = lines; + if (columns.length > 0) body.columns = columns; + if (Object.keys(header).length > 0) body.header = header; + return { ok: true, body: Object.keys(body).length > 0 ? body : null }; +} diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index 943b52e..b08cf95 100644 --- a/src/widgets/DocumentReview/statementGrid.test.ts +++ b/src/widgets/DocumentReview/statementGrid.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { StatementOut, ValueOut } from '../../types/api'; import statementV2Json from './__fixtures__/statement-v2.json'; import type { GridRow } from './statementGrid'; -import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid'; +import { applyDraftInput, buildStatementGrid, cellFlags, emptySublineKeys } from './statementGrid'; const v = (id: string | null, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ id, @@ -109,22 +109,6 @@ describe('cellFlags — внимание только по needs_review', () => }); }); -describe('draftToPatch', () => { - const rows = buildStatementGrid({ - ...statement, - lines: [...statement.lines, { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [] }], - } as unknown as StatementOut).rows; - it('строка из БД — line_id, достроенная — line_code', () => { - expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('code-1700', 'c1')]: null }, rows)).toEqual([ - { line_id: 'l1', column_id: 'c1', amount: '10.5' }, - { line_code: '1700', column_id: 'c1', amount: null }, - ]); - }); - it('неизвестная строка — ошибка, а не тихий пропуск', () => { - expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow(); - }); -}); - describe('applyDraftInput', () => { const parse = (s: string): { ok: true; value: string | null } | { ok: false } => { const n = s.replace(/\s/g, '').replace(',', '.'); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 0638112..a66d523 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -1,4 +1,4 @@ -import type { CellPatch, ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api'; +import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api'; export type GridRow = { key: string; @@ -83,20 +83,6 @@ export function emptySublineKeys(rows: GridRow[], draft: Record) ); } -/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */ -export function draftToPatch(draft: Record, rows: GridRow[]): CellPatch[] { - const byKey = new Map(rows.map((row) => [row.key, row])); - return Object.entries(draft).map(([key, amount]) => { - const sep = key.lastIndexOf(':'); - const row = byKey.get(key.slice(0, sep)); - const column_id = key.slice(sep + 1); - if (!row) { - throw new Error(`строка черновика ${key} не найдена в форме`); - } - return row.lineId ? { line_id: row.lineId, column_id, amount } : { line_code: row.code ?? '', column_id, amount }; - }); -} - /** Каноничный вид суммы для сравнения строк: без хвостовых нулей дроби и лишних ведущих нулей. */ function normalizeAmount(value: string | null): string | null { if (value === null) { -- 2.47.2 From d416422e73d9d63096e89ae9f267e805187f8bb1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:36:07 +0400 Subject: [PATCH 09/22] =?UTF-8?q?feat(statement):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=20=D1=81=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=BE=D0=B9=20=D0=B8=D0=B7=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?,=20LINE=5FCODE=5FTAKEN=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/CodeCell.tsx | 61 +++++++++++++-- src/widgets/DocumentReview/LineMenuButton.tsx | 24 ++++++ .../DocumentReview/StatementReview.tsx | 27 ++++++- src/widgets/DocumentReview/StatementTable.tsx | 59 ++++++++++++-- .../DocumentReview/codeSuggestions.test.ts | 70 +++++++++++++++++ src/widgets/DocumentReview/codeSuggestions.ts | 46 +++++++++++ src/widgets/DocumentReview/lineMenu.test.ts | 78 +++++++++++++++++++ src/widgets/DocumentReview/lineMenu.ts | 58 ++++++++++++++ .../DocumentReview/statementGrid.test.ts | 4 + src/widgets/DocumentReview/statementGrid.ts | 19 ++++- 10 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 src/widgets/DocumentReview/LineMenuButton.tsx create mode 100644 src/widgets/DocumentReview/codeSuggestions.test.ts create mode 100644 src/widgets/DocumentReview/codeSuggestions.ts create mode 100644 src/widgets/DocumentReview/lineMenu.test.ts create mode 100644 src/widgets/DocumentReview/lineMenu.ts diff --git a/src/widgets/DocumentReview/CodeCell.tsx b/src/widgets/DocumentReview/CodeCell.tsx index 251faa7..fa8bd0a 100644 --- a/src/widgets/DocumentReview/CodeCell.tsx +++ b/src/widgets/DocumentReview/CodeCell.tsx @@ -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 ( + + + ({ value: o.code, label: `${o.code} — ${o.name}` }))} + onChange={editing.onCodeChange} + /> + {tag && ( + + {tag} + + )} + + + ); + } const content = view.muted ? ( {view.text} ) : ( {view.text} ); - if (!view.tip) return content; + const withTag = tag ? ( + <> + {content} + + {tag} + + + ) : ( + content + ); + if (!view.tip) return withTag; return ( {view.tip}}> {/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */} - {content} + {withTag} ); } diff --git a/src/widgets/DocumentReview/LineMenuButton.tsx b/src/widgets/DocumentReview/LineMenuButton.tsx new file mode 100644 index 0000000..eed3da4 --- /dev/null +++ b/src/widgets/DocumentReview/LineMenuButton.tsx @@ -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 ( + onSelect(key as LineMenuKey) }}> + + )} +
+ ); +} diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index c4411a1..950fb88 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -73,8 +73,8 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD onChange={(e) => onDraftChange(r.field, e.target.value)} /> {r.canConfirm && onConfirmDate && ( - )} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 5fd177b..cb21607 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -273,6 +273,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut } 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) })) } diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index 629c9cf..febd274 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -1,17 +1,18 @@ 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 type { LineMenuKey } from './lineMenu'; -import { columnTitle } from './reviewView'; -import type { LineAction } from './statementDraft'; +import type { LineAction, StatementDraft } from './statementDraft'; import { type GridRow, cellKey } from './statementGrid'; type Props = { @@ -32,6 +33,11 @@ type Props = { lineErrors?: Record; onLineCodeChange?: (row: GridRow, code: string) => void; onLineMenu?: (row: GridRow, key: LineMenuKey) => void; + /** весь черновик: подтверждения ячеек и даты колонок (суммы — в `draft` выше) */ + fullDraft?: StatementDraft; + onConfirmToggle?: (key: string, state: NonNullable) => void; + onColumnDateChange?: (columnId: string, text: string) => void; + onColumnDateConfirm?: (col: ColumnOut) => void; }; const CHILD_INDENT = 20; // отступ названия строки детализации под родителем @@ -45,22 +51,6 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !== // наименование и код: красноватый (нет кода) / оранжевый (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 ( - ( -
{i.message}
- ))}> - - - {columnTitle(col)} - -
- ); -} - export function StatementTable({ columns, layout, @@ -75,6 +65,10 @@ export function StatementTable({ lineErrors = {}, onLineCodeChange, onLineMenu, + fullDraft, + onConfirmToggle, + onColumnDateChange, + onColumnDateConfirm, }: Props) { const span = columns.length + 2; // строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты @@ -156,7 +150,17 @@ export function StatementTable({ }, ...columns.map((col) => ({ key: col.id, - title: , + title: ( + + ), width: 112, align: 'right' as const, onCell: (r: LayoutRow) => @@ -184,6 +188,8 @@ export function StatementTable({ onDraftChange={onDraftChange} hasError={errorKeys?.has(draftKey)} issues={places.byCell.get(draftKey)} + confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null} + onConfirmToggle={onConfirmToggle} /> ); }, diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index e1e8ce4..c86a057 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -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,30 @@ type Props = { issues?: IssueOut[]; /** ошибка черновика, найденная при сохранении */ hasError?: boolean; + /** какая кнопка подтверждения нужна ячейке (editRules.cellConfirmState) и обработчик её нажатия */ + confirmState?: CellConfirmState; + onConfirmToggle?: (key: string, state: NonNullable) => void; }; +const CONFIRM_TITLE = { + confirm: 'Подтвердить значение', + unconfirm: 'Снять подтверждение', + 'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать', + '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 +60,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 ( - + {corner} : undefined} value={text} onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} - style={{ textAlign: 'right' }} + // значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст + style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }} /> + {confirmState && ( + + - + 0 ? ( + focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} + ) : undefined + }> + + ); @@ -229,18 +265,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut /> - {confirmMessage && ( - 0 - ? confirmFailures.map((f) =>
{failureText(f)}
) - : undefined - } - /> - )} + {confirmMessage && } {emptySublines.size > 0 && ( diff --git a/src/widgets/DocumentReview/confirmBlockers.test.ts b/src/widgets/DocumentReview/confirmBlockers.test.ts new file mode 100644 index 0000000..6e7692a --- /dev/null +++ b/src/widgets/DocumentReview/confirmBlockers.test.ts @@ -0,0 +1,34 @@ +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 => ({ ...rows[0], cells: {}, ...patch }); + +describe('unresolvedLines', () => { + it('фикстура: строки без кода со значениями', () => { + expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']); + }); + 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'); + }); +}); diff --git a/src/widgets/DocumentReview/confirmBlockers.ts b/src/widgets/DocumentReview/confirmBlockers.ts new file mode 100644 index 0000000..f27e4e2 --- /dev/null +++ b/src/widgets/DocumentReview/confirmBlockers.ts @@ -0,0 +1,12 @@ +import type { GridRow } from './statementGrid'; + +/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */ +export function unresolvedLines(rows: GridRow[]): GridRow[] { + return rows.filter( + (row) => + (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') && + Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)), + ); +} + +export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`; diff --git a/src/widgets/DocumentReview/confirmError.test.ts b/src/widgets/DocumentReview/confirmError.test.ts index 2d8957e..11828b4 100644 --- a/src/widgets/DocumentReview/confirmError.test.ts +++ b/src/widgets/DocumentReview/confirmError.test.ts @@ -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'); }); }); diff --git a/src/widgets/DocumentReview/confirmError.ts b/src/widgets/DocumentReview/confirmError.ts index 138c9fb..8caac83 100644 --- a/src/widgets/DocumentReview/confirmError.ts +++ b/src/widgets/DocumentReview/confirmError.ts @@ -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 }; } -- 2.47.2 From fdf9358b40a8c1bcc383d2455e924c2c76b78d43 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:45:27 +0400 Subject: [PATCH 12/22] =?UTF-8?q?feat(files):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D1=81=D1=82=D1=80=D1=83=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=B8=20=D1=83=20=D0=9E=D0=A1?= =?UTF-8?q?=D0=92=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/IssuesPanel.tsx | 15 ++- src/widgets/DocumentReview/OsvDocument.tsx | 7 ++ .../DocumentReview/StatementReview.tsx | 2 +- .../FilesDrawer/FileWarningsPopover.tsx | 106 ++++++++++++++++++ src/widgets/FilesDrawer/FilesList.tsx | 15 ++- src/widgets/FilesDrawer/documentRows.test.ts | 60 +++++++++- src/widgets/FilesDrawer/documentRows.ts | 25 ++++- src/widgets/FilesDrawer/fileWarnings.test.ts | 51 +++++++++ src/widgets/FilesDrawer/fileWarnings.ts | 34 ++++++ 9 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 src/widgets/FilesDrawer/FileWarningsPopover.tsx create mode 100644 src/widgets/FilesDrawer/fileWarnings.test.ts create mode 100644 src/widgets/FilesDrawer/fileWarnings.ts diff --git a/src/widgets/DocumentReview/IssuesPanel.tsx b/src/widgets/DocumentReview/IssuesPanel.tsx index e95152d..9576f10 100644 --- a/src/widgets/DocumentReview/IssuesPanel.tsx +++ b/src/widgets/DocumentReview/IssuesPanel.tsx @@ -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 ( (
{severityLabel(item.severity)} - {item.message} ({item.place}) - {item.targetKey !== null && onShow && ( + {item.message} + {!plain && ` (${item.place})`} + {!plain && item.targetKey !== null && onShow && ( diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index ca6b1d7..573023c 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -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 /> Строк: {rows.length}
+ {problem && ( setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))} /> - + {confirmMessage && } {emptySublines.size > 0 && ( diff --git a/src/widgets/FilesDrawer/FileWarningsPopover.tsx b/src/widgets/FilesDrawer/FileWarningsPopover.tsx new file mode 100644 index 0000000..5328f4b --- /dev/null +++ b/src/widgets/FilesDrawer/FileWarningsPopover.tsx @@ -0,0 +1,106 @@ +import { Popover, Spin, Tag } from 'antd'; +import { 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 ; + if (state.status === 'error') return Не удалось загрузить; + if (state.issues.length === 0) return Нет; + return ( + <> + {state.issues.map((i) => ( +
+ {severityLabel(i.severity)} + {i.message} +
+ ))} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */ +export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) { + const [issues, setIssues] = useState(null); + + if (!badge) return null; + + // предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново + const onOpenChange = (open: boolean) => { + if (!open) { + setIssues(null); + return; + } + if (!row.documentId) return; + setIssues({ status: 'loading' }); + getDocument(row.documentId) + .then((d) => setIssues({ status: 'done', issues: d.issues })) + .catch(() => setIssues({ status: 'error' })); + }; + + const content = ( +
+ {row.fileWarnings.length > 0 && ( +
+ {row.fileWarnings.map((w, i) => ( +
+ {severityLabel(w.severity)} + {warningText(w)} +
+ ))} +
+ )} + {row.mappingWarnings.length > 0 && ( +
+ {row.mappingWarnings.map((w) => ( +
+ {severityLabel('info')} + {w} +
+ ))} +
+ )} + {row.documentId && ( +
+ +
+ )} +
+ ); + + return ( + + + + ); +} diff --git a/src/widgets/FilesDrawer/FilesList.tsx b/src/widgets/FilesDrawer/FilesList.tsx index 8e4eaf8..cf8ae52 100644 --- a/src/widgets/FilesDrawer/FilesList.tsx +++ b/src/widgets/FilesDrawer/FilesList.tsx @@ -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 = { 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) => }, + { + title: head('Статус'), + key: 'status', + width: 160, + render: (_, r) => ( +
+ + +
+ ), + }, ]; if (loading && files.length === 0) { diff --git a/src/widgets/FilesDrawer/documentRows.test.ts b/src/widgets/FilesDrawer/documentRows.test.ts index 5983204..59384ef 100644 --- a/src/widgets/FilesDrawer/documentRows.test.ts +++ b/src/widgets/FilesDrawer/documentRows.test.ts @@ -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 => ({ id: 'd1', @@ -116,3 +116,61 @@ 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> = {}): 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 документа переносятся в 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('нули — null', () => { + expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull(); + }); +}); diff --git a/src/widgets/FilesDrawer/documentRows.ts b/src/widgets/FilesDrawer/documentRows.ts index bfeef40..fbe68a6 100644 --- a/src/widgets/FilesDrawer/documentRows.ts +++ b/src/widgets/FilesDrawer/documentRows.ts @@ -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,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s statusLabel: documentStatusLabel(d.status), errorReason: null, linkable: LINKABLE.has(d.document_type), + fileWarnings, + mappingWarnings, + issueCounts: d.issue_counts, }; }); }); diff --git a/src/widgets/FilesDrawer/fileWarnings.test.ts b/src/widgets/FilesDrawer/fileWarnings.test.ts new file mode 100644 index 0000000..d0931ec --- /dev/null +++ b/src/widgets/FilesDrawer/fileWarnings.test.ts @@ -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'); + }); +}); diff --git a/src/widgets/FilesDrawer/fileWarnings.ts b/src/widgets/FilesDrawer/fileWarnings.ts new file mode 100644 index 0000000..aaa6a2e --- /dev/null +++ b/src/widgets/FilesDrawer/fileWarnings.ts @@ -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; + 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); -- 2.47.2 From fc5cc16b3d0665aaa8a2164326428166a6f383ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:47:21 +0400 Subject: [PATCH 13/22] =?UTF-8?q?fix(files):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20popover=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83,=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=20=D0=B8=20aria-label=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../FilesDrawer/FileWarningsPopover.tsx | 33 +++++++++++++++---- src/widgets/FilesDrawer/documentRows.test.ts | 4 +++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/widgets/FilesDrawer/FileWarningsPopover.tsx b/src/widgets/FilesDrawer/FileWarningsPopover.tsx index 5328f4b..8bfd2d7 100644 --- a/src/widgets/FilesDrawer/FileWarningsPopover.tsx +++ b/src/widgets/FilesDrawer/FileWarningsPopover.tsx @@ -1,5 +1,5 @@ import { Popover, Spin, Tag } from 'antd'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { getDocument } from '../../services/api/documents'; import type { IssueOut, Severity } from '../../types/api'; import { severityLabel } from '../../utils/labels'; @@ -27,6 +27,16 @@ function DocumentIssues({ state }: { state: IssuesState | null }) { ); } +/** Ключи для строк, которые могут повторяться: к тексту добавляется порядковый номер повтора (индекс массива в key линтер запрещает). */ +function uniqueKeys(list: string[]): { key: string; text: string }[] { + const seen = new Map(); + 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 (
@@ -39,11 +49,15 @@ function Section({ title, children }: { title: string; children: React.ReactNode /** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) { const [issues, setIssues] = useState(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; @@ -51,12 +65,16 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { if (!row.documentId) return; setIssues({ status: 'loading' }); getDocument(row.documentId) - .then((d) => setIssues({ status: 'done', issues: d.issues })) - .catch(() => setIssues({ status: 'error' })); + .then((d) => mine === requestNo.current && setIssues({ status: 'done', issues: d.issues })) + .catch(() => mine === requestNo.current && setIssues({ status: 'error' })); }; const content = ( -
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}> {row.fileWarnings.length > 0 && (
{row.fileWarnings.map((w, i) => ( @@ -69,10 +87,10 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { )} {row.mappingWarnings.length > 0 && (
- {row.mappingWarnings.map((w) => ( -
+ {uniqueKeys(row.mappingWarnings).map((w) => ( +
{severityLabel('info')} - {w} + {w.text}
))}
@@ -89,6 +107,7 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { - 0 ? ( - focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} - ) : undefined - }> - - + + {unresolved.length > 0 && ( + focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} + )} ); diff --git a/src/widgets/DocumentReview/StatementTable.tsx b/src/widgets/DocumentReview/StatementTable.tsx index febd274..76fac75 100644 --- a/src/widgets/DocumentReview/StatementTable.tsx +++ b/src/widgets/DocumentReview/StatementTable.tsx @@ -47,7 +47,7 @@ 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); diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index c86a057..c5d920c 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -29,6 +29,13 @@ const CONFIRM_TITLE = { 'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать', 'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать', } as const; +// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке +const CONFIRM_LABEL = { + confirm: 'Подтвердить значение', + 'pending-confirm': 'Подтвердить значение', + unconfirm: 'Снять подтверждение', + 'pending-unconfirm': 'Снять подтверждение', +} as const; /** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */ export function ValueCell({ @@ -80,7 +87,7 @@ export function ValueCell({ size='small' type={pending ? 'primary' : 'default'} aria-pressed={pending} - aria-label={CONFIRM_TITLE[confirmState]} + aria-label={CONFIRM_LABEL[confirmState]} icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? : } onClick={() => onConfirmToggle?.(draftKey, confirmState)} /> diff --git a/src/widgets/DocumentReview/codeSuggestions.ts b/src/widgets/DocumentReview/codeSuggestions.ts index 76391dc..e2a8d03 100644 --- a/src/widgets/DocumentReview/codeSuggestions.ts +++ b/src/widgets/DocumentReview/codeSuggestions.ts @@ -1,5 +1,5 @@ import { ApiError } from '../../api/client'; -import type { EditionLineOut } from '../../types/api'; +import type { EditionLineOut, LineErrorDetail } from '../../types/api'; import type { GridRow } from './statementGrid'; export type CodeOption = { code: string; name: string }; @@ -40,7 +40,7 @@ const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVAL /** 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?: { line_id?: unknown; line_code?: unknown } } | undefined)?.detail; + const detail = (error.body as { detail?: Partial> } | 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 }; } diff --git a/src/widgets/DocumentReview/confirmBlockers.test.ts b/src/widgets/DocumentReview/confirmBlockers.test.ts index 6e7692a..b893052 100644 --- a/src/widgets/DocumentReview/confirmBlockers.test.ts +++ b/src/widgets/DocumentReview/confirmBlockers.test.ts @@ -12,6 +12,12 @@ 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([]); }); diff --git a/src/widgets/DocumentReview/confirmBlockers.ts b/src/widgets/DocumentReview/confirmBlockers.ts index f27e4e2..c650b63 100644 --- a/src/widgets/DocumentReview/confirmBlockers.ts +++ b/src/widgets/DocumentReview/confirmBlockers.ts @@ -1,12 +1,17 @@ import type { GridRow } from './statementGrid'; -/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — как в таблице. */ +/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */ export function unresolvedLines(rows: GridRow[]): GridRow[] { - return rows.filter( + 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}`; diff --git a/src/widgets/DocumentReview/formLayout.test.ts b/src/widgets/DocumentReview/formLayout.test.ts index f031438..c846bf5 100644 --- a/src/widgets/DocumentReview/formLayout.test.ts +++ b/src/widgets/DocumentReview/formLayout.test.ts @@ -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']); -- 2.47.2 From 12fcea7560592855d4b4fe8efd05584fddc16255 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:03:33 +0400 Subject: [PATCH 18/22] =?UTF-8?q?fix(statement):=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B4=D0=BB=D1=8F=20=D1=8F?= =?UTF-8?q?=D0=B4=D1=80=D0=B0=20=D0=B1=D0=B5=D0=B7=20v5.19=20(M-2,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/ChecksPanel.tsx | 2 +- src/widgets/DocumentReview/OsvDocument.tsx | 2 +- src/widgets/DocumentReview/issuePlaces.ts | 9 +++++---- src/widgets/FilesDrawer/documentRows.test.ts | 7 +++++++ src/widgets/FilesDrawer/documentRows.ts | 3 ++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/widgets/DocumentReview/ChecksPanel.tsx b/src/widgets/DocumentReview/ChecksPanel.tsx index 5fff10c..4955519 100644 --- a/src/widgets/DocumentReview/ChecksPanel.tsx +++ b/src/widgets/DocumentReview/ChecksPanel.tsx @@ -22,7 +22,7 @@ const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => {list.map((f) => (
{failureText(f)} - {onShow && f.line_ids.length > 0 && ( + {onShow && (f.line_ids ?? []).length > 0 && ( diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index 573023c..e957432 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -119,7 +119,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on 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 ?? []).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'; diff --git a/src/widgets/DocumentReview/issuePlaces.ts b/src/widgets/DocumentReview/issuePlaces.ts index 3a126f2..550fbd2 100644 --- a/src/widgets/DocumentReview/issuePlaces.ts +++ b/src/widgets/DocumentReview/issuePlaces.ts @@ -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; diff --git a/src/widgets/FilesDrawer/documentRows.test.ts b/src/widgets/FilesDrawer/documentRows.test.ts index 7037627..88683a7 100644 --- a/src/widgets/FilesDrawer/documentRows.test.ts +++ b/src/widgets/FilesDrawer/documentRows.test.ts @@ -151,6 +151,13 @@ describe('buildDocumentRows: предупреждения', () => { expect(legacy[0].fileWarnings[0]).toMatchObject({ code: 'LEGACY_WARNING', message: 'старое' }); }); + it('ядро без issue_counts (до v5.19) — нули, не падает', () => { + const old = doc() as Partial; + 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 }); diff --git a/src/widgets/FilesDrawer/documentRows.ts b/src/widgets/FilesDrawer/documentRows.ts index fbe68a6..d5873ee 100644 --- a/src/widgets/FilesDrawer/documentRows.ts +++ b/src/widgets/FilesDrawer/documentRows.ts @@ -99,7 +99,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s linkable: LINKABLE.has(d.document_type), fileWarnings, mappingWarnings, - issueCounts: d.issue_counts, + // ядро без v5.19 не присылает issue_counts — считаем, что замечаний нет + issueCounts: d.issue_counts ?? { error: 0, info: 0 }, }; }); }); -- 2.47.2 From a3199d636ed15b1de8a4f8a65d41b1a5e1d79ea9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:06:02 +0400 Subject: [PATCH 19/22] =?UTF-8?q?fix(arm-web):=20=D0=B2=D1=91=D1=80=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=E2=80=94=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA,=20=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=BA=D0=B8=20=D0=B2=20=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D0=B9=D0=BA=D0=B5,=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0,=20=D1=81?= =?UTF-8?q?=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T11.1–T11.4, T11.6–T11.8: короткая подпись кнопки даты, фиксированная раскладка таблиц, код без переноса, значок уровня внутри ячейки, подсказка на всю ячейку, отступ child-строки, ссылка «Разберите строки без кода» — Button type=link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/ColumnHeader.tsx | 15 ++++++++--- .../DocumentReview/StatementHeaderBlock.tsx | 21 +++++++++++---- .../DocumentReview/StatementReview.tsx | 13 ++++++--- src/widgets/DocumentReview/StatementTable.tsx | 18 ++++++++----- src/widgets/DocumentReview/ValueCell.tsx | 27 ++++++++++--------- 5 files changed, 62 insertions(+), 32 deletions(-) diff --git a/src/widgets/DocumentReview/ColumnHeader.tsx b/src/widgets/DocumentReview/ColumnHeader.tsx index 7c44e3f..b585d98 100644 --- a/src/widgets/DocumentReview/ColumnHeader.tsx +++ b/src/widgets/DocumentReview/ColumnHeader.tsx @@ -51,9 +51,18 @@ export function ColumnHeader({ col, issues, editing, draftDate, error = false, o style={{ marginTop: 4, fontWeight: 400 }} /> {canConfirm && ( - + // короткая подпись, чтобы кнопка помещалась в колонку 112 px и ширина не прыгала (T11.1); длинный текст — в подсказке + + + )}
); diff --git a/src/widgets/DocumentReview/StatementHeaderBlock.tsx b/src/widgets/DocumentReview/StatementHeaderBlock.tsx index 56d1094..01f2480 100644 --- a/src/widgets/DocumentReview/StatementHeaderBlock.tsx +++ b/src/widgets/DocumentReview/StatementHeaderBlock.tsx @@ -45,7 +45,12 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD const reportDate = formatReportDate(header.report_date.value); return (
- +
+ {/* фиксированная раскладка: ширина блока не зависит от содержимого и подписи кнопки (T11.6) */} + + + + {headerFields(header, byField).map((r) => ( @@ -54,7 +59,6 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD style={{ ...cell, fontWeight: 600, - minWidth: r.field === 'organization_name' ? 320 : 160, background: fieldBackground(r.modified, r.attention), }}> {r.issues.length > 0 && ( @@ -74,9 +78,16 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD onChange={(e) => onDraftChange(r.field, e.target.value)} /> {r.canConfirm && onConfirmDate && ( - + + + )} ) : ( diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 1ab3ac7..bc61dad 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -208,7 +208,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut confirmDescription = confirmFailures.map((f) =>
{failureText(f)}
); } else if (confirmLines.length > 0) { confirmDescription = ( - focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)} + ); } else if (duplicateId) { confirmDescription = ( @@ -242,9 +244,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut onClick={confirm}> Подтвердить данные - {unresolved.length > 0 && ( - focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)} - )} ); @@ -259,6 +258,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
+ {unresolved.length > 0 && ( + // настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7) + + )} void; }; +const ICON_WIDTH = 18; // значок уровня с отступом: у ребёнка вычитается из отступа, чтобы текст не съезжал const CHILD_INDENT = 20; // отступ названия строки детализации под родителем function lineWeight(r: LayoutRow): number | undefined { @@ -89,7 +90,7 @@ export function StatementTable({ const rowIssues = places.byRow.get(r.key) ?? []; const rowWorst = worstSeverity(rowIssues); const text = ( - + {r.displayName || '—'} {r.secondaryName && ( @@ -111,9 +112,11 @@ export function StatementTable({ ) : ( namedText ); - if (!rowWorst) return named; + // отступ ребёнка — на общей обёртке: значок уровня остаётся внутри отступа и не сдвигает текст (T11.8) + const indent = r.child ? CHILD_INDENT : undefined; + if (!rowWorst) return
{named}
; return ( - <> +
(
{i.message}
@@ -123,16 +126,16 @@ export function StatementTable({
{named} - +
); }, }, { key: 'code', title: 'Код', - width: editable ? 160 : 60, + width: editable ? 160 : 84, align: 'center' as const, - onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }), + onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r), whiteSpace: 'nowrap' } }), render: (_: unknown, r: LayoutRow) => { if (r.kind !== 'line') return null; const lineId = r.row.lineId; @@ -161,7 +164,7 @@ export function StatementTable({ onConfirmDate={onColumnDateConfirm} /> ), - width: 112, + width: 128, align: 'right' as const, onCell: (r: LayoutRow) => r.kind === 'section' @@ -198,6 +201,7 @@ export function StatementTable({ return ( size='small' + tableLayout='fixed' bordered rowKey='key' columns={tableColumns} diff --git a/src/widgets/DocumentReview/ValueCell.tsx b/src/widgets/DocumentReview/ValueCell.tsx index c5d920c..6681191 100644 --- a/src/widgets/DocumentReview/ValueCell.tsx +++ b/src/widgets/DocumentReview/ValueCell.tsx @@ -1,9 +1,9 @@ import { CheckOutlined, UndoOutlined } from '@ant-design/icons'; -import { Button, Input, Tooltip } from 'antd'; +import { Button, Input, Tooltip, Typography } from 'antd'; import type { IssueOut, ValueOut } from '../../types/api'; -import { formatAmount, parseAmountInput } from '../../utils/money'; +import { parseAmountInput } from '../../utils/money'; import { SeverityIcon } from './IssuesPanel'; -import { cellTipLines } from './cellView'; +import { cellTipLines, valueCellText } from './cellView'; import type { CellConfirmState } from './editRules'; import { worstSeverity } from './issuePlaces'; @@ -51,17 +51,19 @@ export function ValueCell({ }: Props) { const lines = cellTipLines(value, issues); const worst = worstSeverity(issues); - // значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы - const corner = worst && ; + const shown = valueCellText(value); + // значок уровня — внутри ячейки, слева от суммы, по центру по вертикали (T11.3) + const corner = worst && ; const tip = lines.length > 0 ? lines.map((line, i) =>
{line}
) : undefined; if (!editable) { return ( + // подсказка на всей ячейке: блок на всю ширину колонки (T11.4) - +
{corner} - {value.is_confirmed && } - {formatAmount(value.amount, { fraction: 'auto', empty: '-' })} - + {value.is_confirmed && } + {shown.muted ? {shown.text} : shown.text} +
); } @@ -70,7 +72,7 @@ export function ValueCell({ const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm'; return ( - +
{corner} : undefined} value={text} onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} - // значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст - style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }} + style={{ textAlign: 'right', minWidth: 0, flex: 1 }} /> {confirmState && ( @@ -93,7 +94,7 @@ export function ValueCell({ /> )} - +
); } -- 2.47.2 From 88a4920d7d9c45e5e2f698afec34c9578f36ef84 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:06:02 +0400 Subject: [PATCH 20/22] =?UTF-8?q?fix(arm-web):=20=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B?= =?UTF-8?q?,=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA,=20=D1=81=D0=B2=D1=91=D1=80=D0=BD=D1=83=D1=82=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T11.5 valueCellText, T11.10 emptySublineKeys пропускает detail, T11.11 collapseIssues (3 записи + «Показать все»). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- src/widgets/DocumentReview/IssuesPanel.tsx | 14 +++++++++-- src/widgets/DocumentReview/cellView.test.ts | 24 ++++++++++++++++++- src/widgets/DocumentReview/cellView.ts | 11 +++++++++ .../DocumentReview/issuePlaces.test.ts | 13 +++++++++- src/widgets/DocumentReview/issuePlaces.ts | 6 +++++ .../DocumentReview/statementGrid.test.ts | 4 ++++ src/widgets/DocumentReview/statementGrid.ts | 1 + 7 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/widgets/DocumentReview/IssuesPanel.tsx b/src/widgets/DocumentReview/IssuesPanel.tsx index 9576f10..e679a66 100644 --- a/src/widgets/DocumentReview/IssuesPanel.tsx +++ b/src/widgets/DocumentReview/IssuesPanel.tsx @@ -1,8 +1,11 @@ import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons'; import { Alert, Button, Tag } from 'antd'; +import { useState } from 'react'; import type { Severity } from '../../types/api'; import { severityLabel } from '../../utils/labels'; -import type { IssueListItem } from './issuePlaces'; +import { type IssueListItem, collapseIssues } from './issuePlaces'; + +const COLLAPSED_LIMIT = 3; // сколько записей видно в свёрнутом списке (T11.11) /** Значок уровня (P6-15): error — красный, info — синий. */ export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) { @@ -21,7 +24,9 @@ type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: */ export function IssuesPanel({ items, mode, onShow }: Props) { const plain = mode === 'document'; + const [expanded, setExpanded] = useState(false); if (items.length === 0) return null; + const { shown, hidden } = collapseIssues(items, expanded, COLLAPSED_LIMIT); return ( i.severity === 'error') ? 'error' : 'info'} @@ -29,7 +34,7 @@ export function IssuesPanel({ items, mode, onShow }: Props) { title={`Предупреждения (${items.length})`} description={
- {items.map((item) => ( + {shown.map((item) => (
{severityLabel(item.severity)} {item.message} @@ -41,6 +46,11 @@ export function IssuesPanel({ items, mode, onShow }: Props) { )}
))} + {(hidden > 0 || expanded) && ( + + )}
} /> diff --git a/src/widgets/DocumentReview/cellView.test.ts b/src/widgets/DocumentReview/cellView.test.ts index 0af48af..c636ee7 100644 --- a/src/widgets/DocumentReview/cellView.test.ts +++ b/src/widgets/DocumentReview/cellView.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ValueOut } from '../../types/api'; -import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground } from './cellView'; +import { formatAmount } from '../../utils/money'; +import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground, valueCellText } from './cellView'; import type { GridRow } from './statementGrid'; const val = (extra: Partial = {}): ValueOut => ({ @@ -138,3 +139,24 @@ describe('фон', () => { expect(rowBackground(row())).toBeUndefined(); }); }); + +describe('valueCellText (T11.5)', () => { + it('сумма есть — обычный текст', () => { + expect(valueCellText(val({ amount: '1500.00' }))).toEqual({ + text: formatAmount('1500.00', { fraction: 'auto', empty: '-' }), + muted: false, + }); + }); + it('суммы нет, есть string_value — он серым', () => { + expect(valueCellText(val({ amount: null, string_value: '211(1 |' }))).toEqual({ text: '211(1 |', muted: true }); + }); + it('ничего нет — прочерк', () => { + expect(valueCellText(val({ amount: null }))).toEqual({ text: '-', muted: false }); + }); +}); + +describe('cellTipLines и string_value (T11.5)', () => { + it('без суммы показывает нераспознанный string_value', () => { + expect(cellTipLines(val({ amount: null, string_value: 'abc' }))).toContain('Не распознано как сумма: «abc»'); + }); +}); diff --git a/src/widgets/DocumentReview/cellView.ts b/src/widgets/DocumentReview/cellView.ts index 747299d..74f6301 100644 --- a/src/widgets/DocumentReview/cellView.ts +++ b/src/widgets/DocumentReview/cellView.ts @@ -34,11 +34,22 @@ export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`); const confidence = formatPercent(value.confidence); if (confidence !== null) lines.push(`Уверенность: ${confidence}`); + if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') { + lines.push(`Не распознано как сумма: «${value.string_value}»`); + } if (value.is_confirmed) lines.push('Подтверждено аналитиком'); for (const issue of issues) lines.push(issue.message); return lines; } +/** Текст ячейки суммы (T11.5): сумма; без неё нераспознанный string_value серым; иначе прочерк. */ +export function valueCellText(value: ValueOut): { text: string; muted: boolean } { + if (value.amount === null && value.string_value !== null && value.string_value.trim() !== '') { + return { text: value.string_value, muted: true }; + } + return { text: formatAmount(value.amount, { fraction: 'auto', empty: '-' }), muted: false }; +} + /** Ячейка кода (P6-16): текст, серый ли он, подсказка. */ export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined } { switch (row.lineStatus) { diff --git a/src/widgets/DocumentReview/issuePlaces.test.ts b/src/widgets/DocumentReview/issuePlaces.test.ts index 111cd9f..fde4e3e 100644 --- a/src/widgets/DocumentReview/issuePlaces.test.ts +++ b/src/widgets/DocumentReview/issuePlaces.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { IssueOut, StatementOut } from '../../types/api'; import statementV2Json from './__fixtures__/statement-v2.json'; -import { issueListItems, issuePlaces, worstSeverity } from './issuePlaces'; +import { collapseIssues, issueListItems, issuePlaces, worstSeverity } from './issuePlaces'; import { buildStatementGrid, cellKey } from './statementGrid'; const statement = statementV2Json as unknown as StatementOut; @@ -82,3 +82,14 @@ describe('worstSeverity', () => { expect(worstSeverity([])).toBeNull(); }); }); + +describe('collapseIssues (T11.11)', () => { + const items = Array.from({ length: 6 }, (_, i) => ({ id: String(i) })) as never[]; + it('свёрнуто: первые 3, скрыто 3', () => { + expect(collapseIssues(items, false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 3 }); + }); + it('развёрнуто или короткий список: всё', () => { + expect(collapseIssues(items, true, 3)).toEqual({ shown: items, hidden: 0 }); + expect(collapseIssues(items.slice(0, 3), false, 3)).toEqual({ shown: items.slice(0, 3), hidden: 0 }); + }); +}); diff --git a/src/widgets/DocumentReview/issuePlaces.ts b/src/widgets/DocumentReview/issuePlaces.ts index 550fbd2..7d82746 100644 --- a/src/widgets/DocumentReview/issuePlaces.ts +++ b/src/widgets/DocumentReview/issuePlaces.ts @@ -118,3 +118,9 @@ export function worstSeverity(issues: IssueOut[]): Severity | null { if (issues.length === 0) return null; return issues.some((i) => i.severity === 'error') ? 'error' : 'info'; } + +/** Свёрнутый список предупреждений (T11.11): первые `limit` (порядок уже «ошибки первыми») и сколько скрыто. */ +export function collapseIssues(items: T[], expanded: boolean, limit: number): { shown: T[]; hidden: number } { + if (expanded || items.length <= limit) return { shown: items, hidden: 0 }; + return { shown: items.slice(0, limit), hidden: items.length - limit }; +} diff --git a/src/widgets/DocumentReview/statementGrid.test.ts b/src/widgets/DocumentReview/statementGrid.test.ts index 849ee06..c44f59c 100644 --- a/src/widgets/DocumentReview/statementGrid.test.ts +++ b/src/widgets/DocumentReview/statementGrid.test.ts @@ -173,6 +173,10 @@ describe('parent_code и пустые строки детализации', () = ]; expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']); }); + it('расшифровка (lineStatus detail) с parent_code не считается пустой подстрокой (T11.10)', () => { + const detail = { ...gridRow('d1', '1110', { c1: cell({}) }), lineStatus: 'detail' as const }; + expect([...emptySublineKeys([detail], {})]).toEqual([]); + }); it('подстрока с правкой в черновике не считается пустой', () => { const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })]; expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 3fd510a..a80c53d 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -88,6 +88,7 @@ export function emptySublineKeys( .filter( (row) => row.parentCode !== null && + row.lineStatus !== 'detail' && !edited.has(row.key) && !(row.lineId !== null && row.lineId in lineActions) && !Object.values(row.cells).some(hasValue), -- 2.47.2 From aa3054e4ed572205a30cab66f2d22b9950092b1a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:06:02 +0400 Subject: [PATCH 21/22] =?UTF-8?q?docs(arm-web):=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20v5.19=20=E2=80=94=20R?= =?UTF-8?q?EADME=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- CLAUDE.md | 8 ++++++++ README.md | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 91fe069..bbe34c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,14 @@ SPA кредитного аналитика АРМ КА: справочники - `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`). - `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и `/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ (стол консолидации / все файлы ЮЛ, подпись консолидации, селектор `store/slices/consolidations/selectors.ts`)), `src/components/` — примитивы (`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API. +- Экран проверки формы (`src/widgets/DocumentReview/`): вся логика — чистые модули с тестами рядом, компоненты `.tsx` без юнит-тестов. + `formLayout` — раскладка строк по разделам и статусам; `issuePlaces` — предупреждения по местам (ячейка / колонка / строка / поле шапки), + список для панели и `collapseIssues`; `statementDraft` — весь черновик правки и сборка тела `PATCH` (суммы, подтверждения, коды, даты, шапка); + `cellView` — цвета, подсказки, текст ячейки суммы и кода; `codeSuggestions` — подсказки кодов по справочнику; `lineMenu` — меню строки + («Это расшифровка», «Назначить код»); `editRules` — что можно править и какая кнопка подтверждения нужна ячейке/колонке; + `confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать». + `src/widgets/FilesDrawer/fileWarnings` и `documentRows` — предупреждения файла и документа в панели файлов; `src/utils/percent` — + формат уверенности. ## Команды - `make install` — `npm install`. diff --git a/README.md b/README.md index e287afe..c4e24fe 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v Если на стенде раньше вручную сопоставляли контрагентов (K14), алиасы сохраняются между версиями, и «несопоставленных» будет меньше (на стенде прогона — 1349); остальные цифры не меняются. +## Экран проверки формы ББ/ОФР (контракт 2.0, ядро v5.19) + +Что нового (#43): строки формы показываются по статусам (`ok` / нераспознанный код / без кода / расшифровка) на своих местах; +предупреждения и нарушения стоят значками у ячеек, колонок, строк и полей шапки, а полный список свёрнут до трёх записей +(«Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату +колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка +«Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа. +Карта модулей — в `CLAUDE.md`, раздел «Архитектура». + ## Ограничения прототипа - Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов, -- 2.47.2 From b4b8c04693847e591633b74851273e86ca88912e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:07:51 +0400 Subject: [PATCH 22/22] =?UTF-8?q?openspec(statements-v2):=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=20(T11.D-1=E2=80=A6D-5,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- openspec/changes/statements-v2/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/statements-v2/design.md b/openspec/changes/statements-v2/design.md index 04c8c8f..749640c 100644 --- a/openspec/changes/statements-v2/design.md +++ b/openspec/changes/statements-v2/design.md @@ -81,5 +81,10 @@ | T10.D-4 | Значок — `