Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44
@ -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`.
|
||||
|
||||
@ -55,6 +55,15 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
|
||||
Если на стенде раньше вручную сопоставляли контрагентов (K14), алиасы сохраняются между версиями, и
|
||||
«несопоставленных» будет меньше (на стенде прогона — 1349); остальные цифры не меняются.
|
||||
|
||||
## Экран проверки формы ББ/ОФР (контракт 2.0, ядро v5.19)
|
||||
|
||||
Что нового (#43): строки формы показываются по статусам (`ok` / нераспознанный код / без кода / расшифровка) на своих местах;
|
||||
предупреждения и нарушения стоят значками у ячеек, колонок, строк и полей шапки, а полный список свёрнут до трёх записей
|
||||
(«Показать все»); в режиме правки можно назначить код строке, пометить её расшифровкой и подтвердить значение, код или дату
|
||||
колонки — всё уходит одним `PATCH`; кнопка «Подтвердить данные» заблокирована, пока есть строки без кода (ссылка
|
||||
«Разберите строки без кода: N» ведёт к первой); панель файлов ЮЛ показывает счётчик предупреждений файла и документа.
|
||||
Карта модулей — в `CLAUDE.md`, раздел «Архитектура».
|
||||
|
||||
## Ограничения прототипа
|
||||
|
||||
- Нет блокировки версии («Взять в работу»), сопоставления контрагентов, экрана чек-листа, комментариев, удаления файлов,
|
||||
|
||||
933
docs/superpowers/plans/2026-10-18-statements-v2.md
Normal file
933
docs/superpowers/plans/2026-10-18-statements-v2.md
Normal file
@ -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 <noreply@anthropic.com>` и `Claude-Session:
|
||||
https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude
|
||||
Code](https://claude.com/claude-code)`.
|
||||
- Находки и решения — с маркерами (`T<n>.<k>`, `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 <repo> 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/<N>/g"`); проверка: `grep -c "feat/<N>-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<Record<string, HeaderSourceOut>>`, `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<string, ValueOut | undefined>;
|
||||
};
|
||||
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<string> }): 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` — `?<codeText ?? '-'>`, `detail` — `> <name>`):
|
||||
- `строка без кода под ближайшей предыдущей по 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-<key>"` (`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` через `<br/>`); `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<string, IssueOut[]>; byCell: Map<string, IssueOut[]>; // ключ ячейки — cellKey(rowKey, columnId)
|
||||
byColumn: Map<string, IssueOut[]>; byField: Map<string, IssueOut[]>; 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` —
|
||||
«строка без кода «<codeText>»» (без текста — «строка без кода»), `detail` — «расшифровка «<name>»»; ячейка — «<строка>,
|
||||
колонка «<columnTitle>»»; колонка — «колонка «<columnTitle>»»; поле — «поле «<подпись поля>»»; документ — «документ»,
|
||||
с `column_index` без `column_id` — «столбец <column_index + 1> вне колонок формы»; `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<string, IssueOut[]>): HeaderFieldRow[];
|
||||
```
|
||||
|
||||
Порядок и подписи: «Организация», «ИНН», «Дата отчёта», «Единица измерения». `attention` — поле в
|
||||
`needs_review_fields` (только); `modified` — `organization_name_modified` или `HeaderFieldOut.modified`; `tip`:
|
||||
исправлено → «Исходное: <original_value>» (у организации — «Распознано: «<source.organization_name.text>»»), иначе при
|
||||
наличии `source[field]` — «Распознано: «<text>» · уверенность 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<Record<HeaderFieldKey, string>> & { confirm_report_date?: boolean };
|
||||
export interface StatementDraft {
|
||||
amounts: Record<string, string>; // cellKey → введённый текст
|
||||
confirms: Record<string, boolean>; // cellKey → confirmed
|
||||
lines: Record<string, LineAction>; // lineId → действие
|
||||
columns: Record<string, string>; // 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<string, LineAction>): Record<string, LineAction>;
|
||||
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<string>; // 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 |
|
||||
2
openspec/changes/statements-v2/.openspec.yaml
Normal file
2
openspec/changes/statements-v2/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-18
|
||||
90
openspec/changes/statements-v2/design.md
Normal file
90
openspec/changes/statements-v2/design.md
Normal file
@ -0,0 +1,90 @@
|
||||
## 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-1 | Шаги «сверка с макетом / прогон в браузере» (T3 s5, T4 s4, T7–T10) перенесены в T11 (дата 2026-10-18) | нет стенда ядра с v5.19, общий Playwright-браузер делят параллельные дорожки | визуальные дефекты найдутся позже, в T11 |
|
||||
| R-2 | Ссылка «Показать» также у проверок со статусом `soft_failed` (дата 2026-10-18) | согласованное расширение: нарушение так же адресуется строкой | только показ |
|
||||
| R-3 | Компонент меню строки назван `LineMenuButton.tsx`, а не `LineMenu.tsx` (дата 2026-10-18) | на регистронезависимой ФС (macOS) `LineMenu.tsx` и `lineMenu.ts` дают TS1149 | нет |
|
||||
| O-1 | Два MODIFIED-требования («Форма ББ/ОФР — просмотр и правка», «Подтверждение документа») составлены исполнителем из текущей спеки: в docs-спеке `arm-web` все требования ADDED. «Предупреждения ОСВ-документа» (P6-19) в docs-спеке нет; написано по плану. Сверить в arm-ka-docs#116 (дата 2026-10-18) | иначе change нельзя было бы описать дельтой | расхождение формулировок со спекой docs при архивации |
|
||||
| T1.D-1 | `cellFlags` возвращает ещё и `confirmed`; двум старым ожиданиям добавлен `confirmed: false`; тест «внимание по уверенности» заменён на «внимание только по needs_review» (дата 2026-10-18) | `toEqual` падает на новом ключе; так велит бриф | только тесты |
|
||||
| T1.D-2 | В тестовый хелпер `doc()` добавлен `issue_counts: {error: 0, info: 0}` (дата 2026-10-18) | поле обязательно в `DocumentBrief`, иначе падает tsc | нет |
|
||||
| T1.D-3 | Значения фикстуры `statement-v2.json`, которых нет в брифе, придуманы исполнителем (форматы сумм, уверенности, `text`, UUID, `codes`, сообщения, `is_current`, пустая колонка c2, порядок `edition_lines`) (дата 2026-10-18) | фикстура должна быть полной | тесты завязаны на выдуманные значения; реальные данные — в T11 |
|
||||
| T1.D-4 | Фикстура приведена к `StatementOut` через `as` в тесте (дата 2026-10-18) | импорт JSON расширяет литералы до `string`; полноту проверяет рантайм-тест пар «строка × колонка» | слабая проверка типов фикстуры |
|
||||
| T1.D-5 | Тест «переносит поля v2» проверяет ключи по `l-u7`, `code-1250`, `l-1230`, `l-d2` (дата 2026-10-18) | ровно как в брифе | нет |
|
||||
| T3.D-1 | Несколько строк подсказки кода склеиваются переводом строки; у `codeModified` уверенность не показывается (дата 2026-10-18) | читаемость подсказки | только вид |
|
||||
| T3.D-2 | Подсказка сумм принимает необязательный `issues` (добавляет message) (дата 2026-10-18) | задел для T4 | нет |
|
||||
| T3.D-3 | Уверенность округляется `Math.round(Number())` (P6-17) (дата 2026-10-18) | одно правило для всех уверенностей | нет |
|
||||
| T3.D-4 | В режиме правки у подтверждённой ячейки галочка — `prefix` поля ввода (дата 2026-10-18) | ячейка в режиме правки — Input | наложение значков, см. T8.D-3 |
|
||||
| T3.D-5 | `id` строки `row-<key>` выставляется только на строках-линиях, не на заголовках разделов (дата 2026-10-18) | якорь прокрутки нужен только строкам | нет |
|
||||
| T4.D-1 | Подписи мест полей шапки: organization_name — «Организация», inn — «ИНН», report_date — «Дата отчёта», units — «Единица измерения»; неизвестное поле — своим кодом, запись уходит в `document` (дата 2026-10-18) | P6-5 | только вид |
|
||||
| T4.D-2 | Запись с `field` из блока шапки всегда в `byField`, независимо от `line_ids` (дата 2026-10-18) | поле шапки — самостоятельное место | нет |
|
||||
| T4.D-3 | `warning` с `line_ids` без `column_id` — место «строка …», с `column_id` — «<строка>, колонка «…»»; `relation` / `soft` в список не входят (дата 2026-10-18) | P6-5 | только вид |
|
||||
| T4.D-5 | `onShow` панелей передаёт ключ строки; для достроенных строк (`id = null`) перехода нет — `line_ids` их не содержат (дата 2026-10-18) | нет id — нет адреса | нет ссылки «Показать» у достроенной строки |
|
||||
| T4.D-6 | Значок у наименования строки — только по `byRow`; записи `byCell` дают значок только в ячейке (дата 2026-10-18) | не дублировать | только вид |
|
||||
| T5.D-1 | Кнопка «Подтвердить дату» рисуется только при переданном `onConfirmDate` (дата 2026-10-18) | иначе мёртвая кнопка | нет |
|
||||
| T5.D-2 | У организации в подсказке при modified — «Распознано: «…»»; нет `source` — подсказки нет (дата 2026-10-18) | нечего показывать | нет |
|
||||
| T5.D-3 | Уверенность в подсказке шапки через `Number()` ('67.00' → 67%) (дата 2026-10-18) | P6-17 | нет |
|
||||
| T5.D-4 | Placeholder организации — «Наименование организации» (текст не из плана) (дата 2026-10-18) | поле в режиме правки должно быть подписано | только вид |
|
||||
| T6.D-1 | `buildStatementPatch` для суммы в неизвестную строку возвращает ошибку `amount` (раньше бросалось исключение) (дата 2026-10-18) | экран не должен падать | нет |
|
||||
| T6.D-2 | Подтверждение ячейки и действие строки у строк без `id` молча пропускаются (дата 2026-10-18) | UI их не создаёт (P6-6, P6-7) | молчаливая потеря действия при ошибке UI |
|
||||
| T6.D-3 | Код сравнивается после `trim`; пустой или равный текущему — не действие и не ошибка, проверка до формата (дата 2026-10-18) | P6-7 | нет |
|
||||
| T6.D-4 | Конфликт P6-8 ищется по сумме в шаблонную строку (`id = null`) с тем же кодом; `key` ошибки — `lineId` строки с действием (дата 2026-10-18) | P6-8 | нет |
|
||||
| T6.D-5 | Тексты ошибок: «Наименование организации — не длиннее 1000 символов» (длина организации); «Исправьте суммы с неверным форматом» (суммы, как раньше) (дата 2026-10-18) | тексты не из плана | только вид |
|
||||
| T6.D-6 | `confirm_report_date` при отсутствии текущей даты ничего не отправляет (дата 2026-10-18) | нечего подтверждать | пустое тело PATCH, «Сохранить» молчит |
|
||||
| T6.D-7 | Ошибка `amount` подсвечивает ячейку красной рамкой и сама по себе (дата 2026-10-18) | раньше подсветка шла только по разбору формата | только вид |
|
||||
| T7.D-2 | Подпись пункта меню: «Это расшифровка» для `unreadable` / `missing`, «Сделать расшифровкой» для `ok` (дата 2026-10-18) | разные смыслы действия | только вид |
|
||||
| T7.D-3 | Меню строки у `ok` — только при `origin = 'source'`; шаблонные строки с id меню не получают (дата 2026-10-18) | бриф | нет |
|
||||
| T7.D-4 | `lineErrorTarget` читает `ApiError.body.detail` (отдельного поля `detail` у `ApiError` нет) (дата 2026-10-18) | иначе деталей ошибки не достать | нет |
|
||||
| T7.D-5 | Ошибка сервера сбрасывается любой правкой черновика (в `setDraft`) (дата 2026-10-18) | иначе устаревшая ошибка висит после правки | нет |
|
||||
| T7.D-6 | У `unreadable` / `missing` с действием detail / confirm поле кода остаётся, рядом тег (дата 2026-10-18) | бриф: поле всегда | см. T7.2: очистка поля теряет действие |
|
||||
| T8.D-1 | Подтверждение ячейки без `id` значения молча пропускается в `buildStatementPatch`; кнопка не рисуется (`cellConfirmState → null`) (дата 2026-10-18) | P6-6 | нет |
|
||||
| T8.D-2 | Ожидающая кнопка даты — disabled с текстом «Дата подтверждена (до сохранения)»; без ожидания «Подтвердить дату»; отменить подтверждение даты можно только отменой всей правки. Для колонки ожидание = черновая дата равна `period_date`. Затем текст подсказки при нажатой кнопке подтверждения ячейки: «Подтверждение будет сохранено — нажмите, чтобы убрать» (дата 2026-10-18) | пользователь должен видеть, что подтверждение в черновике | текст не из плана; ширина колонки (T8.2) проверяется в T11 |
|
||||
| T8.D-3 | При значке записи у Input `paddingLeft: 14`, чтобы значок не перекрывал галочку (дата 2026-10-18) | без браузера не проверено | визуальное наложение; проверка в T11 |
|
||||
| T8.D-4 | `cellConfirmState` возвращает `pending-unconfirm` при `confirms[key] === false` (дата 2026-10-18) | нужно третье состояние | нет |
|
||||
| T8.D-5 | Подтверждение ячейки только строкам с `lineId` (дата 2026-10-18) | P6-6, P6-7 | нет |
|
||||
| T9.D-1 | «Значения есть» = `amount !== null || string_value !== null` по любой ячейке; `detail` не блокирует независимо от значений (дата 2026-10-18) | P6-10 | расхождение с условием ядра |
|
||||
| T9.D-2 | Ключ ссылки в 409 `LINES_UNRESOLVED` — `line_id` (дата 2026-10-18) | ключ GridRow для строк из БД | ссылка молчит, если строки нет в таблице (T9.6) |
|
||||
| T9.D-3 | Описание Alert подтверждения собирается императивно (`confirmDescription`); приоритет: failures > lines > duplicate (дата 2026-10-18) | линтер запрещает вложенные тернарники | только вид |
|
||||
| T10.D-1 | `toFileWarnings` отбрасывает объекты без `code` / `message` или с `severity` не `error` / `info` (дата 2026-10-18) | P6-13 | нет |
|
||||
| T10.D-2 | В режиме `document` у `IssuesPanel` колонка «место» не показывается; `OsvDocument` кладёт place '' / targetKey null (дата 2026-10-18) | у документа нет места | нет |
|
||||
| T10.D-3 | Подробности документа в поповере грузятся при открытии (`trigger=click`), состояние сбрасывается при закрытии (дата 2026-10-18) | P6-12 | лишний запрос при повторном открытии |
|
||||
| T10.D-4 | Значок — `<button>` под плашкой статуса; без `documentId` раздел «Предупреждения документа» не показывается (дата 2026-10-18) | правила a11y | нет |
|
||||
| T10.D-5 | Пустой список в разделе «Предупреждения документа» — «Нет» (дата 2026-10-18) | текст не из плана | только вид |
|
||||
| UI-1 | Тексты, которых нет в плане: «Дата подтверждена (до сохранения)», «Подтверждение будет сохранено — нажмите, чтобы убрать», «Строка не найдена в форме», «Наименование организации — не длиннее 1000 символов» (дата 2026-10-18) | нужны для состояний, плана для них не было | только формулировки |
|
||||
| T11.D-1 | Колонки значений 128px вместо 112 (поле даты из 10 символов и сумма с галочкой обрезались), `tableLayout='fixed'` (дата 2026-10-18) | замечание T11.1: колонка «прыгала» при появлении кнопки даты | только вид |
|
||||
| T11.D-2 | Кнопка подтверждения даты — короткая «Подтвердить» / «Подтверждено», длинный текст «Подтвердить дату» / «Дата подтверждена (до сохранения)» в подсказке и `aria-label` (дата 2026-10-18) | фиксированная ширина колонки; отклонение от формулировки «Подтвердить дату» в P6-11 | только подпись |
|
||||
| T11.D-3 | Ссылка «Разберите строки без кода: N» — `Button type=link` отдельной строкой над шапкой карточки, видна и в режиме правки (дата 2026-10-18) | клавиатурный доступ; строки нужно разбирать именно при правке | только вид |
|
||||
| T11.D-4 | Список предупреждений документа показывает первые 3 записи (ошибки первыми) и «Показать все (N)»; то же на экране ОСВ (дата 2026-10-18) | таблица уезжала ниже сгиба | только вид |
|
||||
| T11.D-5 | Значение со `string_value` без суммы показывается серым текстом, а не «-» (дата 2026-10-18) | аналитик должен видеть нераспознанный текст | только вид |
|
||||
|
||||
Решения контроллера и исполнителей поверх плана (`R-n`, `O-n`) дописываются сюда по ходу исполнения.
|
||||
34
openspec/changes/statements-v2/proposal.md
Normal file
34
openspec/changes/statements-v2/proposal.md
Normal file
@ -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).
|
||||
109
openspec/changes/statements-v2/specs/document-review-ui/spec.md
Normal file
109
openspec/changes/statements-v2/specs/document-review-ui/spec.md
Normal file
@ -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** над таблицей показан блок предупреждений с текстом записи и уровнем
|
||||
@ -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** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки
|
||||
110
src/types/api.ts
110
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<Record<string, HeaderSourceOut>>;
|
||||
}
|
||||
export interface ColumnOut {
|
||||
id: string;
|
||||
column_index: number;
|
||||
label: string | null;
|
||||
label_confidence: string | null;
|
||||
period_date: string | null;
|
||||
period_kind: PeriodKind;
|
||||
is_current: boolean | null;
|
||||
}
|
||||
export interface ValueOut {
|
||||
@ -128,25 +186,54 @@ 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[];
|
||||
}
|
||||
/** Элемент detail 422/409 по строке: LINE_CODE_TAKEN, LINE_CODE_REQUIRED и др. */
|
||||
export interface LineErrorDetail {
|
||||
code: string;
|
||||
message: string;
|
||||
line_id: string;
|
||||
line_code: string | null;
|
||||
}
|
||||
/** Строка без кода в detail.lines ответа 409 LINES_UNRESOLVED. */
|
||||
export interface UnresolvedLineOut {
|
||||
line_id: string;
|
||||
row_index: number | null;
|
||||
line_code_text: string | null;
|
||||
}
|
||||
export interface FailedCheckOut {
|
||||
column_index: number;
|
||||
relation: string;
|
||||
expected: string;
|
||||
actual: string;
|
||||
codes: string[];
|
||||
line_ids: string[];
|
||||
}
|
||||
export interface ChecksOut {
|
||||
failed: FailedCheckOut[];
|
||||
@ -165,17 +252,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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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('Информация');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, MatchKind, Quarter } from '../types/api';
|
||||
import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, LineStatus, MatchKind, Quarter, Severity } from '../types/api';
|
||||
|
||||
const TYPE_LABELS: Record<DocumentType, string> = {
|
||||
BALANCE: 'Бух. баланс',
|
||||
@ -70,3 +70,14 @@ const MATCH_KIND: Record<MatchKind, string> = {
|
||||
|
||||
export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t;
|
||||
export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен');
|
||||
|
||||
const LINE_STATUS: Record<LineStatus, string> = {
|
||||
ok: 'Строка формы',
|
||||
unreadable: 'Код не распознан',
|
||||
missing: 'Кода нет',
|
||||
detail: 'Расшифровка',
|
||||
};
|
||||
export const lineStatusLabel = (s: LineStatus): string => LINE_STATUS[s] ?? s;
|
||||
|
||||
const SEVERITY: Record<Severity, string> = { error: 'Ошибка', info: 'Информация' };
|
||||
export const severityLabel = (s: Severity): string => SEVERITY[s] ?? s;
|
||||
|
||||
15
src/utils/percent.test.ts
Normal file
15
src/utils/percent.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatPercent } from './percent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('округляет до целого', () => {
|
||||
expect(formatPercent('61.54')).toBe('62%');
|
||||
expect(formatPercent('67.00')).toBe('67%');
|
||||
expect(formatPercent(100)).toBe('100%');
|
||||
});
|
||||
it('не число — null', () => {
|
||||
expect(formatPercent('abc')).toBeNull();
|
||||
expect(formatPercent('')).toBeNull();
|
||||
expect(formatPercent(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
6
src/utils/percent.ts
Normal file
6
src/utils/percent.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** Уверенность API (строка Decimal) → «62%» (округление до целого); не число — null, чтобы не показывать «NaN%». */
|
||||
export function formatPercent(confidence: string | number | null | undefined): string | null {
|
||||
if (confidence === null || confidence === undefined || confidence === '') return null;
|
||||
const n = Number(confidence);
|
||||
return Number.isFinite(n) ? `${Math.round(n)}%` : null;
|
||||
}
|
||||
@ -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<void> };
|
||||
type Props = {
|
||||
checks: ChecksOut;
|
||||
editable: boolean;
|
||||
dirty: boolean;
|
||||
onAccept: (comment: string) => Promise<void>;
|
||||
onShow?: (key: string) => void;
|
||||
};
|
||||
|
||||
// soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления;
|
||||
// подтверждение они не блокируют
|
||||
// длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
|
||||
const failureList = (list: FailedCheckOut[]) => (
|
||||
const failureList = (list: FailedCheckOut[], onShow?: (key: string) => void) => (
|
||||
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
|
||||
{list.map((f) => (
|
||||
<div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>
|
||||
<div key={`${f.column_index}-${f.relation}`}>
|
||||
{failureText(f)}
|
||||
{onShow && (f.line_ids ?? []).length > 0 && (
|
||||
<Button type='link' size='small' onClick={() => onShow(f.line_ids[0])}>
|
||||
Показать
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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) {
|
||||
<Alert type='success' showIcon title='Проверки пройдены' />
|
||||
)}
|
||||
{checks.failed.length > 0 && (
|
||||
<Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed)} />
|
||||
<Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed, onShow)} />
|
||||
)}
|
||||
{checks.soft_failed.length > 0 && (
|
||||
<Alert
|
||||
type='warning'
|
||||
showIcon
|
||||
title={`Предупреждения проверок (${checks.soft_failed.length})`}
|
||||
description={failureList(checks.soft_failed)}
|
||||
description={failureList(checks.soft_failed, onShow)}
|
||||
/>
|
||||
)}
|
||||
{checks.cross_form && (
|
||||
|
||||
71
src/widgets/DocumentReview/CodeCell.tsx
Normal file
71
src/widgets/DocumentReview/CodeCell.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
/** Правка кода строки: действие черновика, справочник подсказок и ошибка (формат / ответ сервера). */
|
||||
export type CodeEditing = {
|
||||
action: LineAction | undefined;
|
||||
options: CodeOption[];
|
||||
error?: string;
|
||||
onCodeChange: (code: string) => void;
|
||||
};
|
||||
|
||||
const actionTag = (action: LineAction | undefined) => {
|
||||
if (action?.kind === 'detail') return '→ расшифровка';
|
||||
if (action?.kind === 'confirm') return action.confirmed ? '→ код подтверждён' : '→ подтверждение снято';
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Ячейка кода: просмотр (код, у нераспознанного — серый сырой текст) или, в режиме правки, поле кода с подсказками. */
|
||||
export function CodeCell({ row, fontWeight, editing }: { row: GridRow; fontWeight?: number; editing?: CodeEditing }) {
|
||||
const view = codeCellView(row);
|
||||
const tag = editing ? actionTag(editing.action) : null;
|
||||
if (editing && showsCodeInput(row, editing.action)) {
|
||||
const value = editing.action?.kind === 'code' ? editing.action.code : '';
|
||||
return (
|
||||
<Tooltip title={editing.error}>
|
||||
<span>
|
||||
<AutoComplete
|
||||
size='small'
|
||||
style={{ width: 140 }}
|
||||
status={editing.error ? 'error' : undefined}
|
||||
value={value}
|
||||
placeholder='Код'
|
||||
options={filterCodeOptions(editing.options, value).map((o) => ({ value: o.code, label: `${o.code} — ${o.name}` }))}
|
||||
onChange={editing.onCodeChange}
|
||||
/>
|
||||
{tag && (
|
||||
<Tag style={{ marginTop: 2 }} variant='filled'>
|
||||
{tag}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const content = view.muted ? (
|
||||
<Typography.Text type='secondary'>{view.text}</Typography.Text>
|
||||
) : (
|
||||
<span style={{ fontWeight }}>{view.text}</span>
|
||||
);
|
||||
const withTag = tag ? (
|
||||
<>
|
||||
{content}
|
||||
<Tag style={{ marginLeft: 4 }} variant='filled'>
|
||||
{tag}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
if (!view.tip) return withTag;
|
||||
return (
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{view.tip}</span>}>
|
||||
{/* span с минимальной шириной: у расшифровки текст пуст, а подсказка должна открываться */}
|
||||
<span style={{ display: 'inline-block', minWidth: 16 }}>{withTag}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
69
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
69
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import type { ColumnOut, IssueOut } from '../../types/api';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { columnDateEditable } from './editRules';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
import { columnTitle } from './reviewView';
|
||||
|
||||
type Props = {
|
||||
col: ColumnOut;
|
||||
issues: IssueOut[];
|
||||
editing: boolean;
|
||||
/** введённая, но не сохранённая дата колонки */
|
||||
draftDate?: string;
|
||||
error?: boolean;
|
||||
onDateChange?: (columnId: string, text: string) => void;
|
||||
/** «Подтвердить дату»: кладёт в черновик текущую дату колонки */
|
||||
onConfirmDate?: (col: ColumnOut) => void;
|
||||
};
|
||||
|
||||
/** Заголовок колонки: подпись, значок предупреждения и (в режиме правки) поле даты ГГГГ-ММ-ДД с подтверждением. */
|
||||
export function ColumnHeader({ col, issues, editing, draftDate, error = false, onDateChange, onConfirmDate }: Props) {
|
||||
const worst = worstSeverity(issues);
|
||||
const title = (
|
||||
<span>
|
||||
{worst && <SeverityIcon severity={worst} style={{ marginRight: 4 }} />}
|
||||
{columnTitle(col)}
|
||||
</span>
|
||||
);
|
||||
const withTip = worst ? (
|
||||
<Tooltip
|
||||
title={issues.map((i) => (
|
||||
<div key={i.id}>{i.message}</div>
|
||||
))}>
|
||||
{title}
|
||||
</Tooltip>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
const { editable, canConfirm } = columnDateEditable(col, issues);
|
||||
if (!editing || !editable) return withTip;
|
||||
const pending = canConfirm && draftDate !== undefined && draftDate === col.period_date;
|
||||
return (
|
||||
<div>
|
||||
{withTip}
|
||||
<Input
|
||||
size='small'
|
||||
status={error ? 'error' : undefined}
|
||||
placeholder='ГГГГ-ММ-ДД'
|
||||
value={draftDate ?? col.period_date ?? ''}
|
||||
onChange={(e) => onDateChange?.(col.id, e.target.value)}
|
||||
style={{ marginTop: 4, fontWeight: 400 }}
|
||||
/>
|
||||
{canConfirm && (
|
||||
// короткая подпись, чтобы кнопка помещалась в колонку 112 px и ширина не прыгала (T11.1); длинный текст — в подсказке
|
||||
<Tooltip title={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
|
||||
<Button
|
||||
size='small'
|
||||
block
|
||||
style={{ marginTop: 4, paddingInline: 0, fontSize: 12 }}
|
||||
disabled={pending}
|
||||
aria-label={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
|
||||
onClick={() => onConfirmDate?.(col)}>
|
||||
{pending ? 'Подтверждено' : 'Подтвердить'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/widgets/DocumentReview/IssuesPanel.tsx
Normal file
58
src/widgets/DocumentReview/IssuesPanel.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
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, collapseIssues } from './issuePlaces';
|
||||
|
||||
const COLLAPSED_LIMIT = 3; // сколько записей видно в свёрнутом списке (T11.11)
|
||||
|
||||
/** Значок уровня (P6-15): error — красный, info — синий. */
|
||||
export function SeverityIcon({ severity, style }: { severity: Severity; style?: React.CSSProperties }) {
|
||||
return severity === 'error' ? (
|
||||
<ExclamationCircleOutlined style={{ color: '#cf1322', ...style }} />
|
||||
) : (
|
||||
<InfoCircleOutlined style={{ color: '#1677ff', ...style }} />
|
||||
);
|
||||
}
|
||||
|
||||
type Props = { items: IssueListItem[]; mode: 'statement' | 'document'; onShow?: (key: string) => void };
|
||||
|
||||
/**
|
||||
* Список предупреждений; пустой — не показывается. Длинный список прокручивается, как список нарушений.
|
||||
* `statement` — форма ББ/ОФР: место записи и ссылка «Показать»; `document` (ОСВ, P6-19) — простые пункты без места и ссылок.
|
||||
*/
|
||||
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 (
|
||||
<Alert
|
||||
type={items.some((i) => i.severity === 'error') ? 'error' : 'info'}
|
||||
showIcon
|
||||
title={`Предупреждения (${items.length})`}
|
||||
description={
|
||||
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
|
||||
{shown.map((item) => (
|
||||
<div key={item.id}>
|
||||
<Tag color={item.severity === 'error' ? 'error' : 'processing'}>{severityLabel(item.severity)}</Tag>
|
||||
{item.message}
|
||||
{!plain && ` (${item.place})`}
|
||||
{!plain && item.targetKey !== null && onShow && (
|
||||
<Button type='link' size='small' onClick={() => onShow(item.targetKey as string)}>
|
||||
Показать
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(hidden > 0 || expanded) && (
|
||||
<Button type='link' size='small' style={{ paddingInline: 0 }} onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? 'Свернуть' : `Показать все (${items.length})`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
24
src/widgets/DocumentReview/LineMenuButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { type LineMenuKey, lineMenuItems } from './lineMenu';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Меню строки в режиме правки: кнопка «⋮» рядом с наименованием. Пусто — кнопки нет. */
|
||||
export function LineMenuButton({
|
||||
row,
|
||||
action,
|
||||
onSelect,
|
||||
}: {
|
||||
row: GridRow;
|
||||
action: LineAction | undefined;
|
||||
onSelect: (key: LineMenuKey) => void;
|
||||
}) {
|
||||
const items = lineMenuItems(row, action);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<Dropdown trigger={['click']} menu={{ items, onClick: ({ key }) => onSelect(key as LineMenuKey) }}>
|
||||
<Button type='text' size='small' icon={<MoreOutlined />} aria-label='Действия со строкой' style={{ marginLeft: 4 }} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { ApiError } from '../../api/client';
|
||||
import { confirmDocument, getAllEntries } from '../../services/api/documents';
|
||||
import type { DocumentOut, EntryOut, FailedCheckOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { IssuesPanel } from './IssuesPanel';
|
||||
import { confirmButtonStyle } from './ReviewCard';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
|
||||
@ -116,6 +117,11 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
const rows = useMemo(() => filterRows(table.rows, query, account), [table.rows, query, account]);
|
||||
// колонки до сумм: Субсчёт, [Подразделение], аналитика, [ИНН] — под ними подпись «Итого по документу»
|
||||
const labelSpan = 2 + (table.hasDepartments ? 1 : 0) + (table.hasCounterparties ? 1 : 0);
|
||||
// предупреждения документа (P6-19): все записи issues, без привязки к строкам
|
||||
const issueItems = useMemo(
|
||||
() => (document.issues ?? []).map((i) => ({ id: i.id, severity: i.severity, message: i.message, place: '', targetKey: null })),
|
||||
[document.issues],
|
||||
);
|
||||
const canConfirm = document.status === 'RECOGNIZED' || document.status === 'NEEDS_CHECK';
|
||||
|
||||
const confirm = async () => {
|
||||
@ -165,6 +171,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', color: '#667085' }}>Строк: {rows.length}</span>
|
||||
</div>
|
||||
<IssuesPanel mode='document' items={issueItems} />
|
||||
{problem && (
|
||||
<Alert
|
||||
type='error'
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
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<Record<HeaderFieldKey, string>>;
|
||||
import type { HeaderDraft } from './statementDraft';
|
||||
|
||||
type Props = {
|
||||
header: StatementHeaderOut;
|
||||
@ -11,11 +12,22 @@ type Props = {
|
||||
editing: boolean;
|
||||
draft: HeaderDraft;
|
||||
onDraftChange: (field: HeaderFieldKey, text: string) => void;
|
||||
/** предупреждения по полям шапки (IssuePlaces.byField) */
|
||||
byField?: Map<string, IssueOut[]>;
|
||||
/** ключи полей шапки с ошибкой черновика (красная рамка) */
|
||||
errorKeys?: Set<string>;
|
||||
/** «Подтвердить дату»: кладёт в черновик confirm_report_date */
|
||||
onConfirmDate?: () => void;
|
||||
};
|
||||
|
||||
const ATTENTION_BG = '#fff7e6';
|
||||
const MODIFIED_BG = '#e6f4ff';
|
||||
const PLACEHOLDER: Record<HeaderFieldKey, string> = { inn: 'ИНН', report_date: 'ГГГГ-ММ-ДД', units: 'тыс. руб.' };
|
||||
const PLACEHOLDER: Record<HeaderFieldKey, string> = {
|
||||
organization_name: 'Наименование организации',
|
||||
inn: 'ИНН',
|
||||
report_date: 'ГГГГ-ММ-ДД',
|
||||
units: 'тыс. руб.',
|
||||
};
|
||||
const cell = { border: '1px solid #d0d5dd', padding: '4px 10px' };
|
||||
|
||||
function fieldBackground(modified: boolean, attention: boolean): string | undefined {
|
||||
@ -24,33 +36,60 @@ function fieldBackground(modified: boolean, attention: boolean): string | undefi
|
||||
}
|
||||
|
||||
/**
|
||||
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица ИНН / Дата отчёта / Единица измерения —
|
||||
* ровно то, что распознавание извлекает надёжно; дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* Шапка формы как в старом АРМ (PDFResultViewer): справа компактная таблица из четырёх полей — организация, ИНН,
|
||||
* дата отчёта, единица измерения (в режиме правки поля редактируются; дату из `needs_review_fields` можно подтвердить
|
||||
* кнопкой); дальше заголовок формы. Реквизиты бумажной формы из макета [41]
|
||||
* (ОКПО, ОКОПФ, ОКФС, адрес) распознавание не отдаёт (arm-scan-gateway#15) — пустые рамки не рисуем.
|
||||
*/
|
||||
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange }: Props) {
|
||||
export function StatementHeaderBlock({ header, documentType, editing, draft, onDraftChange, byField, errorKeys, onConfirmDate }: Props) {
|
||||
const reportDate = formatReportDate(header.report_date.value);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<table style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
{/* фиксированная раскладка: ширина блока не зависит от содержимого и подписи кнопки (T11.6) */}
|
||||
<colgroup>
|
||||
<col style={{ width: 170 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{headerFields(header).map((r) => (
|
||||
{headerFields(header, byField).map((r) => (
|
||||
<tr key={r.field}>
|
||||
<td style={{ ...cell, color: '#475467' }}>{r.label}</td>
|
||||
<td
|
||||
style={{
|
||||
...cell,
|
||||
fontWeight: 600,
|
||||
minWidth: 160,
|
||||
background: fieldBackground(r.modified, r.attention),
|
||||
}}>
|
||||
{r.issues.length > 0 && (
|
||||
<Tooltip title={r.issues.map((i) => i.message).join('; ')}>
|
||||
<span>
|
||||
<SeverityIcon severity={worstSeverity(r.issues) ?? 'info'} style={{ marginRight: 4 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{editing ? (
|
||||
<>
|
||||
<Input
|
||||
size='small'
|
||||
status={errorKeys?.has(r.field) ? 'error' : undefined}
|
||||
placeholder={PLACEHOLDER[r.field]}
|
||||
value={draft[r.field] ?? r.raw ?? ''}
|
||||
onChange={(e) => onDraftChange(r.field, e.target.value)}
|
||||
/>
|
||||
{r.canConfirm && onConfirmDate && (
|
||||
<Tooltip title={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
|
||||
<Button
|
||||
size='small'
|
||||
style={{ marginTop: 4 }}
|
||||
disabled={draft.confirm_report_date === true}
|
||||
aria-label={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
|
||||
onClick={onConfirmDate}>
|
||||
{draft.confirm_report_date ? 'Подтверждено' : 'Подтвердить'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Tooltip title={r.tip}>{r.text}</Tooltip>
|
||||
)}
|
||||
|
||||
@ -1,31 +1,57 @@
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Space, Splitter, Switch, Typography, message } from 'antd';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
||||
import type { DocumentOut, FailedCheckOut, StatementOut, StatementPatch } from '../../types/api';
|
||||
import type { DocumentOut, FailedCheckOut, StatementOut, UnresolvedLineOut } from '../../types/api';
|
||||
import { parseAmountInput } from '../../utils/money';
|
||||
import { ChecksPanel, failureText } from './ChecksPanel';
|
||||
import { IssuesPanel } from './IssuesPanel';
|
||||
import { ReviewCard, confirmButtonStyle } from './ReviewCard';
|
||||
import { SourcePreview } from './SourcePreview';
|
||||
import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
|
||||
import { StatementHeaderBlock } from './StatementHeaderBlock';
|
||||
import { StatementTable } from './StatementTable';
|
||||
import { codeOptions as buildCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||||
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { switchesFor } from './focusTarget';
|
||||
import { detailCount, filterLayout, formLayout } from './formLayout';
|
||||
import { issueListItems, issuePlaces } from './issuePlaces';
|
||||
import { applyLineMenu, showsCodeInput } from './lineMenu';
|
||||
import { reviewActions } from './reviewView';
|
||||
import { 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<StatementOut | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [header, setHeader] = useState<HeaderDraft>({});
|
||||
const [draft, setDraftState] = useState<StatementDraft>(emptyDraft);
|
||||
// ошибки последней попытки сохранения: ключи подсвечивают поля красной рамкой, пока аналитик не поправит черновик
|
||||
const [draftErrors, setDraftErrors] = useState<DraftError[]>([]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
||||
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
|
||||
const [confirmLines, setConfirmLines] = useState<UnresolvedLineOut[]>([]);
|
||||
const [duplicateId, setDuplicateId] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showEmptySublines, setShowEmptySublines] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [highlightKey, setHighlightKey] = useState<string | null>(null);
|
||||
// ошибка элемента lines[] из ответа на сохранение (P6-9); строки на экране нет — текст над таблицей
|
||||
const [lineServerError, setLineServerError] = useState<{ lineId: string; text: string } | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = useState<string | null>(null);
|
||||
const highlightTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
const setDraft = (update: (prev: StatementDraft) => StatementDraft) => {
|
||||
setDraftState(update);
|
||||
setDraftErrors([]);
|
||||
setLineServerError(null);
|
||||
};
|
||||
const errorKeys = useMemo(() => new Set(draftErrors.map((error) => error.key)), [draftErrors]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@ -42,56 +68,87 @@ 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<string>()), [grid, draft]);
|
||||
const shownRows = useMemo(() => {
|
||||
if (!grid) return [];
|
||||
return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key));
|
||||
}, [grid, showEmptySublines, emptySublines]);
|
||||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
|
||||
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
|
||||
const layout = useMemo(
|
||||
() => filterLayout(fullLayout, { showDetails, hiddenKeys: showEmptySublines ? new Set<string>() : emptySublines }),
|
||||
[fullLayout, showDetails, showEmptySublines, emptySublines],
|
||||
);
|
||||
const codeOptions = useMemo(() => (statement && grid ? buildCodeOptions(statement.edition_lines, grid.rows) : []), [statement, grid]);
|
||||
// красные поля кода: ошибки формата из черновика и текст сервера
|
||||
const lineErrors = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const error of draftErrors) if (error.kind === 'code' || error.kind === 'conflict') result[error.key] = error.message;
|
||||
if (lineServerError) result[lineServerError.lineId] = lineServerError.text;
|
||||
return result;
|
||||
}, [draftErrors, lineServerError]);
|
||||
const serverErrorOffScreen =
|
||||
lineServerError !== null &&
|
||||
!layout.some(
|
||||
(item) =>
|
||||
item.kind === 'line' &&
|
||||
item.row.lineId === lineServerError.lineId &&
|
||||
editing &&
|
||||
showsCodeInput(item.row, draft.lines[lineServerError.lineId]),
|
||||
);
|
||||
const places = useMemo(
|
||||
() => (grid && statement ? issuePlaces(statement.issues ?? [], grid.rows, grid.columns) : null),
|
||||
[grid, statement],
|
||||
);
|
||||
const issueItems = useMemo(
|
||||
() => (grid && statement && places ? issueListItems(statement.issues ?? [], places, grid.rows, grid.columns) : []),
|
||||
[grid, statement, places],
|
||||
);
|
||||
// «Показать»: сначала включаем нужные переключатели, прокрутка — в эффекте ниже; 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 unresolved = useMemo(() => (grid ? unresolvedLines(grid.rows) : []), [grid]);
|
||||
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
|
||||
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
||||
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
|
||||
const dirty = Object.keys(draft).length > 0 || Object.keys(header).length > 0;
|
||||
const dirty = !isDraftEmpty(draft);
|
||||
|
||||
const save = async () => {
|
||||
const values: Record<string, string | null> = {};
|
||||
for (const [key, text] of Object.entries(draft)) {
|
||||
const parsed = parseAmountInput(text);
|
||||
if (!parsed.ok) {
|
||||
message.error('Исправьте суммы с неверным форматом');
|
||||
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;
|
||||
}
|
||||
values[key] = parsed.value;
|
||||
}
|
||||
const body: StatementPatch = {};
|
||||
if (Object.keys(values).length > 0) {
|
||||
body.values = draftToPatch(values, grid?.rows ?? []);
|
||||
}
|
||||
const headerPatch: HeaderDraft = {};
|
||||
for (const field of ['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);
|
||||
resetConfirmError();
|
||||
message.success('Изменения сохранены');
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const target = lineErrorTarget(e);
|
||||
if (target) setLineServerError({ lineId: target.lineId, text: errorText(e, 'Не удалось сохранить строку') });
|
||||
message.error(errorText(e, 'Не удалось сохранить изменения'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@ -99,8 +156,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft({});
|
||||
setHeader({});
|
||||
setDraft(() => emptyDraft());
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
@ -110,23 +166,32 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
onChanged();
|
||||
setConfirmFailures([]);
|
||||
setConfirmMessage(null);
|
||||
resetConfirmError();
|
||||
} catch (e: unknown) {
|
||||
message.error(errorText(e, 'Не удалось принять нарушения'));
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const resetConfirmError = () => {
|
||||
setConfirmLines([]);
|
||||
setDuplicateId(null);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await confirmDocument(document.id);
|
||||
message.success('Документ подтверждён');
|
||||
setConfirmFailures([]);
|
||||
setConfirmMessage(null);
|
||||
resetConfirmError();
|
||||
onChanged();
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const info = confirmErrorInfo(e);
|
||||
setConfirmFailures(info.failures);
|
||||
setConfirmLines(info.lines);
|
||||
setDuplicateId(info.code === 'DUPLICATE_FORM' ? info.documentId : null);
|
||||
setConfirmMessage(info.code === 'PERIOD_REQUIRED' ? `${info.message}. Исправьте дату отчёта в шапке и сохраните.` : info.message);
|
||||
}
|
||||
};
|
||||
@ -134,10 +199,25 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
if (loadError) {
|
||||
return <Alert type='error' showIcon title={loadError} action={<Button onClick={() => void load()}>Повторить</Button>} />;
|
||||
}
|
||||
if (!statement || !grid) {
|
||||
if (!statement || !grid || !places) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let confirmDescription: ReactNode;
|
||||
if (confirmFailures.length > 0) {
|
||||
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
|
||||
} else if (confirmLines.length > 0) {
|
||||
confirmDescription = (
|
||||
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(confirmLines[0].line_id)}>
|
||||
{unresolvedHint(confirmLines.length)}
|
||||
</Button>
|
||||
);
|
||||
} else if (duplicateId) {
|
||||
confirmDescription = (
|
||||
<Typography.Link onClick={() => navigate(`/documents/${duplicateId}`)}>Открыть подтверждённую форму</Typography.Link>
|
||||
);
|
||||
}
|
||||
const confirmEnabled = actions.canConfirm && unresolved.length === 0;
|
||||
const cardActions = editing ? (
|
||||
<Space>
|
||||
<Button
|
||||
@ -159,8 +239,8 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<CheckOutlined />}
|
||||
style={actions.canConfirm ? confirmButtonStyle : undefined}
|
||||
disabled={!actions.canConfirm}
|
||||
style={confirmEnabled ? confirmButtonStyle : undefined}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={confirm}>
|
||||
Подтвердить данные
|
||||
</Button>
|
||||
@ -178,26 +258,25 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
<div style={{ height: '100%', paddingLeft: 6 }}>
|
||||
<ReviewCard title='Результат распознавания' extra={cardActions}>
|
||||
<Space orientation='vertical' size={12} style={{ width: '100%' }}>
|
||||
{unresolved.length > 0 && (
|
||||
// настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7)
|
||||
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(unresolved[0].key)}>
|
||||
{unresolvedHint(unresolved.length)}
|
||||
</Button>
|
||||
)}
|
||||
<StatementHeaderBlock
|
||||
header={statement.header}
|
||||
documentType={statement.document.document_type}
|
||||
editing={editing}
|
||||
draft={header}
|
||||
onDraftChange={(field, text) => setHeader((prev) => ({ ...prev, [field]: text }))}
|
||||
draft={draft.header}
|
||||
byField={places.byField}
|
||||
errorKeys={errorKeys}
|
||||
onDraftChange={(field, text) => setDraft((prev) => ({ ...prev, header: { ...prev.header, [field]: text } }))}
|
||||
onConfirmDate={() => setDraft((prev) => ({ ...prev, header: { ...prev.header, confirm_report_date: true } }))}
|
||||
/>
|
||||
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} />
|
||||
{confirmMessage && (
|
||||
<Alert
|
||||
type='error'
|
||||
showIcon
|
||||
title={confirmMessage}
|
||||
description={
|
||||
confirmFailures.length > 0
|
||||
? confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ChecksPanel checks={statement.checks} editable={changeable} dirty={dirty} onAccept={accept} onShow={focusRow} />
|
||||
<IssuesPanel mode='statement' items={issueItems} onShow={focusRow} />
|
||||
{confirmMessage && <Alert type='error' showIcon title={confirmMessage} description={confirmDescription} />}
|
||||
{emptySublines.size > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
|
||||
@ -206,13 +285,47 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
{details > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch id='show-details' size='small' checked={showDetails} onChange={setShowDetails} />
|
||||
<label htmlFor='show-details' style={{ cursor: 'pointer' }}>
|
||||
Показать расшифровки ({details})
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
|
||||
<StatementTable
|
||||
documentType={statement.document.document_type}
|
||||
columns={grid.columns}
|
||||
rows={shownRows}
|
||||
layout={layout}
|
||||
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}
|
||||
lineActions={draft.lines}
|
||||
codeOptions={codeOptions}
|
||||
lineErrors={lineErrors}
|
||||
onLineCodeChange={(row, code) =>
|
||||
row.lineId && setDraft((prev) => ({ ...prev, lines: { ...prev.lines, [row.lineId as string]: { kind: 'code', code } } }))
|
||||
}
|
||||
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
|
||||
errorKeys={errorKeys}
|
||||
fullDraft={draft}
|
||||
onConfirmToggle={(key, state) =>
|
||||
setDraft((prev) => {
|
||||
const confirms = { ...prev.confirms };
|
||||
// повторный клик по нажатой кнопке убирает подтверждение из черновика
|
||||
if (state === 'pending-confirm' || state === 'pending-unconfirm') delete confirms[key];
|
||||
else confirms[key] = state === 'confirm';
|
||||
return { ...prev, confirms };
|
||||
})
|
||||
}
|
||||
onColumnDateChange={(columnId, text) => setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [columnId]: text } }))}
|
||||
onColumnDateConfirm={(col) =>
|
||||
col.period_date && setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [col.id]: col.period_date as string } }))
|
||||
}
|
||||
onDraftChange={(key, text, original) =>
|
||||
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</ReviewCard>
|
||||
|
||||
@ -1,51 +1,76 @@
|
||||
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 { columnTitle } from './reviewView';
|
||||
import { type GridRow, cellFlags, cellKey } from './statementGrid';
|
||||
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 type { LineAction, StatementDraft } from './statementDraft';
|
||||
import { type GridRow, cellKey } from './statementGrid';
|
||||
|
||||
type Props = {
|
||||
documentType: DocumentType;
|
||||
columns: ColumnOut[];
|
||||
rows: GridRow[];
|
||||
layout: LayoutRow[];
|
||||
editable: boolean;
|
||||
draft: Record<string, string>;
|
||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||
/** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */
|
||||
errorKeys?: Set<string>;
|
||||
/** предупреждения и нарушения у мест таблицы */
|
||||
places: IssuePlaces;
|
||||
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
|
||||
highlightKey?: string | null;
|
||||
/** действия над строками из черновика, подсказки кода и ошибки полей кода (lineId → текст) */
|
||||
lineActions?: Record<string, LineAction>;
|
||||
codeOptions?: CodeOption[];
|
||||
lineErrors?: Record<string, string>;
|
||||
onLineCodeChange?: (row: GridRow, code: string) => void;
|
||||
onLineMenu?: (row: GridRow, key: LineMenuKey) => void;
|
||||
/** весь черновик: подтверждения ячеек и даты колонок (суммы — в `draft` выше) */
|
||||
fullDraft?: StatementDraft;
|
||||
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
|
||||
onColumnDateChange?: (columnId: string, text: string) => void;
|
||||
onColumnDateConfirm?: (col: ColumnOut) => void;
|
||||
};
|
||||
|
||||
const 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 ICON_WIDTH = 18; // значок уровня с отступом: у ребёнка вычитается из отступа, чтобы текст не съезжал
|
||||
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 === 'unreadable' || r.row.lineStatus === 'missing');
|
||||
// наименование и код: красноватый (нет кода) / оранжевый (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,
|
||||
errorKeys,
|
||||
places,
|
||||
highlightKey = null,
|
||||
lineActions = {},
|
||||
codeOptions = [],
|
||||
lineErrors = {},
|
||||
onLineCodeChange,
|
||||
onLineMenu,
|
||||
fullDraft,
|
||||
onConfirmToggle,
|
||||
onColumnDateChange,
|
||||
onColumnDateConfirm,
|
||||
}: Props) {
|
||||
const span = columns.length + 2;
|
||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
|
||||
@ -57,63 +82,136 @@ 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 rowIssues = places.byRow.get(r.key) ?? [];
|
||||
const rowWorst = worstSeverity(rowIssues);
|
||||
const text = (
|
||||
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
|
||||
<span style={{ fontWeight: lineWeight(r), display: 'inline-block' }}>
|
||||
{r.displayName || '—'}
|
||||
{r.secondaryName && (
|
||||
<Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}>
|
||||
{r.secondaryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
const namedText = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
const named = editable ? (
|
||||
<>
|
||||
{namedText}
|
||||
<LineMenuButton
|
||||
row={r.row}
|
||||
action={r.row.lineId ? lineActions[r.row.lineId] : undefined}
|
||||
onSelect={(key) => onLineMenu?.(r.row, key)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
namedText
|
||||
);
|
||||
// отступ ребёнка — на общей обёртке: значок уровня остаётся внутри отступа и не сдвигает текст (T11.8)
|
||||
const indent = r.child ? CHILD_INDENT : undefined;
|
||||
if (!rowWorst) return <div style={{ paddingLeft: indent }}>{named}</div>;
|
||||
return (
|
||||
<div style={{ paddingLeft: indent === undefined ? undefined : indent - ICON_WIDTH, display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip
|
||||
title={rowIssues.map((i) => (
|
||||
<div key={i.id}>{i.message}</div>
|
||||
))}>
|
||||
<span>
|
||||
<SeverityIcon severity={rowWorst} style={{ marginRight: 4 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{named}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
title: 'Код',
|
||||
width: 60,
|
||||
width: editable ? 160 : 84,
|
||||
align: 'center' as const,
|
||||
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: lineBackground(r) } }),
|
||||
render: (_: unknown, r: LayoutRow) => (r.kind === 'line' ? <span style={{ fontWeight: lineWeight(r) }}>{r.row.code}</span> : null),
|
||||
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;
|
||||
const editing =
|
||||
editable && lineId
|
||||
? {
|
||||
action: lineActions[lineId],
|
||||
options: codeOptions,
|
||||
error: lineErrors[lineId],
|
||||
onCodeChange: (code: string) => onLineCodeChange?.(r.row, code),
|
||||
}
|
||||
: undefined;
|
||||
return <CodeCell row={r.row} fontWeight={lineWeight(r)} editing={editing} />;
|
||||
},
|
||||
},
|
||||
...columns.map((col) => ({
|
||||
key: col.id,
|
||||
title: columnTitle(col),
|
||||
width: 112,
|
||||
title: (
|
||||
<ColumnHeader
|
||||
col={col}
|
||||
issues={places.byColumn.get(col.id) ?? []}
|
||||
editing={editable}
|
||||
draftDate={fullDraft?.columns[col.id]}
|
||||
error={errorKeys?.has(col.id)}
|
||||
onDateChange={onColumnDateChange}
|
||||
onConfirmDate={onColumnDateConfirm}
|
||||
/>
|
||||
),
|
||||
width: 128,
|
||||
align: 'right' as const,
|
||||
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 <Tooltip title={tip}>{formatAmount(value.amount, { fraction: 'auto', empty: '-' })}</Tooltip>;
|
||||
}
|
||||
const text = draft[key] ?? value.amount ?? '';
|
||||
const invalid = key in draft && !parseAmountInput(text).ok;
|
||||
const value = r.row.cells[col.id];
|
||||
if (!value) return null;
|
||||
const draftKey = cellKey(r.row.key, col.id);
|
||||
return (
|
||||
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
||||
<Input
|
||||
size='small'
|
||||
status={invalid ? 'error' : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(key, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right' }}
|
||||
<ValueCell
|
||||
value={value}
|
||||
editable={editable}
|
||||
draftKey={draftKey}
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
hasError={errorKeys?.has(draftKey)}
|
||||
issues={places.byCell.get(draftKey)}
|
||||
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
|
||||
onConfirmToggle={onConfirmToggle}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
];
|
||||
return <Table<LayoutRow> size='small' bordered rowKey='key' columns={tableColumns} dataSource={layout} pagination={false} />;
|
||||
return (
|
||||
<Table<LayoutRow>
|
||||
size='small'
|
||||
tableLayout='fixed'
|
||||
bordered
|
||||
rowKey='key'
|
||||
columns={tableColumns}
|
||||
dataSource={layout}
|
||||
pagination={false}
|
||||
onRow={(r) =>
|
||||
r.kind === 'line'
|
||||
? { id: `row-${r.key}`, style: r.key === highlightKey ? { outline: '2px solid #1677ff', outlineOffset: -2 } : undefined }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/widgets/DocumentReview/ValueCell.tsx
Normal file
100
src/widgets/DocumentReview/ValueCell.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Tooltip, Typography } from 'antd';
|
||||
import type { IssueOut, ValueOut } from '../../types/api';
|
||||
import { parseAmountInput } from '../../utils/money';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { cellTipLines, valueCellText } from './cellView';
|
||||
import type { CellConfirmState } from './editRules';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
|
||||
type Props = {
|
||||
value: ValueOut;
|
||||
editable: boolean;
|
||||
/** ключ ячейки в черновике и сам черновик (текст, пока аналитик правит) */
|
||||
draftKey: string;
|
||||
draft: Record<string, string>;
|
||||
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
|
||||
/** записи issues, привязанные к этой ячейке */
|
||||
issues?: IssueOut[];
|
||||
/** ошибка черновика, найденная при сохранении */
|
||||
hasError?: boolean;
|
||||
/** какая кнопка подтверждения нужна ячейке (editRules.cellConfirmState) и обработчик её нажатия */
|
||||
confirmState?: CellConfirmState;
|
||||
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
|
||||
};
|
||||
|
||||
const CONFIRM_TITLE = {
|
||||
confirm: 'Подтвердить значение',
|
||||
unconfirm: 'Снять подтверждение',
|
||||
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
|
||||
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
|
||||
} as const;
|
||||
// постоянное имя кнопки по виду действия (для скринридера и тестов); состояние «в черновике» — в aria-pressed, длинный текст — в подсказке
|
||||
const CONFIRM_LABEL = {
|
||||
confirm: 'Подтвердить значение',
|
||||
'pending-confirm': 'Подтвердить значение',
|
||||
unconfirm: 'Снять подтверждение',
|
||||
'pending-unconfirm': 'Снять подтверждение',
|
||||
} as const;
|
||||
|
||||
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
|
||||
export function ValueCell({
|
||||
value,
|
||||
editable,
|
||||
draftKey,
|
||||
draft,
|
||||
onDraftChange,
|
||||
issues = [],
|
||||
hasError = false,
|
||||
confirmState = null,
|
||||
onConfirmToggle,
|
||||
}: Props) {
|
||||
const lines = cellTipLines(value, issues);
|
||||
const worst = worstSeverity(issues);
|
||||
const shown = valueCellText(value);
|
||||
// значок уровня — внутри ячейки, слева от суммы, по центру по вертикали (T11.3)
|
||||
const corner = worst && <SeverityIcon severity={worst} style={{ fontSize: 11, flex: 'none' }} />;
|
||||
const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined;
|
||||
if (!editable) {
|
||||
return (
|
||||
// подсказка на всей ячейке: блок на всю ширину колонки (T11.4)
|
||||
<Tooltip title={tip}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4, minHeight: 22 }}>
|
||||
{corner}
|
||||
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />}
|
||||
{shown.muted ? <Typography.Text type='secondary'>{shown.text}</Typography.Text> : shown.text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const text = draft[draftKey] ?? value.amount ?? '';
|
||||
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
|
||||
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
|
||||
return (
|
||||
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
|
||||
{corner}
|
||||
<Input
|
||||
size='small'
|
||||
status={invalid ? 'error' : undefined}
|
||||
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right', minWidth: 0, flex: 1 }}
|
||||
/>
|
||||
{confirmState && (
|
||||
<Tooltip title={CONFIRM_TITLE[confirmState]}>
|
||||
<Button
|
||||
size='small'
|
||||
type={pending ? 'primary' : 'default'}
|
||||
aria-pressed={pending}
|
||||
aria-label={CONFIRM_LABEL[confirmState]}
|
||||
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
|
||||
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
1006
src/widgets/DocumentReview/__fixtures__/statement-v2.json
Normal file
1006
src/widgets/DocumentReview/__fixtures__/statement-v2.json
Normal file
File diff suppressed because it is too large
Load Diff
162
src/widgets/DocumentReview/cellView.test.ts
Normal file
162
src/widgets/DocumentReview/cellView.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ValueOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { COLORS, cellTipLines, codeCellView, rowBackground, valueBackground, valueCellText } from './cellView';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
const val = (extra: Partial<ValueOut> = {}): 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> = {}): 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('пустой или пробельный текст — строки «Распознано» нет', () => {
|
||||
expect(cellTipLines(val({ text: '', confidence: '100' }))).toEqual(['Уверенность: 100%']);
|
||||
expect(cellTipLines(val({ text: ' ', confidence: null }))).toEqual([]);
|
||||
});
|
||||
it('уверенность округляется, нечисловая — без строки', () => {
|
||||
expect(cellTipLines(val({ text: null, confidence: '61.54' }))).toEqual(['Уверенность: 62%']);
|
||||
expect(cellTipLines(val({ text: null, confidence: 'abc' }))).toEqual([]);
|
||||
});
|
||||
it('расшифровка без needs_review тоже получает подсказку', () => {
|
||||
expect(cellTipLines(val({ needs_review: false, text: '70045', confidence: '67' }))).toEqual([
|
||||
'Распознано: «70045»',
|
||||
'Уверенность: 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: ' ' })).tip).toBe('Код не распознан: «—»');
|
||||
});
|
||||
it('ok: нечисловая уверенность кода — без хвоста', () => {
|
||||
expect(codeCellView(row({ codeConfidence: 'abc' })).tip).toBeUndefined();
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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»');
|
||||
});
|
||||
});
|
||||
76
src/widgets/DocumentReview/cellView.ts
Normal file
76
src/widgets/DocumentReview/cellView.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Вид ячеек и строк таблицы формы: цвета, подсказки, ячейка кода. Только показ, данные не меняются.
|
||||
import type { IssueOut, ValueOut } from '../../types/api';
|
||||
import { formatAmount } from '../../utils/money';
|
||||
import { formatPercent } from '../../utils/percent';
|
||||
import { type GridRow, cellFlags } from './statementGrid';
|
||||
|
||||
export const COLORS = {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Строки подсказки ячейки суммы; подсказка есть и без подсветки (Р-23). */
|
||||
export function cellTipLines(value: ValueOut, issues: IssueOut[] = []): string[] {
|
||||
const lines: string[] = [];
|
||||
if (value.is_modified) {
|
||||
lines.push(`Исходное: ${formatAmount(value.original_amount, { fraction: 'auto', empty: '-' })}`);
|
||||
}
|
||||
if (value.text !== null && value.text.trim() !== '') lines.push(`Распознано: «${value.text}»`);
|
||||
const confidence = formatPercent(value.confidence);
|
||||
if (confidence !== null) lines.push(`Уверенность: ${confidence}`);
|
||||
if (value.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) {
|
||||
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) {
|
||||
const confidence = formatPercent(row.codeConfidence);
|
||||
if (confidence !== null) tips.push(`Уверенность кода: ${confidence}`);
|
||||
}
|
||||
if (row.isConfirmed) tips.push('Код подтверждён');
|
||||
return { text: row.code ?? '', muted: false, tip: tips.length > 0 ? tips.join('\n') : undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
70
src/widgets/DocumentReview/codeSuggestions.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { codeOptions, filterCodeOptions, lineErrorTarget } from './codeSuggestions';
|
||||
import { type GridRow, buildStatementGrid } from './statementGrid';
|
||||
|
||||
const statement = statementV2Json as StatementOut;
|
||||
const { rows } = buildStatementGrid(statement);
|
||||
|
||||
describe('codeOptions', () => {
|
||||
it('сначала справочник по порядку, коды без повторов', () => {
|
||||
const options = codeOptions(statement.edition_lines, rows);
|
||||
expect(options.slice(0, 3).map((o) => o.code)).toEqual(['1110', '1150', '1170']);
|
||||
expect(options.filter((o) => o.code === '1250')).toEqual([{ code: '1250', name: 'Денежные средства и денежные эквиваленты' }]);
|
||||
expect(options.filter((o) => o.code === '1370')).toHaveLength(1);
|
||||
});
|
||||
it('код ok-строки файла вне справочника — в конце, с её именем', () => {
|
||||
const base = rows.find((r) => r.key === 'l-1210');
|
||||
if (!base) throw new Error('нет строки l-1210');
|
||||
const extra: GridRow = { ...base, key: 'x', code: '1231', name: 'Прочая задолженность', referenceName: null };
|
||||
const options = codeOptions(statement.edition_lines, [...rows, extra]);
|
||||
expect(options.map((o) => o).pop()).toEqual({ code: '1231', name: 'Прочая задолженность' });
|
||||
});
|
||||
it('справочник пуст — только коды ok-строк; unreadable и detail не попадают', () => {
|
||||
const options = codeOptions([], rows);
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
expect(options.map((o) => o.code)).toContain('1210');
|
||||
expect(options.every((o) => /^\d{4,5}$/.test(o.code))).toBe(true);
|
||||
const codes = options.map((o) => o.code);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCodeOptions', () => {
|
||||
const options = codeOptions(statement.edition_lines, rows);
|
||||
it('по началу кода', () => {
|
||||
const found = filterCodeOptions(options, '12');
|
||||
expect(found.length).toBeGreaterThan(0);
|
||||
expect(found.every((o) => o.code.startsWith('12'))).toBe(true);
|
||||
});
|
||||
it('по названию без учёта регистра и ё/е', () => {
|
||||
expect(filterCodeOptions(options, 'денеж').map((o) => o.code)).toEqual(['1250']);
|
||||
expect(filterCodeOptions(options, 'ДЕНЁЖ').map((o) => o.code)).toEqual(['1250']);
|
||||
});
|
||||
it('пустой запрос — первые 20', () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ code: String(1000 + i), name: '' }));
|
||||
expect(filterCodeOptions(many, '')).toHaveLength(20);
|
||||
expect(filterCodeOptions(many, '', 5)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineErrorTarget', () => {
|
||||
it('409 LINE_CODE_TAKEN → строка и код', () => {
|
||||
const body = { detail: { code: 'LINE_CODE_TAKEN', message: 'Код занят', line_id: 'l-u7', line_code: '1170' } };
|
||||
expect(lineErrorTarget(new ApiError(409, 'LINE_CODE_TAKEN', 'Код занят', body))).toEqual({ lineId: 'l-u7', lineCode: '1170' });
|
||||
});
|
||||
it('422 INVALID_LINE_PATCH с line_code null', () => {
|
||||
const body = { detail: { code: 'INVALID_LINE_PATCH', message: 'm', line_id: 'l-u7', line_code: null } };
|
||||
expect(lineErrorTarget(new ApiError(422, 'INVALID_LINE_PATCH', 'm', body))).toEqual({ lineId: 'l-u7', lineCode: null });
|
||||
});
|
||||
it('LINES_UNRESOLVED и массив pydantic → null', () => {
|
||||
const unresolved = { detail: { code: 'LINES_UNRESOLVED', message: 'm', lines: [{ line_id: 'a' }] } };
|
||||
expect(lineErrorTarget(new ApiError(409, 'LINES_UNRESOLVED', 'm', unresolved))).toBeNull();
|
||||
expect(lineErrorTarget(new ApiError(422, undefined, 'msg', { detail: [{ msg: 'msg' }] }))).toBeNull();
|
||||
});
|
||||
it('не ApiError → null', () => {
|
||||
expect(lineErrorTarget(new Error('x'))).toBeNull();
|
||||
});
|
||||
});
|
||||
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
46
src/widgets/DocumentReview/codeSuggestions.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { EditionLineOut, LineErrorDetail } from '../../types/api';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
export type CodeOption = { code: string; name: string };
|
||||
|
||||
const LIST_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Подсказки кода (P6-1): сначала справочник редакции по порядку, затем коды строк `ok` файла вне справочника.
|
||||
* Без повторов; строки без кода (unreadable / missing / detail) не попадают.
|
||||
*/
|
||||
export function codeOptions(editionLines: EditionLineOut[], rows: GridRow[]): CodeOption[] {
|
||||
const result: CodeOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of editionLines) {
|
||||
if (seen.has(line.code)) continue;
|
||||
seen.add(line.code);
|
||||
result.push({ code: line.code, name: line.name });
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.lineStatus !== 'ok' || !row.code || seen.has(row.code)) continue;
|
||||
seen.add(row.code);
|
||||
result.push({ code: row.code, name: row.referenceName ?? row.name ?? '' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const fold = (text: string) => text.toLowerCase().replace(/ё/g, 'е');
|
||||
|
||||
/** Отбор подсказок: код начинается с запроса или имя содержит его (регистр и ё/е не важны). */
|
||||
export function filterCodeOptions(options: CodeOption[], query: string, limit = LIST_LIMIT): CodeOption[] {
|
||||
const q = fold(query.trim());
|
||||
if (q === '') return options.slice(0, limit);
|
||||
return options.filter((o) => o.code.startsWith(q) || fold(o.name).includes(q)).slice(0, limit);
|
||||
}
|
||||
|
||||
const LINE_ERROR_CODES = new Set(['LINE_CODE_TAKEN', 'INVALID_LINE_CODE', 'INVALID_LINE_PATCH', 'UNKNOWN_LINE', 'LINE_CODE_REQUIRED']);
|
||||
|
||||
/** P6-9: к какой строке относится ошибка элемента `lines[]` ответа на сохранение; иная ошибка — null. */
|
||||
export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null {
|
||||
if (!(error instanceof ApiError) || !error.code || !LINE_ERROR_CODES.has(error.code)) return null;
|
||||
const detail = (error.body as { detail?: Partial<Record<keyof LineErrorDetail, unknown>> } | undefined)?.detail;
|
||||
if (typeof detail?.line_id !== 'string') return null;
|
||||
return { lineId: detail.line_id, lineCode: typeof detail.line_code === 'string' ? detail.line_code : null };
|
||||
}
|
||||
40
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal file
40
src/widgets/DocumentReview/confirmBlockers.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StatementOut, ValueOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { unresolvedHint, unresolvedLines } from './confirmBlockers';
|
||||
import { type GridRow, buildStatementGrid } from './statementGrid';
|
||||
|
||||
const { rows } = buildStatementGrid(statementV2Json as unknown as StatementOut);
|
||||
const emptyCell = { amount: null, string_value: null } as ValueOut;
|
||||
const row = (patch: Partial<GridRow>): GridRow => ({ ...rows[0], cells: {}, ...patch });
|
||||
|
||||
describe('unresolvedLines', () => {
|
||||
it('фикстура: строки без кода со значениями', () => {
|
||||
expect(unresolvedLines(rows).map((r) => r.lineId)).toEqual(['l-u7', 'l-m8']);
|
||||
});
|
||||
it('сортирует по row_index, без индекса — в конец, равные — как пришли', () => {
|
||||
const withAmount = { ...emptyCell, amount: '1.00' } as ValueOut;
|
||||
const mk = (lineId: string, rowIndex: number | null) => row({ lineId, rowIndex, lineStatus: 'unreadable', cells: { a: withAmount } });
|
||||
const shuffled = [mk('n1', null), mk('c', 30), mk('a', 10), mk('b', 20), mk('n2', null), mk('b2', 20)];
|
||||
expect(unresolvedLines(shuffled).map((r) => r.lineId)).toEqual(['a', 'b', 'b2', 'c', 'n1', 'n2']);
|
||||
});
|
||||
it('missing без значений не блокирует', () => {
|
||||
expect(unresolvedLines([row({ lineStatus: 'missing', cells: { a: emptyCell } })])).toEqual([]);
|
||||
});
|
||||
it('unreadable только со string_value блокирует', () => {
|
||||
const r = row({ lineStatus: 'unreadable', cells: { a: { ...emptyCell, string_value: '211(1 |' } } });
|
||||
expect(unresolvedLines([r])).toEqual([r]);
|
||||
});
|
||||
it('detail с суммой не блокирует', () => {
|
||||
expect(unresolvedLines([row({ lineStatus: 'detail', cells: { a: { ...emptyCell, amount: '1.00' } } })])).toEqual([]);
|
||||
});
|
||||
it('форма 1.x: все ok — пусто', () => {
|
||||
expect(unresolvedLines(rows.filter((r) => r.lineStatus === 'ok'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unresolvedHint', () => {
|
||||
it('текст', () => {
|
||||
expect(unresolvedHint(2)).toBe('Разберите строки без кода: 2');
|
||||
});
|
||||
});
|
||||
17
src/widgets/DocumentReview/confirmBlockers.ts
Normal file
17
src/widgets/DocumentReview/confirmBlockers.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Строки без распознанного кода, в которых есть значения (P6-10): они блокируют «Подтвердить данные». Порядок — по `row_index`, строки без индекса в конце. */
|
||||
export function unresolvedLines(rows: GridRow[]): GridRow[] {
|
||||
const found = rows.filter(
|
||||
(row) =>
|
||||
(row.lineStatus === 'unreadable' || row.lineStatus === 'missing') &&
|
||||
Object.values(row.cells).some((cell) => cell !== undefined && (cell.amount !== null || cell.string_value !== null)),
|
||||
);
|
||||
// порядок как в бланке: по row_index (без индекса — в конец), при равенстве — как пришло
|
||||
return found
|
||||
.map((row, i) => ({ row, i }))
|
||||
.sort((a, b) => (a.row.rowIndex ?? Number.POSITIVE_INFINITY) - (b.row.rowIndex ?? Number.POSITIVE_INFINITY) || a.i - b.i)
|
||||
.map(({ row }) => row);
|
||||
}
|
||||
|
||||
export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`;
|
||||
@ -8,7 +8,13 @@ describe('confirmErrorInfo', () => {
|
||||
const e = new ApiError(409, 'CHECKS_NOT_PASSED', 'не пройдены проверки', {
|
||||
detail: { code: 'CHECKS_NOT_PASSED', message: 'x', failures: [failure] },
|
||||
});
|
||||
expect(confirmErrorInfo(e)).toEqual({ code: 'CHECKS_NOT_PASSED', message: 'не пройдены проверки', failures: [failure] });
|
||||
expect(confirmErrorInfo(e)).toEqual({
|
||||
code: 'CHECKS_NOT_PASSED',
|
||||
message: 'не пройдены проверки',
|
||||
failures: [failure],
|
||||
lines: [],
|
||||
documentId: null,
|
||||
});
|
||||
});
|
||||
it('прочие ошибки без нарушений', () => {
|
||||
expect(
|
||||
@ -17,7 +23,24 @@ describe('confirmErrorInfo', () => {
|
||||
code: 'PERIOD_REQUIRED',
|
||||
message: 'нужна дата',
|
||||
failures: [],
|
||||
lines: [],
|
||||
documentId: null,
|
||||
});
|
||||
expect(confirmErrorInfo(new Error('boom'))).toEqual({ code: undefined, message: 'Не удалось подтвердить документ', failures: [] });
|
||||
expect(confirmErrorInfo(new Error('boom'))).toEqual({
|
||||
code: undefined,
|
||||
message: 'Не удалось подтвердить документ',
|
||||
failures: [],
|
||||
lines: [],
|
||||
documentId: null,
|
||||
});
|
||||
});
|
||||
it('LINES_UNRESOLVED со строками', () => {
|
||||
const line = { line_id: 'l-m8', row_index: 8, line_code_text: null };
|
||||
const e = new ApiError(409, 'LINES_UNRESOLVED', 'есть строки без кода', { detail: { lines: [line] } });
|
||||
expect(confirmErrorInfo(e).lines).toEqual([line]);
|
||||
});
|
||||
it('DUPLICATE_FORM с document_id', () => {
|
||||
const e = new ApiError(409, 'DUPLICATE_FORM', 'форма уже есть', { detail: { document_id: 'doc-0' } });
|
||||
expect(confirmErrorInfo(e).documentId).toBe('doc-0');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { FailedCheckOut } from '../../types/api';
|
||||
import type { FailedCheckOut, UnresolvedLineOut } from '../../types/api';
|
||||
|
||||
/** Разбор ошибки подтверждения: код, текст и нарушения проверок (CHECKS_NOT_PASSED). */
|
||||
export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[] } {
|
||||
/** Разбор ошибки подтверждения: код, текст, нарушения проверок (CHECKS_NOT_PASSED), строки без кода (LINES_UNRESOLVED) и документ-дубль (DUPLICATE_FORM). */
|
||||
export function confirmErrorInfo(e: unknown): {
|
||||
code: string | undefined;
|
||||
message: string;
|
||||
failures: FailedCheckOut[];
|
||||
lines: UnresolvedLineOut[];
|
||||
documentId: string | null;
|
||||
} {
|
||||
if (!(e instanceof ApiError)) {
|
||||
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [] };
|
||||
return { code: undefined, message: 'Не удалось подтвердить документ', failures: [], lines: [], documentId: null };
|
||||
}
|
||||
const detail = (e.body as { detail?: { failures?: unknown } } | undefined)?.detail;
|
||||
const detail = (e.body as { detail?: { failures?: unknown; lines?: unknown; document_id?: unknown } } | undefined)?.detail;
|
||||
const failures = Array.isArray(detail?.failures) ? (detail.failures as FailedCheckOut[]) : [];
|
||||
return { code: e.code, message: e.message, failures };
|
||||
const lines = Array.isArray(detail?.lines) ? (detail.lines as UnresolvedLineOut[]) : [];
|
||||
const documentId = typeof detail?.document_id === 'string' ? detail.document_id : null;
|
||||
return { code: e.code, message: e.message, failures, lines, documentId };
|
||||
}
|
||||
|
||||
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IssueOut, StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { cellConfirmState, columnDateEditable } from './editRules';
|
||||
import { emptyDraft } from './statementDraft';
|
||||
import { buildStatementGrid, cellKey } from './statementGrid';
|
||||
|
||||
const statement = statementV2Json as StatementOut;
|
||||
const { rows, columns } = buildStatementGrid(statement);
|
||||
const col = (id: string) => columns.find((c) => c.id === id) as (typeof columns)[number];
|
||||
const issue = (code: string) => statement.issues.find((i) => i.code === code) as IssueOut;
|
||||
const rowOf = (key: string) => rows.find((r) => r.key === key) as (typeof rows)[number];
|
||||
|
||||
describe('columnDateEditable', () => {
|
||||
it('колонка без даты с записью — правится, подтверждать нечего', () => {
|
||||
expect(columnDateEditable(col('c2'), [issue('COLUMN_WITHOUT_DATE')])).toEqual({ editable: true, canConfirm: false });
|
||||
});
|
||||
it('колонка с датой и без записей — не правится', () => {
|
||||
expect(columnDateEditable(col('c1'), [])).toEqual({ editable: false, canConfirm: false });
|
||||
});
|
||||
it('колонка с датой и COLUMN_DATE_UNCERTAIN — правится и подтверждается', () => {
|
||||
const uncertain = { ...issue('COLUMN_WITHOUT_DATE'), code: 'COLUMN_DATE_UNCERTAIN' } as IssueOut;
|
||||
expect(columnDateEditable(col('c1'), [uncertain])).toEqual({ editable: true, canConfirm: true });
|
||||
});
|
||||
it('посторонняя запись колонки не открывает правку', () => {
|
||||
expect(columnDateEditable(col('c1'), [issue('RELATION_FAILED')])).toEqual({ editable: false, canConfirm: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cellConfirmState', () => {
|
||||
const row = rowOf('l-u7');
|
||||
const value = row.cells.c1;
|
||||
const key = cellKey('l-u7', 'c1');
|
||||
it('needs_review → confirm', () => {
|
||||
expect(cellConfirmState(row, value, emptyDraft(), key)).toBe('confirm');
|
||||
});
|
||||
it('подтверждение в черновике → pending-confirm', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: true } }, key)).toBe('pending-confirm');
|
||||
});
|
||||
it('снятие в черновике → pending-unconfirm', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: false } }, key)).toBe('pending-unconfirm');
|
||||
});
|
||||
it('сумма в черновике → null', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), amounts: { [key]: '7' } }, key)).toBeNull();
|
||||
});
|
||||
it('шаблонная строка (нет id) → null', () => {
|
||||
const tpl = rowOf('code-1250');
|
||||
expect(cellConfirmState(tpl, tpl.cells.c1, emptyDraft(), cellKey('code-1250', 'c1'))).toBeNull();
|
||||
});
|
||||
it('значение без id → null', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, id: null }, emptyDraft(), key)).toBeNull();
|
||||
});
|
||||
it('is_confirmed → unconfirm', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, needs_review: false, is_confirmed: true }, emptyDraft(), key)).toBe('unconfirm');
|
||||
});
|
||||
it('обычная ячейка → null', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, needs_review: false }, emptyDraft(), key)).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/widgets/DocumentReview/editRules.ts
Normal file
25
src/widgets/DocumentReview/editRules.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// Правила правки: когда у колонки можно править дату и когда у ячейки доступно подтверждение (P6-6, P6-11).
|
||||
import type { ColumnOut, IssueOut, ValueOut } from '../../types/api';
|
||||
import type { StatementDraft } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Записи issues, при которых аналитик правит или подтверждает дату колонки. */
|
||||
export const COLUMN_DATE_CODES: ReadonlySet<string> = new Set(['COLUMN_WITHOUT_DATE', 'COLUMN_DATE_UNCERTAIN']);
|
||||
|
||||
export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean } {
|
||||
const flagged = columnIssues.some((issue) => COLUMN_DATE_CODES.has(issue.code));
|
||||
const editable = (col.period_kind === 'none' && col.period_date === null) || flagged;
|
||||
return { editable, canConfirm: editable && col.period_date !== null && flagged };
|
||||
}
|
||||
|
||||
export type CellConfirmState = 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null;
|
||||
|
||||
/** Какая кнопка подтверждения нужна ячейке. Нужны оба `id` (у достроенных ядро ответит UNKNOWN_CELL); сумма в черновике подтверждение отменяет. */
|
||||
export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): CellConfirmState {
|
||||
if (!row.lineId || !value || value.id === null) return null;
|
||||
if (key in draft.amounts) return null;
|
||||
if (key in draft.confirms) return draft.confirms[key] ? 'pending-confirm' : 'pending-unconfirm';
|
||||
if (value.needs_review) return 'confirm';
|
||||
if (value.is_confirmed) return 'unconfirm';
|
||||
return null;
|
||||
}
|
||||
24
src/widgets/DocumentReview/focusTarget.test.ts
Normal file
24
src/widgets/DocumentReview/focusTarget.test.ts
Normal file
@ -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<string>() };
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
21
src/widgets/DocumentReview/focusTarget.ts
Normal file
21
src/widgets/DocumentReview/focusTarget.ts
Normal file
@ -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<string> },
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@ -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',
|
||||
@ -10,8 +13,19 @@ 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 isLineRow = (r: ReturnType<typeof formLayout>[number]): r is Extract<ReturnType<typeof formLayout>[number], { kind: 'line' }> =>
|
||||
r.kind === 'line';
|
||||
const shape = (rows: ReturnType<typeof formLayout>) =>
|
||||
rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`));
|
||||
|
||||
@ -122,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']);
|
||||
@ -137,3 +158,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> = {}): 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<typeof formLayout>) =>
|
||||
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<string>();
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<La
|
||||
total: code !== null && isTotal(code),
|
||||
grand: balance && GRAND_TOTALS.has(row.code ?? ''),
|
||||
child: false,
|
||||
...stripSectionPrefixes(row.name, prefixes),
|
||||
...lineNames(row, prefixes),
|
||||
};
|
||||
}
|
||||
|
||||
@ -122,15 +145,57 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
|
||||
return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false));
|
||||
}
|
||||
|
||||
const isLine = (item: LayoutRow): item is Extract<LayoutRow, { kind: 'line' }> => 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<string | null, GridRow[]>();
|
||||
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<string, GridRow[]>();
|
||||
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<string> }): 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;
|
||||
|
||||
@ -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> = {}): HeaderFieldOut => ({
|
||||
@ -15,7 +16,11 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
|
||||
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,60 @@ const header = (over: Partial<StatementHeaderOut> = {}) =>
|
||||
...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 tip = (confidence: string | null, text = 'ООО Р') =>
|
||||
headerFields(header({ source: { organization_name: { text, confidence } } }))[0].tip;
|
||||
expect(tip('61.54')).toBe('Распознано: «ООО Р» · уверенность 62%');
|
||||
expect(tip('abc')).toBe('Распознано: «ООО Р»');
|
||||
expect(tip('67', ' ')).toBeUndefined();
|
||||
});
|
||||
it('исправленное поле — исходное в подсказке', () => {
|
||||
const rows = headerFields(header({ units: f('руб.', { modified: true, original_value: 'тыс. руб.' }) }));
|
||||
expect(rows[3]).toMatchObject({ modified: true, tip: 'Исходное: тыс. руб.' });
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { HeaderFieldOut, StatementHeaderOut } from '../../types/api';
|
||||
import type { HeaderFieldOut, IssueOut, StatementHeaderOut } from '../../types/api';
|
||||
import { formatPercent } from '../../utils/percent';
|
||||
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 +12,47 @@ 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<string, IssueOut[]>): 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.trim() !== '' ? `Распознано: «${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) {
|
||||
const confidence = formatPercent(src?.confidence);
|
||||
tip = confidence !== null ? `${recognized} · уверенность ${confidence}` : recognized;
|
||||
}
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
text,
|
||||
raw,
|
||||
attention,
|
||||
modified,
|
||||
tip,
|
||||
issues: byField?.get(field) ?? [],
|
||||
canConfirm: field === 'report_date' && attention && raw !== null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
95
src/widgets/DocumentReview/issuePlaces.test.ts
Normal file
95
src/widgets/DocumentReview/issuePlaces.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IssueOut, StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { collapseIssues, 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>): 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();
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
126
src/widgets/DocumentReview/issuePlaces.ts
Normal file
126
src/widgets/DocumentReview/issuePlaces.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// Куда в интерфейсе положить каждую запись 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<string, IssueOut[]>;
|
||||
/** ключ — cellKey(rowKey, columnId) */
|
||||
byCell: Map<string, IssueOut[]>;
|
||||
byColumn: Map<string, IssueOut[]>;
|
||||
byField: Map<string, IssueOut[]>;
|
||||
document: IssueOut[];
|
||||
}
|
||||
|
||||
export interface IssueListItem {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
message: string;
|
||||
place: string;
|
||||
targetKey: string | null;
|
||||
}
|
||||
|
||||
const push = (map: Map<string, IssueOut[]>, 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<string, GridRow>();
|
||||
for (const r of rows) if (r.lineId !== null) rowByLineId.set(r.lineId, r);
|
||||
const columnIds = new Set(columns.map((c) => c.id));
|
||||
for (const issue of issues) {
|
||||
const lineIds = issue.line_ids ?? [];
|
||||
if (issue.field !== null) {
|
||||
if (fields.includes(issue.field)) push(places.byField, issue.field, issue);
|
||||
else places.document.push(issue);
|
||||
continue;
|
||||
}
|
||||
const known = lineIds.map((id) => rowByLineId.get(id)).filter((r): r is GridRow => r !== undefined);
|
||||
const columnId = issue.column_id !== null && columnIds.has(issue.column_id) ? issue.column_id : null;
|
||||
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 (lineIds.length === 0 && columnId !== null) {
|
||||
push(places.byColumn, columnId, issue);
|
||||
} else {
|
||||
places.document.push(issue);
|
||||
}
|
||||
}
|
||||
return places;
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
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<Severity, number> = { error: 0, info: 1 };
|
||||
|
||||
/** Список предупреждений документа: только kind = warning, сначала error, затем info, внутри — как пришло. */
|
||||
export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[] {
|
||||
const rowByLineId = new Map<string, GridRow>();
|
||||
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';
|
||||
}
|
||||
|
||||
/** Свёрнутый список предупреждений (T11.11): первые `limit` (порядок уже «ошибки первыми») и сколько скрыто. */
|
||||
export function collapseIssues<T>(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 };
|
||||
}
|
||||
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
78
src/widgets/DocumentReview/lineMenu.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { applyLineMenu, lineMenuItems, showsCodeInput } from './lineMenu';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import { type GridRow, buildStatementGrid } from './statementGrid';
|
||||
|
||||
const { rows } = buildStatementGrid(statementV2Json as StatementOut);
|
||||
const row = (key: string): GridRow => {
|
||||
const found = rows.find((r) => r.key === key);
|
||||
if (!found) throw new Error(key);
|
||||
return found;
|
||||
};
|
||||
const keys = (r: GridRow, action?: LineAction) => lineMenuItems(r, action).map((i) => i.key);
|
||||
|
||||
describe('lineMenuItems', () => {
|
||||
it('unreadable / missing — «Это расшифровка»', () => {
|
||||
expect(keys(row('l-u7'))).toEqual(['mark_detail']);
|
||||
expect(keys(row('l-m8'))).toEqual(['mark_detail']);
|
||||
expect(lineMenuItems(row('l-u7'), undefined)[0].label).toBe('Это расшифровка');
|
||||
});
|
||||
it('detail — «Назначить код»', () => {
|
||||
expect(lineMenuItems(row('l-d2'), undefined)).toEqual([{ key: 'assign_code', label: 'Назначить код' }]);
|
||||
});
|
||||
it('ok с needsReview — смена и подтверждение кода', () => {
|
||||
expect(lineMenuItems(row('l-1230'), undefined)).toEqual([
|
||||
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
|
||||
{ key: 'change_code', label: 'Сменить код' },
|
||||
{ key: 'confirm_code', label: 'Подтвердить код' },
|
||||
]);
|
||||
});
|
||||
it('ok подтверждённая — снять подтверждение', () => {
|
||||
const confirmed = { ...row('l-1230'), isConfirmed: true };
|
||||
expect(lineMenuItems(confirmed, undefined)).toEqual([
|
||||
{ key: 'mark_detail', label: 'Сделать расшифровкой' },
|
||||
{ key: 'change_code', label: 'Сменить код' },
|
||||
{ key: 'unconfirm_code', label: 'Снять подтверждение кода' },
|
||||
]);
|
||||
});
|
||||
it('при действии последним — «Отменить изменение строки»', () => {
|
||||
const items = lineMenuItems(row('l-u7'), { kind: 'detail' });
|
||||
expect(items.map((i) => i).pop()).toEqual({ key: 'reset', label: 'Отменить изменение строки' });
|
||||
});
|
||||
it('шаблонная строка без id — пусто', () => {
|
||||
expect(keys(row('code-1250'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLineMenu', () => {
|
||||
it('confirm_code → confirm true', () => {
|
||||
expect(applyLineMenu(row('l-1230'), 'confirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: true } });
|
||||
expect(applyLineMenu(row('l-1230'), 'unconfirm_code', {})).toEqual({ 'l-1230': { kind: 'confirm', confirmed: false } });
|
||||
});
|
||||
it('assign_code / change_code / mark_detail', () => {
|
||||
expect(applyLineMenu(row('l-d2'), 'assign_code', {})['l-d2']).toEqual({ kind: 'code', code: '' });
|
||||
expect(applyLineMenu(row('l-1230'), 'change_code', {})['l-1230']).toEqual({ kind: 'code', code: '1230' });
|
||||
expect(applyLineMenu(row('l-u7'), 'mark_detail', {})['l-u7']).toEqual({ kind: 'detail' });
|
||||
});
|
||||
it('reset убирает ключ, новое действие заменяет прежнее, вход не мутируется', () => {
|
||||
const before: Record<string, LineAction> = { 'l-u7': { kind: 'detail' }, other: { kind: 'detail' } };
|
||||
expect(applyLineMenu(row('l-u7'), 'reset', before)).toEqual({ other: { kind: 'detail' } });
|
||||
expect(applyLineMenu(row('l-1230'), 'confirm_code', { 'l-1230': { kind: 'code', code: '1231' } })['l-1230']).toEqual({
|
||||
kind: 'confirm',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(before['l-u7']).toEqual({ kind: 'detail' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('showsCodeInput', () => {
|
||||
it('unreadable / missing всегда; прочие — только при действии code', () => {
|
||||
expect(showsCodeInput(row('l-u7'), undefined)).toBe(true);
|
||||
expect(showsCodeInput(row('l-m8'), undefined)).toBe(true);
|
||||
expect(showsCodeInput(row('l-1230'), undefined)).toBe(false);
|
||||
expect(showsCodeInput(row('l-1230'), { kind: 'code', code: '1230' })).toBe(true);
|
||||
expect(showsCodeInput(row('l-d2'), { kind: 'code', code: '' })).toBe(true);
|
||||
});
|
||||
});
|
||||
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
58
src/widgets/DocumentReview/lineMenu.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
export type LineMenuKey = 'mark_detail' | 'assign_code' | 'change_code' | 'confirm_code' | 'unconfirm_code' | 'reset';
|
||||
|
||||
const LABELS: Record<LineMenuKey, string> = {
|
||||
mark_detail: 'Сделать расшифровкой',
|
||||
assign_code: 'Назначить код',
|
||||
change_code: 'Сменить код',
|
||||
confirm_code: 'Подтвердить код',
|
||||
unconfirm_code: 'Снять подтверждение кода',
|
||||
reset: 'Отменить изменение строки',
|
||||
};
|
||||
|
||||
/** Пункты меню строки (P6-7). Строка без id (достроенная из шаблона) не меняется — меню пусто. */
|
||||
export function lineMenuItems(row: GridRow, action: LineAction | undefined): { key: LineMenuKey; label: string }[] {
|
||||
if (row.lineId === null) return [];
|
||||
const keys: LineMenuKey[] = [];
|
||||
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') {
|
||||
keys.push('mark_detail');
|
||||
} else if (row.lineStatus === 'detail') {
|
||||
keys.push('assign_code');
|
||||
} else if (row.origin === 'source') {
|
||||
keys.push('mark_detail', 'change_code');
|
||||
if (row.isConfirmed) keys.push('unconfirm_code');
|
||||
else if (row.needsReview) keys.push('confirm_code');
|
||||
}
|
||||
if (action && keys.length > 0) keys.push('reset');
|
||||
const items = keys.map((key) => ({ key, label: key === 'mark_detail' && row.lineStatus !== 'ok' ? 'Это расшифровка' : LABELS[key] }));
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Новый набор действий строк после выбора пункта; исходный объект не меняется. */
|
||||
export function applyLineMenu(row: GridRow, key: LineMenuKey, lines: Record<string, LineAction>): Record<string, LineAction> {
|
||||
const id = row.lineId;
|
||||
if (id === null) return lines;
|
||||
const { [id]: _removed, ...rest } = lines;
|
||||
switch (key) {
|
||||
case 'reset':
|
||||
return rest;
|
||||
case 'mark_detail':
|
||||
return { ...rest, [id]: { kind: 'detail' } };
|
||||
case 'assign_code':
|
||||
return { ...rest, [id]: { kind: 'code', code: '' } };
|
||||
case 'change_code':
|
||||
return { ...rest, [id]: { kind: 'code', code: row.code ?? '' } };
|
||||
case 'confirm_code':
|
||||
return { ...rest, [id]: { kind: 'confirm', confirmed: true } };
|
||||
default:
|
||||
return { ...rest, [id]: { kind: 'confirm', confirmed: false } };
|
||||
}
|
||||
}
|
||||
|
||||
/** Поле кода: у строк без кода — всегда, у остальных — когда выбрано действие «код». */
|
||||
export function showsCodeInput(row: GridRow, action: LineAction | undefined): boolean {
|
||||
if (row.lineStatus === 'unreadable' || row.lineStatus === 'missing') return true;
|
||||
return action?.kind === 'code';
|
||||
}
|
||||
160
src/widgets/DocumentReview/statementDraft.test.ts
Normal file
160
src/widgets/DocumentReview/statementDraft.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
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>): StatementDraft => ({ ...emptyDraft(), ...part });
|
||||
const build = (part: Partial<StatementDraft>) => buildStatementPatch(draftOf(part), ctx);
|
||||
const bodyOf = (part: Partial<StatementDraft>) => {
|
||||
const result = build(part);
|
||||
if (!result.ok) throw new Error(`ожидали тело, получили ошибки: ${JSON.stringify(result.errors)}`);
|
||||
return result.body;
|
||||
};
|
||||
const errorsOf = (part: Partial<StatementDraft>) => {
|
||||
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('подтверждение ячейки без id значения', () => {
|
||||
it('пропускается молча (P6-6)', () => {
|
||||
const row = rows.find((r) => r.key === 'l-u7');
|
||||
if (!row?.cells.c1) throw new Error('нет ячейки в фикстуре');
|
||||
const noId = { ...row, cells: { ...row.cells, c1: { ...row.cells.c1, id: null } } };
|
||||
const result = buildStatementPatch(draftOf({ confirms: { [cellKey('l-u7', 'c1')]: true } }), {
|
||||
rows: rows.map((r) => (r === row ? noId : r)),
|
||||
header: statement.header,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, body: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('действия строки', () => {
|
||||
it('код, расшифровка, подтверждение кода', () => {
|
||||
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]);
|
||||
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: ' ООО Ромашка-2 ' } })?.header).toEqual({ organization_name: 'ООО Ромашка-2' });
|
||||
});
|
||||
it('значение, равное текущему (после обрезки), не отправляется', () => {
|
||||
expect(bodyOf({ header: { organization_name: ' ООО Ромашка ' } })).toBeNull();
|
||||
expect(bodyOf({ header: { inn: '7700000000', units: ' тыс. руб. ' } })).toBeNull();
|
||||
});
|
||||
it('введено и стёрто — тела нет', () => {
|
||||
expect(bodyOf({ header: { inn: ' ', organization_name: '' } })).toBeNull();
|
||||
});
|
||||
it('дата из needs_review_fields с тем же значением — подтверждение, уходит', () => {
|
||||
expect(bodyOf({ header: { report_date: '2025-09-30' } })?.header).toEqual({ report_date: '2025-09-30' });
|
||||
const noReview = { rows, header: { ...statement.header, needs_review_fields: [] } };
|
||||
const res = buildStatementPatch(draftOf({ header: { report_date: '2025-09-30' } }), noReview);
|
||||
expect(res).toEqual({ ok: true, body: null });
|
||||
});
|
||||
it('длиннее 1000 символов — ошибка header', () => {
|
||||
expect(errorsOf({ header: { organization_name: 'я'.repeat(1001) } })[0]).toMatchObject({ kind: 'header', key: 'organization_name' });
|
||||
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);
|
||||
});
|
||||
});
|
||||
139
src/widgets/DocumentReview/statementDraft.ts
Normal file
139
src/widgets/DocumentReview/statementDraft.ts
Normal file
@ -0,0 +1,139 @@
|
||||
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<Record<HeaderFieldKey, string>> & { confirm_report_date?: boolean };
|
||||
|
||||
/** Все несохранённые правки аналитика; уходят одним PATCH. */
|
||||
export interface StatementDraft {
|
||||
/** cellKey → введённый текст суммы */
|
||||
amounts: Record<string, string>;
|
||||
/** cellKey → подтверждена ли ячейка */
|
||||
confirms: Record<string, boolean>;
|
||||
/** lineId → действие над строкой */
|
||||
lines: Record<string, LineAction>;
|
||||
/** columnId → введённая дата колонки */
|
||||
columns: Record<string, string>;
|
||||
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<string>();
|
||||
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);
|
||||
// у достроенной ячейки (нет id значения) подтверждать нечего: ядро ответило бы UNKNOWN_CELL — пропускаем молча
|
||||
if (row?.lineId && row.cells[columnId]?.id) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
|
||||
}
|
||||
|
||||
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)
|
||||
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<StatementPatch['header']> = {};
|
||||
for (const field of ['organization_name', 'inn', 'report_date', 'units'] as const) {
|
||||
const text = draft.header[field]?.trim();
|
||||
if (!text) continue;
|
||||
const current = field === 'organization_name' ? ctx.header.organization_name : ctx.header[field].value;
|
||||
// То же значение не шлём; исключение — дата из needs_review_fields: там то же значение и есть подтверждение (P6-11)
|
||||
const confirmsDate = field === 'report_date' && (ctx.header.needs_review_fields ?? []).includes('report_date');
|
||||
if (text === current?.trim() && !confirmsDate) continue;
|
||||
header[field] = text;
|
||||
}
|
||||
if (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 };
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
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> = {}): ValueOut => ({
|
||||
id,
|
||||
@ -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,38 +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 });
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -129,6 +152,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 = [
|
||||
@ -141,8 +173,16 @@ 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']);
|
||||
});
|
||||
it('подстрока с действием над строкой в черновике не считается пустой', () => {
|
||||
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
|
||||
expect([...emptySublineKeys(rows, {}, { '11101': { kind: 'confirm', confirmed: true } })]).toEqual(['11102']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,27 @@
|
||||
import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api';
|
||||
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
|
||||
import type { LineAction } from './statementDraft';
|
||||
|
||||
export type GridRow = {
|
||||
key: string;
|
||||
lineId: string | null;
|
||||
code: string | null;
|
||||
/** line_name — наименование из файла */
|
||||
name: string | null;
|
||||
/** справочное имя строки по коду редакции */
|
||||
referenceName: string | null;
|
||||
origin: 'source' | 'template';
|
||||
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
|
||||
parentCode: string | null;
|
||||
lineStatus: LineStatus;
|
||||
/** порядковый номер строки в файле; null у достроенной и созданной правкой */
|
||||
rowIndex: number | null;
|
||||
parentRowIndex: number | null;
|
||||
/** код как распознан в файле (например «ИЧ,250»), даже если `code` не распознан */
|
||||
codeText: string | null;
|
||||
codeConfidence: string | null;
|
||||
needsReview: boolean;
|
||||
isConfirmed: boolean;
|
||||
codeModified: boolean;
|
||||
cells: Record<string, ValueOut | undefined>;
|
||||
};
|
||||
|
||||
@ -26,21 +40,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}`;
|
||||
@ -49,30 +74,29 @@ export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue
|
||||
const hasValue = (value: ValueOut | undefined) => Boolean(value) && ((value?.amount ?? '') !== '' || (value?.string_value ?? '') !== '');
|
||||
|
||||
/**
|
||||
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки в черновике.
|
||||
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки (суммы или действия над строкой) в черновике.
|
||||
* Ноль — значение, такая строка не пуста. Только показ: данные и PATCH не затрагиваются.
|
||||
*/
|
||||
export function emptySublineKeys(rows: GridRow[], draft: Record<string, string>): Set<string> {
|
||||
export function emptySublineKeys(
|
||||
rows: GridRow[],
|
||||
draft: Record<string, string>,
|
||||
lineActions: Record<string, LineAction> = {},
|
||||
): Set<string> {
|
||||
const edited = new Set(Object.keys(draft).map((key) => key.slice(0, key.lastIndexOf(':'))));
|
||||
return new Set(
|
||||
rows.filter((row) => row.parentCode !== null && !edited.has(row.key) && !Object.values(row.cells).some(hasValue)).map((row) => row.key),
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.parentCode !== null &&
|
||||
row.lineStatus !== 'detail' &&
|
||||
!edited.has(row.key) &&
|
||||
!(row.lineId !== null && row.lineId in lineActions) &&
|
||||
!Object.values(row.cells).some(hasValue),
|
||||
)
|
||||
.map((row) => row.key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */
|
||||
export function draftToPatch(draft: Record<string, string | null>, 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) {
|
||||
|
||||
125
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
125
src/widgets/FilesDrawer/FileWarningsPopover.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { Popover, Spin, Tag } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { getDocument } from '../../services/api/documents';
|
||||
import type { IssueOut, Severity } from '../../types/api';
|
||||
import { severityLabel } from '../../utils/labels';
|
||||
import { SeverityIcon } from '../DocumentReview/IssuesPanel';
|
||||
import type { DocumentRow } from './documentRows';
|
||||
import { warningText } from './fileWarnings';
|
||||
|
||||
type IssuesState = { status: 'loading' } | { status: 'error' } | { status: 'done'; issues: IssueOut[] };
|
||||
|
||||
const tagColor = (s: Severity) => (s === 'error' ? 'error' : 'processing');
|
||||
|
||||
function DocumentIssues({ state }: { state: IssuesState | null }) {
|
||||
if (state === null || state.status === 'loading') return <Spin size='small' />;
|
||||
if (state.status === 'error') return <span style={{ color: '#cf1322' }}>Не удалось загрузить</span>;
|
||||
if (state.issues.length === 0) return <span style={{ color: '#8c8c8c' }}>Нет</span>;
|
||||
return (
|
||||
<>
|
||||
{state.issues.map((i) => (
|
||||
<div key={i.id}>
|
||||
<Tag color={tagColor(i.severity)}>{severityLabel(i.severity)}</Tag>
|
||||
{i.message}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ключи для строк, которые могут повторяться: к тексту добавляется порядковый номер повтора (индекс массива в key линтер запрещает). */
|
||||
function uniqueKeys(list: string[]): { key: string; text: string }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return list.map((text) => {
|
||||
const n = (seen.get(text) ?? 0) + 1;
|
||||
seen.set(text, n);
|
||||
return { key: `${text}-${n}`, text };
|
||||
});
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Значок с числом предупреждений под плашкой статуса; по клику — подробности (файл, обработка, документ). */
|
||||
export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { count: number; level: Severity } | null }) {
|
||||
const [issues, setIssues] = useState<IssuesState | null>(null);
|
||||
// номер текущего открытия: ответ от прошлого открытия или после закрытия игнорируется
|
||||
const requestNo = useRef(0);
|
||||
|
||||
if (!badge) return null;
|
||||
|
||||
// предупреждения документа грузим один раз за открытие: при закрытии сбрасываем, повторное открытие запрашивает заново
|
||||
const onOpenChange = (open: boolean) => {
|
||||
requestNo.current += 1;
|
||||
const mine = requestNo.current;
|
||||
if (!open) {
|
||||
setIssues(null);
|
||||
return;
|
||||
}
|
||||
if (!row.documentId) return;
|
||||
setIssues({ status: 'loading' });
|
||||
getDocument(row.documentId)
|
||||
.then((d) => mine === requestNo.current && setIssues({ status: 'done', issues: d.issues }))
|
||||
.catch(() => mine === requestNo.current && setIssues({ status: 'error' }));
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
// клики внутри поповера всплывают по дереву React до onClick строки таблицы и открывали бы документ
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{row.fileWarnings.length > 0 && (
|
||||
<Section title='Предупреждения файла'>
|
||||
{row.fileWarnings.map((w, i) => (
|
||||
<div key={`${w.code}-${i}`}>
|
||||
<Tag color={tagColor(w.severity)}>{severityLabel(w.severity)}</Tag>
|
||||
{warningText(w)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{row.mappingWarnings.length > 0 && (
|
||||
<Section title='Предупреждения обработки'>
|
||||
{uniqueKeys(row.mappingWarnings).map((w) => (
|
||||
<div key={w.key}>
|
||||
<Tag color={tagColor('info')}>{severityLabel('info')}</Tag>
|
||||
{w.text}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{row.documentId && (
|
||||
<Section title='Предупреждения документа'>
|
||||
<DocumentIssues state={issues} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover trigger='click' title='Предупреждения' content={content} onOpenChange={onOpenChange}>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={`Предупреждения: ${badge.count}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'nowrap',
|
||||
color: badge.level === 'error' ? '#cf1322' : '#1677ff',
|
||||
}}>
|
||||
<SeverityIcon severity={badge.level} /> {badge.count}
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,8 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { CheckResult, FileOut, FileType } from '../../types/api';
|
||||
import { type DocumentRow, type RowStatus, buildDocumentRows } from './documentRows';
|
||||
import { FileWarningsPopover } from './FileWarningsPopover';
|
||||
import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const STATUS_STYLE: Record<RowStatus, { color: string; background: string; border: string }> = {
|
||||
success: { color: '#237804', background: '#f6ffed', border: '#b7eb8f' },
|
||||
@ -94,7 +95,17 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
||||
</>
|
||||
) : null,
|
||||
},
|
||||
{ title: head('Статус'), key: 'status', width: 160, render: (_, r) => <StatusPill row={r} /> },
|
||||
{
|
||||
title: head('Статус'),
|
||||
key: 'status',
|
||||
width: 160,
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }}>
|
||||
<StatusPill row={r} />
|
||||
<FileWarningsPopover row={r} badge={rowBadge(r)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && files.length === 0) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DocumentBrief, FileOut } from '../../types/api';
|
||||
import { buildDocumentRows } from './documentRows';
|
||||
import { buildDocumentRows, rowBadge } from './documentRows';
|
||||
|
||||
const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
|
||||
id: 'd1',
|
||||
@ -14,6 +14,7 @@ const doc = (over: Partial<DocumentBrief> = {}): DocumentBrief => ({
|
||||
counterparty_check: null,
|
||||
locator: null,
|
||||
comments_count: 0,
|
||||
issue_counts: { error: 0, info: 0 },
|
||||
...over,
|
||||
});
|
||||
|
||||
@ -115,3 +116,72 @@ describe('buildDocumentRows', () => {
|
||||
expect(rows[1].subtitle.endsWith(' · вне консолидации')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const jsonFailed = { code: 'JSON_FAILED', severity: 'error' as const, message: 'm', locator: 'file:x_ofr.json' };
|
||||
const job = (over: Partial<NonNullable<FileOut['job']>> = {}): FileOut['job'] => ({
|
||||
id: 'j',
|
||||
status: 'DONE',
|
||||
error_code: null,
|
||||
error_message: null,
|
||||
mapping_error: null,
|
||||
warnings: [],
|
||||
mapping_warnings: [],
|
||||
finished_at: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildDocumentRows: предупреждения', () => {
|
||||
it('предупреждения файла попадают в обе строки файла', () => {
|
||||
const rows = buildDocumentRows([
|
||||
file({ job: job({ warnings: [jsonFailed], mapping_warnings: ['w'] }), documents: [doc({ id: 'a' }), doc({ id: 'b' })] }),
|
||||
]);
|
||||
expect(rows.map((r) => r.fileWarnings.length)).toEqual([1, 1]);
|
||||
expect(rows[0].mappingWarnings).toEqual(['w']);
|
||||
});
|
||||
|
||||
it('файл ERROR без документов', () => {
|
||||
const rows = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })]);
|
||||
expect(rows[0].fileWarnings).toEqual([jsonFailed]);
|
||||
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
|
||||
});
|
||||
|
||||
it('job null — пустые списки; старый формат строк не ломает', () => {
|
||||
expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] });
|
||||
const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]);
|
||||
expect(legacy[0].fileWarnings[0]).toMatchObject({ code: 'LEGACY_WARNING', message: 'старое' });
|
||||
});
|
||||
|
||||
it('ядро без issue_counts (до v5.19) — нули, не падает', () => {
|
||||
const old = doc() as Partial<DocumentBrief>;
|
||||
old.issue_counts = undefined;
|
||||
const rows = buildDocumentRows([file({ documents: [old as DocumentBrief] })]);
|
||||
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
|
||||
});
|
||||
|
||||
it('issue_counts документа переносятся в issueCounts', () => {
|
||||
const rows = buildDocumentRows([file({ documents: [doc({ issue_counts: { error: 1, info: 3 } })] })]);
|
||||
expect(rows[0].issueCounts).toEqual({ error: 1, info: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowBadge', () => {
|
||||
const row = (fileWarnings: unknown[], counts: { error: number; info: number }) =>
|
||||
buildDocumentRows([file({ job: job({ warnings: fileWarnings as never }), documents: [doc({ issue_counts: counts })] })])[0];
|
||||
|
||||
it('только счётчики info', () => {
|
||||
expect(rowBadge(row([], { error: 0, info: 2 }))).toEqual({ count: 2, level: 'info' });
|
||||
});
|
||||
it('ошибка файла + info документа', () => {
|
||||
expect(rowBadge(row([jsonFailed], { error: 0, info: 1 }))).toEqual({ count: 2, level: 'error' });
|
||||
});
|
||||
it('error документа без файла', () => {
|
||||
expect(rowBadge(row([], { error: 1, info: 0 }))).toMatchObject({ level: 'error' });
|
||||
});
|
||||
it('файл без документов: считаются предупреждения файла', () => {
|
||||
const r = buildDocumentRows([file({ status: 'ERROR', job: job({ warnings: [jsonFailed] }) })])[0];
|
||||
expect(rowBadge(r)).toEqual({ count: 1, level: 'error' });
|
||||
});
|
||||
it('нули — null', () => {
|
||||
expect(rowBadge(row([], { error: 0, info: 0 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api';
|
||||
import type { CheckResult, DocumentStatus, FileOut, FileType, FileWarningOut, IssueCountsOut, Severity } from '../../types/api';
|
||||
import { formatMoscowDate } from '../../utils/dates';
|
||||
import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels';
|
||||
|
||||
import { toFileWarnings, warningsLevel } from './fileWarnings';
|
||||
|
||||
export type RowStatus = 'success' | 'warning' | 'processing' | 'error';
|
||||
|
||||
export interface DocumentRow {
|
||||
@ -18,6 +20,19 @@ export interface DocumentRow {
|
||||
statusLabel: string;
|
||||
errorReason: string | null;
|
||||
linkable: boolean;
|
||||
/** предупреждения разбора файла (общие для всех документов файла) */
|
||||
fileWarnings: FileWarningOut[];
|
||||
mappingWarnings: string[];
|
||||
/** счётчики issues документа; у строки файла без документов — нули */
|
||||
issueCounts: IssueCountsOut;
|
||||
}
|
||||
|
||||
/** Значок у статуса (P6-12): число предупреждений и цвет уровня; ничего нет — null. */
|
||||
export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null {
|
||||
const count = row.fileWarnings.length + row.issueCounts.error + row.issueCounts.info;
|
||||
if (count === 0) return null;
|
||||
const level = warningsLevel(row.fileWarnings) === 'error' || row.issueCounts.error > 0 ? 'error' : 'info';
|
||||
return { count, level };
|
||||
}
|
||||
|
||||
const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']);
|
||||
@ -41,6 +56,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
const extra = labelFor?.(f) ?? null;
|
||||
const name = f.original_name ?? 'без имени';
|
||||
const date = formatMoscowDate(f.uploaded_at);
|
||||
const fileWarnings = toFileWarnings(f.job?.warnings);
|
||||
const mappingWarnings = f.job?.mapping_warnings ?? [];
|
||||
if (f.documents.length === 0) {
|
||||
const failed = f.status === 'ERROR';
|
||||
return [
|
||||
@ -55,6 +72,9 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
statusLabel: failed ? 'Ошибка' : 'Обработка',
|
||||
errorReason: errorReason(f),
|
||||
linkable: false,
|
||||
fileWarnings,
|
||||
mappingWarnings,
|
||||
issueCounts: { error: 0, info: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -77,6 +97,10 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
||||
statusLabel: documentStatusLabel(d.status),
|
||||
errorReason: null,
|
||||
linkable: LINKABLE.has(d.document_type),
|
||||
fileWarnings,
|
||||
mappingWarnings,
|
||||
// ядро без v5.19 не присылает issue_counts — считаем, что замечаний нет
|
||||
issueCounts: d.issue_counts ?? { error: 0, info: 0 },
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
51
src/widgets/FilesDrawer/fileWarnings.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toFileWarnings, warningText, warningsLevel } from './fileWarnings';
|
||||
|
||||
describe('toFileWarnings', () => {
|
||||
it('старый формат: строка становится LEGACY_WARNING', () => {
|
||||
expect(toFileWarnings(['sheet=ОСВ 60 строки без родителя'])).toEqual([
|
||||
{ code: 'LEGACY_WARNING', severity: 'info', message: 'sheet=ОСВ 60 строки без родителя', locator: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('смесь: мусор отбрасывается, объект без locator получает null', () => {
|
||||
const res = toFileWarnings([
|
||||
'a',
|
||||
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
|
||||
5,
|
||||
{ code: 'X' },
|
||||
{ code: 'Y', severity: 'warn', message: 'z' },
|
||||
]);
|
||||
expect(res).toEqual([
|
||||
{ code: 'LEGACY_WARNING', severity: 'info', message: 'a', locator: null },
|
||||
{ code: 'JSON_FAILED', severity: 'error', message: 'm', locator: 'file:x_ofr.json' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('объект без locator — locator null', () => {
|
||||
expect(toFileWarnings([{ code: 'C', severity: 'info', message: 'm' }])[0].locator).toBeNull();
|
||||
});
|
||||
|
||||
it('не массив — пустой список', () => {
|
||||
expect(toFileWarnings('строка')).toEqual([]);
|
||||
expect(toFileWarnings(null)).toEqual([]);
|
||||
expect(toFileWarnings(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningsLevel', () => {
|
||||
it('error важнее info', () => {
|
||||
expect(warningsLevel([{ severity: 'info' }, { severity: 'error' }])).toBe('error');
|
||||
expect(warningsLevel([{ severity: 'info' }])).toBe('info');
|
||||
});
|
||||
it('пусто — null', () => {
|
||||
expect(warningsLevel([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningText', () => {
|
||||
it('с locator и без', () => {
|
||||
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: 'file:x_ofr.json' })).toBe('m — file:x_ofr.json');
|
||||
expect(warningText({ code: 'C', severity: 'error', message: 'm', locator: null })).toBe('m');
|
||||
});
|
||||
});
|
||||
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
34
src/widgets/FilesDrawer/fileWarnings.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { FileWarningOut, Severity } from '../../types/api';
|
||||
|
||||
/**
|
||||
* Приводит `job.warnings` к структурам (P6-13): старые строки становятся `LEGACY_WARNING` уровня info,
|
||||
* записи без code/message/корректного severity и любые прочие значения отбрасываются.
|
||||
*/
|
||||
export function toFileWarnings(value: unknown): FileWarningOut[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: FileWarningOut[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
result.push({ code: 'LEGACY_WARNING', severity: 'info', message: item, locator: null });
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
const w = item as Record<string, unknown>;
|
||||
if (typeof w.code === 'string' && typeof w.message === 'string' && (w.severity === 'error' || w.severity === 'info')) {
|
||||
result.push({
|
||||
code: w.code,
|
||||
severity: w.severity,
|
||||
message: w.message,
|
||||
locator: typeof w.locator === 'string' ? w.locator : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Высший уровень списка: error, если есть хоть одно; пустой список — null. */
|
||||
export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null {
|
||||
if (list.length === 0) return null;
|
||||
return list.some((w) => w.severity === 'error') ? 'error' : 'info';
|
||||
}
|
||||
|
||||
export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message);
|
||||
Loading…
x
Reference in New Issue
Block a user