# План 3 — arm-core: хранение v5.19, правка формы, приём контракта 2.0. План реализации > **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (рекомендуется: в каждой > части больше 5 задач) или superpowers:executing-plans. Шаги размечены чекбоксами (`- [ ]`). Контроллер SDD — фоновый > сабагент на модели sonnet; исполнители — по SDD Model Selection (`~/.claude/CLAUDE.md`, «Исполнение»). **Goal:** ядро хранит формы ББ/ОФР по DBML v5.19 как пришли (строки без кода, повторы кодов, `needs_review`, `document_issue`), читает их «по коду» через один агрегат, даёт аналитику разобрать строки и подтвердить ячейки и коды через `PATCH …/statement`, принимает результаты `1.x` и `2.0` и пересчитывает форму после правки общим кодом `arm_statements`. **Architecture:** две части — два PR в RSHB/arm-core. **Часть А** не зависит от пакета: схема v5.19 (одна миграция `0007_dbml_v5_19`), скрипт очистки форм, `crm_id` ГСК/ГСЗ, модели контракта 2.0 и приём конвертов, словарь имён с синонимами, агрегат формы по коду, новые поля API, порядок 409, `PATCH` (`lines`, `columns`, `values[].confirmed`, `header.organization_name`), маппер `1.x` с полями v5.19 и чистые функции условия `RECOGNIZED`. До части Б §7 по-прежнему считает ядро на лету своей копией словаря (`api/rules/statement_checks.yaml`), но уже по агрегату. **Часть Б** стартует, когда копия пакета лежит в `api/arm_statements/` (план 2): адаптер пакета за протоколом `StatementsEngine`, маппер `2.0`, §7 при приёме `1.x`, пересчёт после правки точкой входа пакета, чтение §7 только из `document_issue`, удаление копии словаря §7 ядра. **Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 (async) + Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, ruff, mypy; `tea` (Gitea), OpenSpec, codegraph. **Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` (ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/arm-core/spec.md`; ещё `specs/recognition-contract/spec.md` (окно перехода, конверт, ack), `specs/statement-normalization/spec.md` («Шаг 8 — статус документа и конверта», «Точка входа пакета для пересчёта по сохранённым данным»), `proposal.md` («Порядок выкладки», шаг 3); модель — `model/data-model-v5.19.dbml` и `model/entities-v5.19.md` (против v5.18); контракты — `contracts/*.schema.json` (v2.0), `contracts/*.v1.schema.json`, `contracts/examples/`, `contracts/examples/v1/`; правила — `context/05-domain-rules.md` §7, §9.3, §9.5 пп. 2–4, §11.9; гайд фронта — `dev/frontend-api-guide.md` §5.6.1. Решения — `design.md` (Р-1…Р-30, S-1…S-74; для ядра важны Р-4, Р-8, Р-10, Р-16, Р-18…Р-20, Р-23…Р-25, Р-27, Р-28, Р-30). --- ## Место в работе и порядок | План | Что | Связь с этим планом | |---|---|---| | 0 | тест идентичности копий контракта по MAJOR (arm-core, tabular-parser, scan-gateway) | слит в arm-core **до** задачи A5 | | 1 | пакет `arm_statements` (новый репозиторий) | ядро вызывает только имена раздела «Публичный API пакета» плана 1 (сверено, раздел «Что ядро вызывает» ниже) | | 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR плана 2 в arm-core слит **до** части Б | | docs PR #117 | контракт v2.0, DBML v5.19, правила | слит **до** слияния PR части А (dbml-check, тест идентичности против `docs` main) | | 6 | arm-web: экран по новым полям, затем `sync-web` в `arm-core/web` | выкладывается вместе с ядром: `FileOut.job.warnings` меняет тип | **Выкладка на контур (шаг 3 `proposal.md`) — только А и Б вместе**, после очистки форм `purge_statements.py` и вместе с фронтом плана 6. PR части А сливается в `main`, но снимок контура с одной частью А не делается: до части Б ядро не принимает `2.0` и не пишет `document_issue` §7 (O-9). ## Разбивка на PR | | Часть А | Часть Б | |---|---|---| | Issue (RSHB/arm-core) | «Единая нормализация ББ/ОФР, ядро — часть А: DBML v5.19, очистка форм, агрегат по коду, правка формы (arm-ka-docs#116)» | «Единая нормализация ББ/ОФР, ядро — часть Б: приём контракта 2.0 и пересчёт общим кодом (arm-ka-docs#116)» | | Ветка | `feat/-normalization-core-a` | `feat/131-normalization-core-b` | | Зеркальный workspace | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/` | | Worktree arm-core | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/arm-core` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/arm-core` | | Change OpenSpec arm-core | `openspec/changes/unified-normalization-core-a/` | `openspec/changes/unified-normalization-core-b/` | | Задачи | A0–A13 (14) | B0–B6 (7) | | Старт | сразу (A5 — после плана 0) | после слияния PR части А и PR плана 2 в arm-core | Зеркальный workspace — как в плане 0 (`context/00-workspace-layout.md`, «Worktree вместо веток»): в каталоге части лежат worktree `arm-core/` (ветка фичи), **отсоединённый** worktree arm-ka-docs `docs/` (до слияния #117 — на `origin/feat/116-unified-normalization`, после — на `origin/main`; в нём не коммитится) и симлинки `fixtures`, `reference-old-arm`, `CLAUDE.md`, `.claude` на основной workspace. Так `../docs` из `arm-core/api` и `DBML ?= ../../docs/model/…` указывают на docs с v5.19 без переменных окружения. Порядок создания (`~/.claude/CLAUDE.md`, «Роли инструментов»), на примере части А: ```bash WS=/Users/andrey.epifancev/projects/awp-ca mkdir -p $WS/.wt/normalization-core-a git -C $WS/arm-core fetch origin git -C $WS/arm-core worktree add -b tmp/normalization-core-a $WS/.wt/normalization-core-a/arm-core origin/main git -C $WS/docs fetch origin git -C $WS/docs worktree add --detach $WS/.wt/normalization-core-a/docs origin/feat/116-unified-normalization for l in fixtures reference-old-arm CLAUDE.md .claude; do ln -s $WS/$l $WS/.wt/normalization-core-a/$l; done tea issues create --login home --repo RSHB/arm-core --title "<заголовок из таблицы>" --description "<цель, ссылка на план 3>" git -C $WS/.wt/normalization-core-a/arm-core branch -m feat/-normalization-core-a cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv ``` Все вызовы `codegraph_explore` — с `projectPath` = абсолютный путь worktree части. PR — `tea pulls create --login home --repo RSHB/arm-core`, со ссылкой на issue и на arm-ka-docs#116; в описание — вывод `make check-full`, таблица находок и «Rulings I made». Слияние — только по команде человека. ## Global Constraints - Источник истины по схеме — `model/data-model-v5.19.dbml`; `src.models.DBML_VERSION = "5.19"`; в `Makefile` `DBML ?= ../../docs/model/data-model-v5.19.dbml`; в `arm-core/CLAUDE.md` «Правила репозитория» — v5.19 и миграция `0007_dbml_v5_19`. - Одна ревизия Alembic `0007_dbml_v5_19` (`down_revision = "0006_dbml_v5_18"`); только добавляет колонки, таблицу, enum'ы, индексы и заполняет значения по умолчанию; §7 не считает; `arm_statements` не импортирует; схема — `from src.models.base import SCHEMA`, не литерал. - Строка, созданная правкой: `row_index IS NULL` ⇔ `origin = template`. Признак `line_code_original_value IS NULL` для `origin` и для «строка не из источника» нигде не используется. Маппер `1.x` и `2.0` всегда пишет `row_index`. - `line_code_original_value` и `header_source`, `*_original_value` правками не меняются. - Строки `detail`, `unreadable`, `missing` не входят в агрегат по коду, в §7, в межформенную проверку. - Деньги — `Decimal` / `numeric(18,2)`, в JSON — строки; `float` в домене нет. - Порядок блокировок: задание → файл → документы → advisory ГК → версии (по `id`); правка формы — «файл → документ», шапка перечитывается под блокировкой с `refresh=True`. - Ошибки — `DomainError`: 404 `NOT_FOUND`, 409 `ConflictError`, 422 `DomainValidationError`, тело `{"detail": {"code", "message", …}}`; 422 валидации тела pydantic — массив. Коды этого плана: 409 `LINE_CODE_TAKEN` (`line_id`, `line_code`), `LINES_UNRESOLVED` (`lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` (`document_id`), `CRM_ID_EXISTS`; 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` (все четыре — с `line_id`, `line_code` элемента `lines[]`, P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. Каталог кодов в `arm-core/README.md` («Ошибки API») дополняется в той задаче, что вводит код. - Один `PATCH …/statement` — один пересчёт и одна запись `audit_event` `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type = "document"`); запрос без изменений — 200 без аудита. Аудит — только через `services/audit.py`. - Коды предупреждений и записей `document_issue` — дословно: `RELATION_FAILED` (`relation`, `error`), `RELATION_SOFT` (`soft`, `info`), `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `STATEMENTS_VERSION_MISMATCH`, `MAPPING_WARNING`, `LEGACY_WARNING` (все — `info`, источник `core`); снимаемые правкой предупреждения сервиса — `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, `NO_ORGANIZATION_NO_INN`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; уровень файла `error` — по полю `severity`, не по списку кодов. - `import arm_statements` — только в `api/src/services/statements_engine.py` (часть Б); копию `api/arm_statements/` не править (её раскладывает `sync-statements`, план 2). - Копии контракта в `api/contracts/` побайтно равны docs: основные схемы v2.0 (у запроса — v1.0), `*.v1.schema.json`, `examples/*.json` (v2.0) и `examples/v1/*.json`; тест идентичности — правило плана 0. - `make check` (без Docker) зелёный после каждой задачи; перед PR — `make check-full` (нужен Docker: `make up` из `../docs/dev/`), его вывод — в описание PR. - Язык кода-комментариев, docstring, коммитов, issue, PR — русский; идентификаторы — английские, как в DBML. Docstring нового алгоритма называет § правил (`05-domain-rules.md`) или требование спеки `arm-core`. - Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 ` и `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. - Находки и решения — с маркерами (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`, `H-n`); решения этого плана — `P3-n` (ниже); исполнитель переносит их в `design.md` своего change'а arm-core (раздел «Decisions made without discussion»). ## Review Focus 1. **Две формы одного типа и периода в одном файле** (лист-продолжение, двойная печать) — оба документа хранятся с разными `file_ordinal`, ни один не `RECOGNIZED` и не автоподтверждён (Р-18); подтверждение второго после первого — 409 `DUPLICATE_FORM`; формы разных периодов — каждая по своим условиям; автозамена и пара межформенной проверки не падают на двух документах с одним ключом. Тесты — A9 (маппер `1.x`), A11 (409), B4 (маппер `2.0`). 2. **Повторы кода в агрегате** — суммы строк `ok` с одним кодом складываются по колонке (`100 + 649 = 749`), пустые не превращаются в 0 (`null`, если непустых нет), межформенная проверка берёт сумму (`500 + 300 = 800`), `parent_code` подстроки и шаблонная достройка видят код один раз. Тесты — A7. 3. **Строка без кода при назначении кода** — строка `unreadable` с суммами становится `ok`, `row_index` и `line_code_original_value = null` сохраняются (`origin = source`), её значения входят в агрегат и §7, родители всех строк `detail` пересчитаны; тот же код у другой строки источника — законный повтор, без 409. Тесты — A13. 4. **Шаблонная строка с суммами** (`row_index = null`) под назначаемым кодом — 409 `LINE_CODE_TAKEN`, и **ничего** из запроса не применено (ни другие элементы `lines`, ни `values`, ни шапка); пустая шаблонная строка удаляется, её `id` уходит из `document_issue.line_ids`. Тесты — A13. 5. **Старые задания со строковыми `warnings`** — `metrics.warnings` из строк, смесь строк и объектов, не список, объект без обязательных полей: `JobBrief.warnings` отдаёт структуры, строки — как `LEGACY_WARNING`, мусор пропускается, данные в БД не переписываются; условие `RECOGNIZED` не считает строки предупреждениями `error`. Тесты — A5, A10. --- ## Карта файлов **Часть А** | Файл | Ответственность | |---|---| | `api/src/models/statements.py` | колонки v5.19 у `financial_statement_*`, новая модель `DocumentIssue` | | `api/src/models/files.py` | `Document.file_ordinal`, частичный уникальный индекс типа, `RecognitionJob.statements_version` | | `api/src/models/directories.py` | `RelatedGroup.crm_id` (unique) | | `api/src/models/enums.py`, `api/src/models/__init__.py` | enum'ы v5.19 в `ENUMS`, `DBML_VERSION = "5.19"`, экспорт `DocumentIssue` | | `api/alembic/versions/0007_dbml_v5_19.py` | миграция (upgrade с заполнением, downgrade с отказами и журналом) | | `api/scripts/purge_statements.py` | очистка форм перед выкладкой | | `api/src/contract/result_v2.py`, `api/src/contract/message.py`, `api/src/contract/versions.py` | модели контракта 2.0, разбор по MAJOR | | `api/src/domain/statement_lines.py` | словарь имён: несколько имён на код | | `api/src/domain/statement_names.py` | совпадение наименований (§11.9) | | `api/src/domain/statement_aggregate.py` | агрегат формы по коду | | `api/src/domain/statement_layout.py` | порядок строк ответа, ближайший родитель `detail` (временный, до Б) | | `api/src/domain/statement_issues.py` | черновик записи `document_issue`, правила снятия предупреждений правкой | | `api/src/domain/statement_status.py` | условие `RECOGNIZED` (2.0, Р-18/Р-19), статус ОСВ (Р-28), проверка наименований | | `api/src/domain/statement_mapping.py` | черновики формы `1.x` с полями v5.19 | | `api/src/repositories/statements.py` | вставка v5.19, агрегат, `document_issue`, удаление строк | | `api/src/services/result_mapping.py` | маппер `1.x`: `file_ordinal`, Р-10, `MAPPING_WARNING` | | `api/src/services/recognition_exchange.py` | приём ack и конвертов `1.x`/`2.0`, `metrics.warnings` структурами | | `api/src/services/documents.py` | `JobBrief.warnings`, `DocumentOut.issues`, порядок 409 | | `api/src/services/statements.py`, `api/src/services/statement_line_edits.py` | `PATCH`, блокеры подтверждения, пересчёт | | `api/src/services/related_groups.py`, `api/src/common/constraint_conflicts.py` | `crm_id` ГСК/ГСЗ | | `api/src/schemas/statements.py`, `files.py`, `documents.py`, `directories.py` | DTO | **Часть Б** | Файл | Ответственность | |---|---| | `api/src/services/statements_engine.py` | протокол `StatementsEngine` и адаптер к копии `arm_statements` (единственный импорт пакета) | | `api/src/domain/stored_form.py` | сборка входа точки пересчёта из строк БД (чистая функция) | | `api/src/services/statement_recompute.py` | пересчёт после правки и при приёме `1.x`, запись результатов | | `api/src/domain/statement_mapping_v2.py` | черновики формы и ОСВ из результата `2.0` | | `api/src/services/result_mapping.py` | выбор маппера по MAJOR, маппер `2.0` | | удаляются: `api/rules/statement_checks.yaml`, §7-часть `api/src/domain/statement_checks.py` | копия словаря §7 ядра | --- ## Что ядро вызывает у пакета (часть Б) Источник истины по именам и типам — раздел «Публичный API пакета» плана 1 (`2026-10-18-plan-1-arm-statements.md`); здесь они не копируются. Ядро импортирует имена верхнего уровня `arm_statements` только в `api/src/services/statements_engine.py` и `api/src/domain/stored_form.py` (сборка входа пересчёта); остальной код работает с протоколом ниже. ```python # api/src/services/statements_engine.py — единственная точка вызова функций arm_statements from arm_statements import (DocStatus, DocumentType, Edition, FormVariant, PeriodKind, RecomputeResult, Rules, StoredForm, choose_edition, default_rules, document_status, recompute, resolve_relative, status_input_from_contract, statements_version) class StatementsEngine(Protocol): # тонкая обёртка: подмена в модульных тестах (FakeEngine), кэш правил на процесс @property def version(self) -> str | None: ... # statements_version(); нет файла VERSION — None def recompute(self, form: StoredForm) -> RecomputeResult: ... # recompute(form, rules) def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ... # document_status(status_input_from_contract(document)) — statementDocument результата 2.0 как dict def edition(self, document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective_date: datetime.date | None, form_variant: FormVariant | None) -> Edition | None: ... # choose_edition(document_type, codes, effective_date, rules, variant=form_variant) def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: ... # rules.code_orders.sequence(edition, document_type, form_variant); DISCLOSURE → () def editions(self) -> tuple[Edition, ...]: ... # rules.code_orders.editions — (RAS_66N, FSBU4) def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... # ABSOLUTE / NONE → None def tolerance(self) -> Decimal: ... # rules.checks.tolerance — Decimal("1"), для межформенной проверки ядра def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rules()), кэш на процесс ``` | Вызов ядра | Имя плана 1 | Где используется | |---|---|---| | `engine.recompute(form)` | `recompute(form: StoredForm, rules) -> RecomputeResult` | B2 (после `PATCH`), B3 (приём `1.x`) | | `engine.contract_document_status(doc)` | `document_status(status_input_from_contract(doc))` | B4 (страховка статуса результата `2.0`) | | `engine.edition(...)` | `choose_edition(document_type, codes, effective, rules, *, variant)` | B4 (редакция формы `2.0` для `CODE_NOT_IN_EDITION`) | | `engine.code_order(...)` | `default_rules().code_orders.sequence(edition, form, variant) -> tuple[str, ...]` | B4, B6 | | `engine.editions()` | `default_rules().code_orders.editions -> tuple[Edition, ...]` | B6 | | `engine.resolve_relative(...)` | `resolve_relative(kind, effective) -> date \| None` | B2 (правка `header.report_date`) | | `engine.tolerance()` | `default_rules().checks.tolerance -> Decimal` | B6 | | `engine.version` | `statements_version()` | B2 (`STATEMENTS_VERSION_MISMATCH`) | | `StoredForm`, `StoredColumn`, `StoredLine`, `StoredValue`, `StoredWarning` | одноимённые dataclass-ы пакета | B1 (`build_stored_form`) | | `FailedRelation`, `StatementChecks` | из `RecomputeResult.checks` | B2 (`relations_to_issues`) | `to_contract` / `warning_to_contract` ядру не нужны: ядро читает контракт, а не пишет его. `StatusInput` ядро не собирает — вход статуса из документа результата строит пакет (`status_input_from_contract`), поэтому страховка статуса считает ровно то, что посчитал сервис (план 1, P1-27). Соглашения на границе ядра и пакета (все — в `stored_form.py` / адаптере, остальной код ядра их не видит): - Перечисления пакета — `StrEnum` (подкласс `str`, значения — строки контракта и БД): строка БД превращается в `LineStatus(...)`, `DocumentType(...)`, `PeriodKind(...)`, `Severity(...)` при сборке `StoredForm`; `period_kind = NULL` в БД → `PeriodKind.NONE`. Обратно значения пишутся в БД как `str(...)` / `.value`. - Уверенности пакета — `float | None` (0–100), в БД — `Numeric(5,2)`: при сборке `float(confidence)`, `None` остаётся `None`. `StoredValue.is_recognized` — `bool`: `NULL` в БД читается как `False`. - `StoredColumn.column_index: int | None` (колонка без номера столбца допустима); ключи строки и колонки — `str(uuid)`. - `RecomputeResult.edition` и `engine.edition(...)` — `Edition | None`; `None` при нераспознанной редакции (нет даты и ничья): проверки по редакции (`CODE_NOT_IN_EDITION`) тогда не пишутся. Пары `codes` для `engine.edition` — строки `ok` результата по `row_index`: `(line_code, line_code_confidence == 100)` (у результата `2.0` кодов, правленых аналитиком, нет); пересчёт после правки берёт ту же функцию внутри `recompute` — редакция приёма и пересчёта совпадает. - `FailedRelation` несёт `column_index: int | None` и `column_key: str`; при пересчёте из БД `column_key` — это `StoredColumn.key`, то есть `str(column_id)`: колонка находится по ключу без обратного сопоставления по индексу. `expected` и `actual` — `Decimal | None` (`None` — цель или все члены пусты, план 1 P1-21); `document_issue.expected` / `actual` — `numeric(18,2)` без `NOT NULL`, `IssueDraft.expected` / `actual` — `Decimal | None`. Ключи хоста — `str(uuid)` строки и колонки (`key`, `column_key`, `parent_key`). --- ## Решения плана без обсуждения (`P3-n`) | Маркер | Решение | Обоснование | Цена ошибки | |---|---|---|---| | P3-1 | Миграция пишет `header_source` с ключами `inn`, `report_date`, `units`, `organization_name`: `text` — из `inn_original_value`, `report_date_original_value`, `units_original_value`, `organization_name`; `confidence` — `inn_confidence` у `inn`, у остальных `null` | спека: «из `*_original_value` и `inn_confidence`»; прочих полей в v5.18 сырыми нет | подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой | | P3-2 | `organization_name_modified` — `false` у всех форм (миграция и оба маппера), не `NULL` | как `inn_modified` в маппере 1.x; API отдаёт `bool` | одна строка `UPDATE` | | P3-3 | `needs_review_fields`: маппер `1.x` пишет `[]`, миграция оставляет `NULL`, чтение `NULL` = `[]` | спека миграции поле не заполняет | нет | | P3-4 | Маппер `1.x` тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары `period_year`, `period_quarter` получают `MAPPING_WARNING` «в файле есть другая форма <тип> за тот же период» и по D-6 — `NEEDS_CHECK` (аналог Р-18) | частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR | O-1: если владелец хочет прежний пропуск — вернуть ветку `continue` | | P3-5 | Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в `document_issue` документа листа (`warning`, `MAPPING_WARNING`, `info`, `core`) при любом MAJOR; строки в `metrics.mapping_warnings` остаются | спека: `DocumentOut.issues` ОСВ — «вместе с предупреждениями маппинга ядра этого листа» | нет | | P3-6 | `file_ordinal` объединённого документа `DISCLOSURE` / `OTHER` — индекс первого такого документа в `documents[]` | несозданный документ «номер не занимает» | нет | | P3-7 | `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` — одна запись на код документа (`line_ids` — все строки `ok` с кодом), пятизначные коды тоже проверяются; при приёме `1.x` проверки ядра уровня `info` не пишутся | спека для `1.x` перечисляет только §7 и `MAPPING_WARNING` | O-3 (поток `info` у пятизначных) | | P3-8 | После правки записи `core` `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH` и `STATEMENTS_VERSION_MISMATCH` пересчитываются (заменяются); `NAME_MISMATCH` не ставится строке с `code_fixed`; `MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются | код строки меняется правкой; страховка статуса — факт приёма | O-4 | | P3-9 | Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING` | FK значения на колонку; уникальность `(meta_id, column_index)` | нет | | P3-10 | Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH` | атомарность и предсказуемость | нет | | P3-11 | `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null` | спека: «первое имя кода в словаре»; `edition` ответа — по дате | нет (Р-29) | | P3-12 | Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется | решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет | архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ | | P3-13 | Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов | как у `check_dbml_vs_orm.py` | нет | | P3-14 | До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату | часть А должна быть самосогласованной | временный код удаляется в B6 | | P3-15 | Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox) | маппер читает файл результата; по контракту наборы одинаковы | нет | | P3-16 | Подтверждение кода (`lines[] {confirmed: true}`) ставит `is_confirmed = true` и `needs_review = false`, `line_code_confidence` и `line_code_modified` **не меняет**; назначение кода — `confidence = 100`, `line_code_modified = true` | иначе исходная уверенность теряется, а снятие подтверждения не может вернуть `needs_review` (гайд §5.6.1: «при `false` — пересчитывается»); аналогия с ячейкой (`is_modified` не ставится); подтверждено владельцем 18.10.2026 (Р-34), спека `arm-core`, §9.5 п. 9 и гайд §5.6.1 приведены к этому | нет | | P3-17 | Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале | спека «Ответ `GET …/statement`» | нет | | P3-18 | Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE` | спека: «изменение — аудит `GROUP_CHANGE`» | O-6 | | P3-19 | Ядро зовёт у пакета `choose_edition` (`engine.edition`) и `status_input_from_contract` (`engine.contract_document_status`) вместо сборки `StoredForm` ради редакции и своего `status_input_from_v2`; `to_contract` ядру не нужен | одно правило с сервисом и с `recompute` (план 1, P1-25, P1-27); пары `(код, уверенный)` — из строк `ok` результата по `row_index` | при ошибке сборки пар редакция приёма разойдётся с редакцией после правки — тест B1 сравнивает их | | P3-20 | `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло | фронт читает все уверенности одним способом (план 6, A8) | округление до сотых в подсказке; исходное число — в БД | | P3-21 | `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит | спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля | нет | | P3-22 | Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному | наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) | ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines` | | P3-23 | `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл | панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) | акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа | | P3-24 | Ошибки элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) несут в `detail` `line_id` и `line_code` первого неверного элемента; `LINE_CODE_REQUIRED` добавлен к четырём кодам запроса фронта | экран ставит ошибку у поля нужной строки, не угадывая (план 6, O-2); одно правило для всех ошибок элемента | при нескольких неверных элементах фронт видит только первый — как и прежде | ## Открытые вопросы (`O-n`) - **O-1.** Р-10 для результатов `1.x` (P3-4): хранить вторую форму того же типа и при `1.x` или пропускать, как раньше? План хранит. - **O-2.** Закрыт решением владельца 18.10.2026 (Р-34): подтверждение кода уверенность не меняет (P3-16); буква спеки `arm-core`, `05-domain-rules.md` §9.5 п. 9 и гайда §5.6.1 приведена к этому правилу. - **O-3.** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` у пятизначных подстрок: каждая подстрока Excel даёт `info`. Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными? - **O-4.** Пересчёт проверок ядра `info` после правки (P3-8) спека не описывает. - **O-5.** Закрыт решением владельца 18.10.2026 (Р-35): отказ только при верифицированной версии (P3-12); спека `arm-core` «Очистка форм перед выкладкой» п. 2 приведена к этому правилу, сценарий «Форма в версии на согласовании или отклонённой» добавлен. - **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`. - **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? - **O-8.** Закрыт 18.10.2026 сведением с планом 1 (его P1-21…P1-31): (а) типы `Rules.code_orders.sequence(...) -> tuple[str, ...]` (параметр `variant: FormVariant`, у `DISCLOSURE` — `()`), `.editions -> tuple[Edition, ...]`, `Rules.checks.tolerance -> Decimal` записаны в API пакета; (б) редакция без пересчёта — `choose_edition` (`engine.edition`, B4); (в) `resolve_relative(ABSOLUTE | NONE, …)` → `None`; (г) вход статуса из документа результата — `status_input_from_contract` пакета (`engine.contract_document_status`), `status_input_from_v2` не пишется. - **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы только через `sync-web` после плана 6 — выкладка ядра и фронта одним снимком. - **O-10.** Метрики `fields_*` ядро сейчас не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`): переводить на агрегат нечего. Подтвердить, что спека («метрики `fields_*`») не требует нового расчёта. --- ## Часть А ### Task A0: Change OpenSpec части А в arm-core **Files:** - Create: `openspec/changes/unified-normalization-core-a/proposal.md`, `design.md`, `tasks.md`, `specs/{data-model,osv-result-mapping,documents-api,primary-files,recognition-job-exchange,directories-api,statement-checks}/spec.md` **Interfaces:** - Produces: change, на который ссылаются коммиты и PR части А; `design.md` с разделом `## Decisions made without discussion` (туда переносятся `P3-n` части А и рулинги контроллера). - [ ] **Step 1:** `proposal.md` — ссылка на change docs (`unified-statement-normalization`, arm-ka-docs#116), перечень требований спеки `arm-core`, которые закрывает часть А (по таблице «Покрытие спеки» ниже), и что остаётся части Б. - [ ] **Step 2:** спеки-дельты: переносят в формулировках capability arm-core требования спеки `arm-core` docs из колонки «А» таблицы покрытия (MODIFIED у `data-model`, `osv-result-mapping`, `documents-api`, `primary-files`, `recognition-job-exchange`, `directories-api`; `statement-checks` — MODIFIED «§7 по агрегату по коду»). - [ ] **Step 3:** `openspec validate unified-normalization-core-a --strict` — без ошибок. - [ ] **Step 4: Commit** `openspec(core-a): change единой нормализации, часть А (arm-ka-docs#116)`. ### Task A1: Схема v5.19 — модели и upgrade миграции **Files:** - Modify: `api/src/models/statements.py`, `api/src/models/files.py`, `api/src/models/directories.py`, `api/src/models/enums.py`, `api/src/models/__init__.py`, `api/src/repositories/statements.py` (`delete_file_statements`), `api/tests/factories.py` (`make_document`), `Makefile`, `CLAUDE.md` - Create: `api/alembic/versions/0007_dbml_v5_19.py` - Test: `api/tests/test_migrations.py`, `api/tests/test_api_files_delete.py`, `api/tests/test_mapping_statements.py` **Interfaces:** - Produces (ORM, имена колонок — как в DBML v5.19, новые колонки — в конце класса в порядке `ADD COLUMN` миграции): - enum'ы `StatementLineStatus {ok, unreadable, missing, detail}`, `StatementFormVariant {full, simplified}`, `DocumentIssueKind {warning, relation, soft}`, `IssueSeverity {error, info}`, `IssueSource {service, core}` — в `ENUMS` под именами `statement_line_status`, `statement_form_variant`, `document_issue_kind`, `issue_severity`, `issue_source`; - `FinancialStatementLine`: `row_index: int | None`, `source_ref: str | None`, `line_status: StatementLineStatus` (`server_default 'ok'`), `line_code_text: str | None`, `parent_row_index: int | None`, `needs_review: bool` (`server_default false`), `is_confirmed: bool` (`server_default false`); индексы `ix_financial_statement_line_meta_id_line_code` (не уникален) и `uq_financial_statement_line_meta_id_source_ref` (`unique`, `postgresql_where=source_ref IS NOT NULL`); - `FinancialStatementValue`: `source_text`, `needs_review` (`false`), `is_confirmed` (`false`), `confirmed_by` (FK `app_user.id`), `confirmed_at`; - `FinancialStatementColumn`: `period_kind: str | None`, `label_confidence: Decimal | None` (`Numeric(5,2)`); - `FinancialStatementMeta`: `form_variant`, `needs_review_fields` (jsonb), `header_source` (jsonb), `organization_name_original_value`, `organization_name_modified`; - `Document.file_ordinal: int` (`SmallInteger`, `server_default 0`); `uq_document_file_type_account` — `unique`, `postgresql_nulls_not_distinct=True`, `postgresql_where=document_type NOT IN ('BALANCE', 'INCOME')`; `uq_document_stored_file_id_file_ordinal` (`stored_file_id`, `file_ordinal`, unique); - `RecognitionJob.statements_version: str | None`; `RelatedGroup.crm_id: str | None` (`unique=True`); - `DocumentIssue` (`document_issue`): поля DBML, `ix_document_issue_document_id`. - Produces (репозиторий): `delete_file_statements(session, file_id)` удаляет и `document_issue` документов файла (первым). - Produces (фабрика): `make_document(...)` без явного `file_ordinal` берёт следующий свободный номер в файле. - [ ] **Step 1: Write the failing test** — `test_upgrade_0007_backfills_v5_19_columns` в `test_migrations.py` (по образцу `test_upgrade_0006_backfills_consolidation_id`): на ревизии `0006_dbml_v5_18` вставить ЮЛ, файл, `INCOME` (`created_at` = t0) и `BALANCE` (t1 > t0), шапку ББ с `inn_original_value = "7701234567"`, `inn_confidence = 87`, `report_date_original_value = "30.09.2025"`, `units_original_value = "тыс. руб."`, `organization_name = "ООО Ромашка"`; колонки `c1` (`period_date = 2025-09-30`) и `c2` (`NULL`); строки `1600` и `1100` (оригинал = код) и `1250` (`line_code_original_value = NULL`, сумма 5); значения: `(conf 100, rec true, mod false)`, `(conf 80, rec true, mod false)`, `(conf 80, mod true)`, `(conf NULL, rec false)`, `(string_value "12 3")`; ГСК без `crm_id`. Затем `upgrade head` и проверки: ```python assert rows["1100"].row_index == 0 and rows["1600"].row_index == 1 and rows["1250"].row_index is None assert {r.line_status for r in rows.values()} == {"ok"} and not any(r.needs_review for r in rows.values()) assert [v.needs_review for v in values] == [False, True, False, True, False] assert value_by_text["12 3"].source_text == "12 3" assert period_kind == {"c1": "absolute", "c2": "none"} and meta.form_variant is None assert meta.header_source == { "inn": {"text": "7701234567", "confidence": 87}, "report_date": {"text": "30.09.2025", "confidence": None}, "units": {"text": "тыс. руб.", "confidence": None}, "organization_name": {"text": "ООО Ромашка", "confidence": None}, } assert meta.organization_name_original_value == "ООО Ромашка" and meta.organization_name_modified is False assert ordinals == {"INCOME": 0, "BALANCE": 1} and related_group.crm_id is None assert await count(conn, "document_issue") == 0 ``` И поведение схемы после upgrade (`test_v5_19_constraints`): вторая `BALANCE` в том же файле с `file_ordinal = 2` вставляется; вторая `DISCLOSURE` — `IntegrityError` (`uq_document_file_type_account`); две строки с одним кодом в форме — вставляются; две строки с одним непустым `source_ref` — `IntegrityError`; две с `source_ref = NULL` — вставляются; два документа файла с одним `file_ordinal` — `IntegrityError`. - [ ] **Step 2:** `make migration-cycle` — FAIL (ревизии `0007` нет). Константы теста: `HEAD = "0007_dbml_v5_19"`, `V5_18 = "0006_dbml_v5_18"`; офлайн-SQL теста `test_offline_sql_creates_schema_before_version_table` доходит до `0007`. - [ ] **Step 3: Implement** модели и `upgrade()` ревизии `0007_dbml_v5_19`: типы enum → колонки → заполнение → индексы (уникальные — после заполнения `row_index`/`file_ordinal`) → таблица `document_issue`. Заполнение — SQL ревизии: ```sql -- row_index: строки источника по возрастанию line_code внутри формы с 0; созданные правкой — NULL UPDATE {S}.financial_statement_line l SET row_index = n.rn FROM ( SELECT id, row_number() OVER (PARTITION BY meta_id ORDER BY line_code, id) - 1 AS rn FROM {S}.financial_statement_line WHERE line_code_original_value IS NOT NULL) n WHERE l.id = n.id; UPDATE {S}.financial_statement_value SET source_text = string_value, needs_review = NOT (is_modified OR (COALESCE(is_recognized, true) AND (confidence IS NULL OR confidence = 100))); UPDATE {S}.financial_statement_column SET period_kind = CASE WHEN period_date IS NULL THEN 'none' ELSE 'absolute' END; UPDATE {S}.document d SET file_ordinal = n.rn FROM ( SELECT id, row_number() OVER (PARTITION BY stored_file_id ORDER BY created_at, id) - 1 AS rn FROM {S}.document) n WHERE d.id = n.id; ``` `header_source` — `jsonb_build_object` по P3-1; `organization_name_original_value = organization_name`, `organization_name_modified = false` (P3-2). Переключить `Makefile` (`DBML ?= ../../docs/model/data-model-v5.19.dbml`), `DBML_VERSION = "5.19"`, `CLAUDE.md` («Правила репозитория»: v5.19, `0007_dbml_v5_19`). `make_document` — следующий свободный `file_ordinal` в файле. `delete_file_statements` — `DELETE document_issue WHERE document_id IN (документы файла)` первым запросом. - [ ] **Step 4:** добавить в `test_delete_file_with_statement_form` строку `document_issue` у документа формы перед удалением и проверку, что её нет после; в `test_repeated_form_mapping_is_idempotent` — строку `document_issue` до повторного маппинга и проверку, что её нет после. - [ ] **Step 5:** `make dbml-check` — пустой вывод, код 0. Если расхождение только в тексте `WHERE` из-за `\'` в note DBML — в `scripts/dbml_parity.py::_norm_where` снимать `\` перед кавычкой, тест в `tests/test_dbml_parity.py` (`document_type NOT IN (\'BALANCE\', \'INCOME\')` ≡ `document_type NOT IN ('BALANCE', 'INCOME')`). - [ ] **Step 6:** `make check && make migration-cycle` — PASS (в т. ч. `test_autogenerate_finds_nothing_after_upgrade`, `test_migrated_schema_equals_orm_create_all`). - [ ] **Step 7: Commit** `feat(models): DBML v5.19 — колонки форм, document_issue, file_ordinal, crm_id ГСК (миграция 0007)`. ### Task A2: Откат миграции v5.19 **Files:** - Modify: `api/alembic/versions/0007_dbml_v5_19.py` (`downgrade`) - Test: `api/tests/test_migrations.py` **Interfaces:** - Consumes: ревизия A1. - Produces: `downgrade()` — сначала проверки, потом DDL; при несовместимых данных `RuntimeError` с текстом `откат v5.19 невозможен: <причина>: <число>[; <причина>: <число>…]`, причины дословно: `повтор кода строки в форме` (число пар `(meta_id, line_code)` с повтором), `строки формы без кода`, `две формы одного типа в файле` (число пар `(stored_file_id, document_type)` у `BALANCE`/`INCOME` с повтором), `crm_id ГСК/ГСЗ задан`, `наименование организации правилось`. Журнал — `logging.getLogger("alembic.runtime.migration").warning("откат v5.19: удалены непустые значения — %s", …)` со счётчиками `<таблица>.<колонка>: N` (булевы — число `true`, `file_ordinal` — число `<> 0`, прочие — `IS NOT NULL`) и `document_issue: N`. - [ ] **Step 1: Write the failing tests** (по образцу `test_downgrade_one_step_fails_while_nu_rows_exist`): `test_downgrade_0007_refuses_duplicate_code` (форма с двумя строками `2410`), `…_refuses_null_code` (строка `unreadable` без кода), `…_refuses_two_forms_of_type` (две `BALANCE` в файле), `…_refuses_related_group_crm_id`, `…_refuses_modified_organization_name`. В каждом: ```python with pytest.raises(AssertionError) as error: await alembic(scratch_db_url, "downgrade", V5_18) assert "откат v5.19 невозможен: повтор кода строки в форме: 1" in str(error.value) assert await _scalars(conn, f"SELECT version_num FROM {SCHEMA}.alembic_version") == ["0007_dbml_v5_19"] assert await count(conn, "financial_statement_line") == lines_before # данные не изменены ``` `test_downgrade_0007_drops_signals_and_logs_counts`: две ячейки `is_confirmed = true`, одна запись `document_issue`, без несовместимых данных → откат проходит, в выводе alembic `financial_statement_value.is_confirmed: 2` и `document_issue: 1`; после отката колонок v5.19 нет, вставка второй строки с тем же кодом в форму — `IntegrityError`; `upgrade head` снова проходит. - [ ] **Step 2:** `make migration-cycle` — новые тесты FAIL. - [ ] **Step 3: Implement** `downgrade()`: пять `SELECT count(*)` до любого DDL; затем журнал; затем удаление таблицы `document_issue`, индексов и колонок v5.19, восстановление `uq_financial_statement_line_meta_id_line_code` (unique) и полного `uq_document_file_type_account` (`NULLS NOT DISTINCT`), удаление enum'ов. - [ ] **Step 4:** `make migration-cycle` — PASS (полный цикл `upgrade → downgrade base → upgrade` тоже). - [ ] **Step 5: Commit** `feat(migrations): откат v5.19 с отказом на несовместимых данных и журналом потерь`. ### Task A3: `crm_id` ГСК/ГСЗ **Files:** - Modify: `api/src/services/related_groups.py`, `api/src/schemas/directories.py`, `api/src/common/constraint_conflicts.py`, `README.md` («Ошибки API») - Test: `api/tests/test_api_related_group_crm_id.py` (переписать: сейчас проверяет «принимается и отбрасывается») **Interfaces:** - Consumes: `RelatedGroup.crm_id` (A1). - Produces: `RelatedGroupCreate.crm_id` / `RelatedGroupUpdate.crm_id` сохраняются (пустая строка после обрезки — `None`; явный `null` в PATCH очищает); `RelatedGroupOut.crm_id` и `RelatedGroupDetail.crm_id` — из БД; конфликт — 409 `CRM_ID_EXISTS` «ГСК/ГСЗ с таким crm_id уже существует» (проверка до вставки и запись `uq_related_group_crm_id` в `CONSTRAINTS` на гонку); аудит изменения — P3-18. - [ ] **Step 1: Write the failing tests:** `test_create_related_group_stores_crm_id` (`POST` с `crm_id = " 51-03-0001 "` → 201, `crm_id == "51-03-0001"`, `GET` деталей отдаёт его); `test_patch_related_group_crm_id_audited` (смена → событие `GROUP_CHANGE`, `before_value["crm_id"] is None`, `after_value["crm_id"] == "77-02-0002"`); `test_crm_id_taken_is_409` (вторая ГСЗ с тем же `crm_id`, в т. ч. в другой ГК → 409 `CRM_ID_EXISTS`, групп не прибавилось); `test_patch_crm_id_null_clears`. - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_related_group_crm_id.py -v` — FAIL. - [ ] **Step 3: Implement** — по образцу `CompanyGroupService._check_crm_id_free`; docstring схем — без «принимается и отбрасывается». - [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS. - [ ] **Step 5: Commit** `feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112)`. ### Task A4: Очистка форм перед выкладкой `purge_statements.py` **Files:** - Create: `api/scripts/purge_statements.py` - Modify: `README.md` (раздел «Выкладка v5.19: очистка форм») - Test: `api/tests/test_purge_statements.py` (маркер `integration`) **Interfaces:** - Consumes: `files_repo.delete_file_rows`, `files_repo.expire_s3_objects`, `statements_repo.delete_file_statements` (уже чистит `document_issue`, A1), `consolidation_documents.bound_versions` / `unbind_documents`, `AuditService.record`, `ex_snapshot` (`source_version_id`). - Produces: - `@dataclass PurgePlan`: `form_only_files: list[uuid.UUID]`, `mixed_files: list[uuid.UUID]`, `form_documents: int`, `confirmed_documents: int`, `unverified_bindings: int`, `blocking: list[BlockingVersion]` (`version_id`, `status`, `document_id`), `superseded_with_osv: list[uuid.UUID]` (файлы, заменённые удаляемым файлом, в которых есть ОСВ); - `async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan` — только чтение; - `async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None`; - `def render_report(plan: PurgePlan) -> str`; - `async def run(argv: Sequence[str]) -> int` — `--dry-run` (по умолчанию), `--apply`, `--include-mixed`; коды P3-13; запуск `cd api && .venv/bin/python -m scripts.purge_statements [--apply] [--include-mixed]`, одна транзакция. - [ ] **Step 1: Write the failing tests** (БД теста, фабрики `tests/factories.py`): - `test_dry_run_reports_and_changes_nothing`: файл только с ББ+ОФР (ББ `CONFIRMED`, привязан к версии `INITIATED`), смешанный файл (ОСВ + ББ), файл только с ОСВ → `run([])` → код 0; в отчёте строки `файлов только с формами: 1`, `смешанных файлов: 1`, `документов форм: 3`, `подтверждённых документов: 1`, `привязок к неверифицированным версиям: 1`; число строк всех таблиц не изменилось. - `test_apply_refuses_verified_binding`: ББ привязан к версии `VERIFIED` → `run(["--apply"])` → код 1, в выводе id версии и `VERIFIED`; база не изменена. То же для документа в `ex_snapshot_item.document_id` и для версии `ARCHIVED`, у которой есть `ex_snapshot` с её `source_version_id`. - `test_apply_ignores_unverified_versions` (сценарий «Форма в версии на согласовании или отклонённой», P3-12): ББ привязан к версии `VERIFICATION_RF`, ОФР — к `REJECTED`, ещё одна форма — к `CANCELLED` и к `ARCHIVED` без `ex_snapshot` → `run(["--apply"])` → код 0; привязки сняты, формы удалены, статусы всех версий прежние. - `test_apply_deletes_form_only_file`: файл с ББ, ОФР, `DISCLOSURE`, комментарием к ББ, заданием; файл `old` с `superseded_by_file_id` = удаляемый → после `--apply`: файла, документов, `financial_statement_*`, комментариев, заданий нет; `old.superseded_by_file_id is None`, `old.status == SUPERSEDED`; `s3_object.expires_at` выставлен; привязка снята, статус версии не изменился; событие `FILE_DELETE` с `user_id is None`, `description == "очистка форм перед v5.19"`, `before_value` — карточка файла. - `test_apply_mixed_keeps_osv_by_default`: смешанный файл → удалены только `BALANCE`/`INCOME` с `financial_statement_*`, комментариями и привязками; файл, ОСВ (`osv_entry`), задания остаются; событие `FILE_DELETE` по файлу с `after_value["deleted_documents"]` — список id; `--apply --include-mixed` — файл удалён целиком. - `test_osv_and_directories_untouched`: число строк `osv_entry`, `consolidation*`, `correction`, `legal_entity`, `audit_event` (кроме новых `FILE_DELETE`) не изменилось. - `test_reupload_after_purge_not_duplicate` (сценарий «Очистка и повторная загрузка»): после `--apply` `PrimaryFileService.find_duplicate(entity_id, sha256 удалённого файла)` — `None`. - [ ] **Step 2:** `cd api && .venv/bin/pytest -m integration tests/test_purge_statements.py -v` — FAIL. - [ ] **Step 3: Implement.** Отбор: документы `BALANCE`/`INCOME` у файлов любого статуса. Отказ — P3-12 и `ex_snapshot_item`. Блокировки — порядок файл → документы → версии (`bound_versions`). Файлы только с формами — `delete_file_rows` (без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`), до него — обнуление `superseded_by_file_id` ссылающихся файлов и `unbind_documents`; объекты S3 — `expire_s3_objects` по префиксам файла, как в `PrimaryFileService.delete`. КФО в ядре ещё нет — пересчитывать нечего (написать в docstring). - [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS (`mypy src scripts`). - [ ] **Step 5: Commit** `feat(scripts): purge_statements — очистка форм ББ/ОФР перед выкладкой v5.19 (Р-24, Р-27)`. ### Task A5: Контракт 2.0 — копии, модели, приём конвертов, `JobBrief.warnings` **Files:** - Modify: `api/contracts/**` (копии docs), `api/src/contract/common.py`, `api/src/contract/message.py`, `api/src/contract/__init__.py`, `api/src/services/recognition_exchange.py`, `api/src/services/documents.py` (`job_brief`), `api/src/schemas/files.py` - Create: `api/src/contract/result_v2.py`, `api/src/contract/versions.py` - Test: `api/tests/test_contract.py`, `api/tests/test_recognition_inbox.py`, `api/tests/test_api_files_read.py` **Interfaces:** - Consumes: тест идентичности плана 0 (`api/tests/contract_identity.py`). - Produces: - `versions.major(schema_version: str) -> int`; `versions.parse_message(data: dict[str, Any]) -> RecognitionResultMessage | RecognitionResultMessageV2` (MAJOR 1 / 2, иное — `ValidationError`); `versions.parse_result(content: bytes) -> RecognitionResult | RecognitionResultV2` (то же); - `result_v2`: `FileWarning {code, severity: Literal["error", "info"], message, locator?}`, `DocumentWarning {code, severity, message, line_refs?, column_index?, field?}`, `RecognitionResultV2`, `StatementDocumentV2` (`document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]`, `form_variant`, `status`, `warnings`, `header`, `columns`, `lines`, `checks?`), `StatementHeaderV2` (10 полей + `<поле>_text`, `<поле>_confidence`, `needs_review_fields`), `StatementColumnV2`, `StatementLineV2`, `StatementValueV2`, `StatementChecksV2`, `FailedRelationV2`, `OsvDocumentV2` (поля `OsvDocument` + `status`, `warnings`), `MetricsV2` (`warnings: list[FileWarning]`) — поля и обязательность по `recognition-result.schema.json` v2.0; - `RecognitionResultMessageV2` — конверт v2.0 (`metrics: MetricsV2`); - `recognition_job.metrics.warnings` пишется списком объектов `{code, severity, message, locator}`: у конверта `2.0` — как пришли, у `1.x` — каждая строка как `{"code": "LEGACY_WARNING", "severity": "info", "message": s, "locator": None}`; - `schemas.files.FileWarningOut {code: str, severity: Literal["error", "info"], message: str, locator: str | None}`; `JobBrief.warnings: list[FileWarningOut]`; `JobBrief.mapping_warnings` — по-прежнему `list[str]`; - `documents.file_warnings(value: Any) -> list[FileWarningOut]` — строки → `LEGACY_WARNING`, объекты с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается. - [ ] **Step 1:** скопировать из `../docs/contracts/` (worktree `docs/` зеркала) в `api/contracts/`: основные схемы (результат и конверт v2.0, запрос v1.0), `recognition-result.v1.schema.json`, `recognition-result-message.v1.schema.json`, `examples/*.json`, `examples/v1/*.json`. `make check` — тест идентичности PASS, тесты примеров FAIL (модели v1). - [ ] **Step 2: Write the failing tests:** таблица примеров `test_contract.py` разделена на v2 (`examples/*.json` → схемы и модели v2) и v1 (`examples/v1/*.json` → схемы `.v1` и модели v1); `test_parse_message_by_major` (`"1.0"` → `RecognitionResultMessage`, `"2.0"` → `RecognitionResultMessageV2`, `"3.0"` → `ValidationError`); `test_inbox_ack_v2_moves_job_to_processing` (ack `processing` со `schema_version "2.0"` → задание `processing`); `test_inbox_v1_warnings_wrapped` (конверт `1.0` с `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → `job.metrics["warnings"] == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", "locator": None}]`); `test_inbox_v2_warnings_kept` (`JSON_FAILED`, `error`, `locator "file:a.json"` → как пришло); `test_job_brief_converts_legacy_strings` (в БД `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → `FileOut.job.warnings == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", "locator": None}]`, БД не изменилась); `test_job_brief_mixed_and_garbage_warnings` (`["a", {"code": "JSON_FAILED", "severity": "error", "message": "m", "locator": "x"}, 5, {"code": "X"}]` → два элемента: `LEGACY_WARNING` «a» и `JSON_FAILED`; `metrics.warnings = "строка"` → `[]`) — Review Focus 5. - [ ] **Step 3:** `make check` — новые тесты FAIL. - [ ] **Step 4: Implement.** `SCHEMA_VERSION_PATTERN` моделей v1 остаётся `^1\.\d+$`; у v2 — `^2\.\d+$`; `RESULT_SCHEMA_VERSION_PATTERN` конверта v2 — `^\d+\.\d+$`. `recognition_exchange._parse_message` → `parse_message`; `_apply_ack` и применение итога принимают оба класса конверта (общие поля одинаковы); `_metrics_json` оборачивает строки `1.x`. `_load_result` в `result_mapping.py` в части А не меняется (файл `2.0` до части Б не мапится — `MappingFatal` «результат не соответствует схеме»). - [ ] **Step 5:** `make check` — PASS. - [ ] **Step 6: Commit** `feat(contract): копии контракта обоих MAJOR, модели v2.0, приём конвертов 1.x/2.0, предупреждения файла структурами`. ### Task A6: Словарь имён с синонимами и совпадение наименований **Files:** - Modify: `api/src/domain/statement_lines.py`, `api/rules/statement_lines.yaml` - Create: `api/src/domain/statement_names.py` - Test: `api/tests/test_domain_statement_lines.py`, `api/tests/test_domain_statement_names.py` **Interfaces:** - Produces: - `LineCatalog.template_lines(edition: str | None, form: str) -> tuple[TemplateLine, ...]` — по одной строке на код, первое имя, порядок первого появления (заменяет `lines()`; вызывающие — `services/statements.py`); - `LineCatalog.reference_name(edition: str | None, form: str, code: str) -> str | None` — первое имя кода (P3-11); - `LineCatalog.names(form: str, code: str) -> tuple[str, ...]` — все имена кода во **всех** редакциях; - `LineCatalog.edition_codes(edition: str, form: str) -> frozenset[str]`; - загрузчик не запрещает повтор кода в редакции; код — четыре цифры, как сейчас; - `statement_names.normalize(text: str) -> str`; `statement_names.section_number(normalized: str) -> str | None`; `statement_names.names_match(recognized: str | None, references: Iterable[str]) -> bool` (правило спеки «Совпадение наименований»: нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел, схлопывание, `strip`; равенство, или более короткая (≥ 5 символов) входит в более длинную, или `difflib.SequenceMatcher(None, a, b).ratio() >= 0.8`; «итого по разделу» — сравнение номеров разделов из букв `i`, `v`, `x` (кириллические `і`, `х` → латиница), остаток слова отбрасывается; номер не выделен — только равенство строк; пустое имя или нет справочных — `False`). - [ ] **Step 1: Write the failing tests:** ```python @pytest.mark.parametrize(("recognized", "references", "expected"), [ ("Нематериальные активы", ["Результаты исследований и разработок"], False), ("НДС, начисленный по отгрузке", ["Налог на добавленную стоимость по приобретенным ценностям", "НДС, начисленный по отгрузке"], True), ("Итого по разделу I", ["Итого по разделу II"], False), ("Итого по разделу V1", ["Итого по разделу V"], True), ("Итого по разделу V /", ["Итого по разделу V"], True), ("Итого по разделу", ["Итого по разделу II"], False), ("АКТИВ I. Внеоборотные активы Гудвил", ["Гудвил"], True), ("Расчёты", ["Расчеты"], True), ("", ["Запасы"], False), ("Запасы", [], False), ]) def test_names_match(recognized, references, expected): assert names_match(recognized, references) is expected ``` и `test_catalog_two_names_one_template_line` (`fsbu4`, `BALANCE`, код `1520` записан дважды: «Кредиторская задолженность», «Обязательство по аренде» → словарь загружается, `template_lines` даёт одну `1520` с первым именем, `names("BALANCE", "1520")` содержит оба); `test_catalog_fsbu4_additions` (в `fsbu4` есть вторые имена `1220` «НДС, начисленный по отгрузке», `1520` «Обязательство по аренде», `2410` «Налог при упрощенной системе налогообложения» и коды `1105` «Гудвил», `1130` «Нематериальные поисковые активы», `1140` «Материальные поисковые активы», `1530` «Доходы будущих периодов», `2460` «Прочее»). - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_lines.py tests/test_domain_statement_names.py -v` — FAIL. - [ ] **Step 3: Implement**; дополнения yaml — на места по `contracts/statement-code-orders.yaml` (порядок `fsbu4`), имена — по `fixtures/ocr` и шаблонам банка; шапку yaml — «словарь имён: код → наименования по редакциям, несколько имён на код (Р-20)». - [ ] **Step 4:** тот же запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(domain): словарь имён с синонимами (Р-20) и совпадение наименований §11.9`. ### Task A7: Агрегат формы по коду и перевод чтений «по коду» **Files:** - Create: `api/src/domain/statement_aggregate.py` - Modify: `api/src/repositories/statements.py` (`statement_amounts` → `form_aggregate`, `_line_amount`, `add_line`, `load_form`), `api/src/services/statements.py` (`checks_report`, `statement_hard_failures`, `_resolve_cells`, `build_out`), `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/schemas/statements.py` (`line_origin`), `api/src/domain/statement_checks.py` (`FailedCheck.codes`) - Test: `api/tests/test_domain_statement_aggregate.py`, `api/tests/test_api_statements.py` **Interfaces:** - Produces: - `@dataclass(frozen=True) AggLine`: `id: uuid.UUID`, `row_index: int | None`, `line_status: StatementLineStatus`, `line_code: str | None`, `amounts: Mapping[uuid.UUID, Decimal | None]` (ключ — `column_id`); - `@dataclass(frozen=True) CodeAggregate`: `code: str`, `amounts: dict[uuid.UUID, Decimal | None]`, `line_ids: tuple[uuid.UUID, ...]` (по `row_index`, `NULL` последними), `reference_name: str | None`, `has_source_line: bool`; - `aggregate_by_code(lines: Iterable[AggLine], reference_name: Callable[[str], str | None] = lambda _: None) -> dict[str, CodeAggregate]` — по возрастанию кода, только строки `ok`; сумма по колонке, `None` — если непустых нет; - `repo.form_aggregate(session, meta_id, reference_name=...) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]`; - `checks_input(columns, aggregates) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]` — вход `run_checks` до части Б: коды без строки источника и без единой суммы пропускаются (правило D-5 шаблонных строк); - `repo._line_amount` → `repo.code_amount(session, meta_id, code, *, current: bool = False, period_date=None) -> Decimal | None` — `SUM(amount)` строк `ok` кода в колонке; - `line_origin(line) -> "template"` ⇔ `line.row_index is None`; `repo.add_line` пишет `row_index = None`, `source_ref = None`, `line_status = ok`, `line_code_original_value = None`; - `FailedCheck.codes: tuple[str, ...]` (цель и члены правила, P3-14). - [ ] **Step 1: Write the failing tests** (домен): ```python def test_repeated_code_sums(): agg = aggregate_by_code([ok("2410", r=1, c1="100"), ok("2410", r=5, c1="649")]) assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5) def test_empty_amounts_stay_null(): assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1=None)])["1110"].amounts[C1] is None assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5") def test_non_ok_lines_excluded(): agg = aggregate_by_code([line("detail", None, r=2, c1="7"), line("unreadable", None, r=3, c1="9"), line("missing", None, r=4, c1="1"), ok("1250", r=5, c1="3")]) assert list(agg) == ["1250"] def test_template_line_counts_as_ok(): agg = aggregate_by_code([ok("1250", r=None, c1="10")]) assert agg["1250"].amounts[C1] == Decimal("10") and agg["1250"].has_source_line is False ``` и API: `test_cross_form_uses_aggregate` (ББ: в колонке отчётной даты две строки `1370` — исходная с 500 и добавленная строка `ok` с `row_index = 99`, 300; ОФР с `2400` = 800 − `1370(нач. года)` → `checks.cross_form is None`; при `2400` = 500 − `1370(нач. года)` → `cross_form` с `expected`/`actual` по 800) — Review Focus 2; `test_parent_code_with_repeated_parent` (две строки `1150`, подстрока `11501` → `parent_code == "1150"` один раз); `test_template_not_completed_for_code_with_ok_line` и `test_template_completed_for_code_only_unreadable` (у формы нет строки `ok` 1250, есть `unreadable` «ИЧ,250» → в ответе достроенная пустая `1250` с `id = null` и строка `unreadable` отдельно); `test_patch_by_line_code_with_ok_line_is_unknown_cell` (код с существующей строкой `ok` → 422 `UNKNOWN_CELL`); `test_origin_by_row_index` (строка источника с `line_code_original_value = null`, `row_index = 7` → `origin == "source"`). - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_aggregate.py tests/test_api_statements.py -v` — FAIL. - [ ] **Step 3: Implement**; удалить `statement_amounts` и `has_lines` — «есть строки» = есть строка с `row_index IS NOT NULL` (`repo.has_source_lines`); `load_form` — строки по `line_code, row_index NULLS LAST, id`. - [ ] **Step 4:** тот же запуск и `make check` — PASS (старые тесты `test_api_statements.py` про шаблонные строки зелёные). - [ ] **Step 5: Commit** `feat(statements): агрегат формы по коду — проверки, межформенная, parent_code, достройка, правка по коду`. ### Task A8: Ответ `GET …/statement` и `DocumentOut.issues` по v5.19 **Files:** - Create: `api/src/domain/statement_layout.py` - Modify: `api/src/schemas/statements.py`, `api/src/schemas/documents.py`, `api/src/schemas/files.py` (`DocumentBrief`), `api/src/services/statements.py` (`build_out`), `api/src/services/documents.py` (`document_out`, `file_out` — `issue_counts` рядом с `counts` комментариев), `api/src/services/primary_files.py` (сборка `FileOut.documents`), `api/src/repositories/statements.py` (`issues`, `issue_counts_by_document`), `api/src/domain/statement_lines.py` (`LineCatalog.edition_lines`) - Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.py`, `api/tests/test_api_files_read.py`, `api/tests/test_domain_statement_lines.py` **Interfaces:** - Consumes: `CodeAggregate` (A7), `LineCatalog.reference_name` (A6). - Produces: - `@dataclass(frozen=True) LayoutLine`: `key: Hashable`, `row_index: int | None`, `line_status: str`, `line_code: str | None`, `parent_row_index: int | None`; `order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]` — P3-17; - `repo.issues(session, document_id) -> list[DocumentIssue]` (по `created_at, id`); - `IssueOut {id, kind, code, severity, message, line_ids: list[uuid.UUID], column_id, column_index, field, source, relation, codes: list[str], expected, actual}` — общий для `StatementOut.issues` и `DocumentOut.issues`; - `StatementDocumentOut.form_variant`; `StatementHeaderOut.needs_review_fields: list[str]`, `source: dict[str, HeaderSourceOut]` (= `header_source` или `{}`; `HeaderSourceOut {text: str | None, confidence: Decimal | None}` — число из JSONB приводится `Decimal(str(x)).quantize(Decimal("0.01"))` и отдаётся строкой, как прочие уверенности API, P3-20), `organization_name_modified: bool`, `okei: str | None` (`financial_statement_meta.okei`, P3-21); `ColumnOut.period_kind`, `label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`, `line_code_confidence: Decimal | None` (строка), `reference_name`, `parent_row_index`, `needs_review`, `is_confirmed`, `line_code_modified`; `ValueOut` + `text` (`source_text`), `needs_review`, `is_confirmed`; `FailedCheckOut` + `codes`, `line_ids`; `StatementOut.issues: list[IssueOut]`; - `StatementOut.edition_lines: list[EditionLineOut]`, `EditionLineOut {code: str, name: str}` — справочник строк для подсказки кода (P3-22): строки словаря имён формы `document.document_type` в редакции ответа (`edition`), по одной на код, имя — первое (как `reference_name`), в порядке словаря; `edition is None` — строки всех редакций словаря по порядку редакций, по одной на код (имя — из первой редакции, где код есть); `LineCatalog.edition_lines(form: str, edition: str | None) -> list[tuple[str, str]]`; - `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся; - `DocumentBrief.issue_counts: IssueCountsOut {error: int, info: int}` (а значит и `DocumentOut.issue_counts`) — число записей `document_issue` документа по `severity`, всех видов (`warning`, `relation`, `soft`), P3-23; `repo.issue_counts_by_document(session, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, IssueCounts]` — один запрос `GROUP BY document_id, severity` на список документов (как `comments_count_by_document`); документа нет в ответе — `{error: 0, info: 0}`. - [ ] **Step 1: Write the failing tests:** ```python def test_order_lines_attachments(): lines = [L("m0", 0, "missing", None, None), L("1230", 1, "ok", "1230", None), L("u2", 2, "unreadable", None, None), L("1210", 3, "ok", "1210", None), L("d4", 4, "detail", None, 3), L("t1250", None, "ok", "1250", None), L("2410b", 9, "ok", "2410", None), L("2410a", 6, "ok", "2410", None)] assert order_lines(lines) == ["m0", "1210", "d4", "1230", "u2", "t1250", "2410a", "2410b"] def test_detail_without_parent_follows_previous_ok(): assert order_lines([L("1110", 0, "ok", "1110", None), L("d1", 1, "detail", None, None)]) == ["1110", "d1"] ``` API: `test_get_statement_v5_19_fields_on_1x_form` (форма из фикстуры `1.2`: у строк `line_status == "ok"`, `row_index` — целые с 0, `source_ref is None`, `origin == "source"`, `needs_review is False`, `is_confirmed is False`; у значений `text == string_value`; `document.form_variant is None`; `header.needs_review_fields == []`; `header.organization_name_modified is False`; `columns[].period_kind ∈ {"absolute", "none"}`; `issues == []`); `test_reference_name_first_name` (`fsbu4`, строка `1520` → `reference_name == "Кредиторская задолженность"`); `test_issues_in_statement_and_document` (вставленная запись `document_issue` `warning` `COLUMN_SKIPPED` с `column_index = 5`, `column_id = null` → она в `StatementOut.issues` и `DocumentOut.issues` с этими полями); `test_line_code_confidence_and_okei` (строка с `line_code_confidence = 67.50` → `"67.50"`; `meta.okei = "384"` → `header.okei == "384"`); `test_header_source_confidence_is_decimal_string` (`header_source = {"inn": {"text": "x", "confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}}` → `header.source.inn.confidence == "61.54"`, `header.source.units.confidence == "100.00"`; `confidence: null` → `null`); `test_edition_lines` (форма `fsbu4` ББ → `edition_lines` — коды редакции `fsbu4` ББ словаря имён по одному, `1520` с именем «Кредиторская задолженность», `1120` нет; форма без даты (`edition is None`) → коды обеих редакций, `1120` есть, повторов кода нет); `test_issue_counts_in_file_and_document` (у ББ записи `warning` `error`, `relation` `error`, `soft` `info`, у ОФР нет → в `GET /files/{id}` `documents[]`: ББ `issue_counts == {"error": 2, "info": 1}`, ОФР `{"error": 0, "info": 0}`; `GET /documents/{id}` — те же числа; число SQL-запросов на `GET /files` не растёт с числом документов). - [ ] **Step 2:** запуск трёх файлов — FAIL. - [ ] **Step 3: Implement**; порядок `lines` ответа — `order_lines` (достроенные шаблонные — как строки `ok` без `row_index`). - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(api): StatementOut и DocumentOut по v5.19 — статусы строк, needs_review, issues, порядок строк`. ### Task A9: Маппер `1.x` — поля v5.19, `file_ordinal`, вторая форма, `MAPPING_WARNING` **Files:** - Create: `api/src/domain/statement_issues.py` - Modify: `api/src/domain/statement_mapping.py`, `api/src/repositories/statements.py` (`insert_statement`, `add_issues`), `api/src/services/result_mapping.py` (`map_file`, `_map_statement_docs`, `_insert_statements`) - Test: `api/tests/test_domain_statement_mapping.py`, `api/tests/test_mapping_statements.py`, `api/tests/test_result_mapping.py` **Interfaces:** - Produces: - `@dataclass(frozen=True) IssueDraft`: `kind: DocumentIssueKind`, `code: str`, `severity: IssueSeverity`, `message: str`, `source: IssueSource`, `line_ids: tuple[uuid.UUID, ...] = ()`, `column_id: uuid.UUID | None = None`, `column_index: int | None = None`, `field: str | None = None`, `relation: str | None = None`, `codes: tuple[str, ...] = ()`, `expected: Decimal | None = None`, `actual: Decimal | None = None`; `mapping_warning(message: str) -> IssueDraft`; - `repo.add_issues(session, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now) -> None`; - `LineDraft` + `row_index: int` (индекс строки в `doc.lines` результата), `line_status = "ok"`, `line_code_original_value`, `source_ref = None`, `line_code_text = None`, `needs_review = False`; `ValueDraft` + `source_text` (= `string_value`), `needs_review` (= НЕ `value_clean(confidence, is_recognized)`); колонки + `period_kind` (`absolute` при `period_date`, иначе `none`), `label_confidence = None`; шапка + `header_source` (10 полей шапки результата: `text` из одноимённого поля 1.x, у `report_date` — `report_date_text`, `confidence` — где есть в 1.x, у `okei` оба `None`), `organization_name_original_value`, `organization_name_modified = False`, `needs_review_fields = []`, `form_variant = None`; - `value_clean(confidence: Decimal | None, is_recognized: bool | None) -> bool` (выделено из `values_ok`); - каждый создаваемый документ — `file_ordinal` = индекс в `result.documents` (P3-6 для `DISCLOSURE`/`OTHER`); - предупреждения черновика формы → `MAPPING_WARNING` документа; предупреждения листа ОСВ — тоже (P3-5); Р-10 — P3-4. - [ ] **Step 1: Write the failing tests:** - `test_two_osv_sheets_file_ordinals` — результат `1.x` с ОСВ 60 и 62 → `file_ordinal` 0 и 1, файл `PROCESSED`; - `test_pdf_balance_income_1x_ordinals` — `1.2` с `BALANCE`, `INCOME` → 0 и 1; - `test_duplicate_osv_sheet_takes_no_ordinal` — ОСВ 60, ОСВ 60, ББ → 0 и 2; - `test_1x_line_fields` — строки 0..n: `row_index == i`, `line_status == "ok"`, `line_code_original_value == line_code`, `source_ref is None`; значения `confidence 100, is_recognized true` → `needs_review is False`, `confidence 80` → `True` (сценарий «Полярность needs_review при приёме 1.x»); `source_text == string_value`; - `test_1x_duplicate_code_keeps_first` — повтор `2410` в `1.2`: хранится первая строка, `row_index` второй не занят, `document_issue` `MAPPING_WARNING` с текстом о повторе; - `test_1x_second_form_same_period_stored` (заменяет `test_duplicate_form_in_file_skipped`) — два `BALANCE` на 30.09.2025 → два документа, оба `NEEDS_CHECK`, у каждого `MAPPING_WARNING` «в файле есть другая форма BALANCE за тот же период», ни один не `CONFIRMED` — Review Focus 1; - `test_1x_second_form_other_period` — `BALANCE` на 30.09.2025 и на 31.12.2024 → у каждого свой статус, предупреждения о повторе нет; - `test_osv_orphans_mapping_warning_issue` — лист с сиротами → у документа ОСВ `MAPPING_WARNING` с текстом сирот, строка в `mapping_warnings` тоже есть; - `test_1x_header_source` — `header_source["inn"] == {"text": inn, "confidence": inn_confidence}`, `header_source["report_date"]["text"] == report_date_text`. - [ ] **Step 2:** запуск трёх файлов — FAIL. - [ ] **Step 3: Implement**; статус форм `1.x` — D-6, как сейчас (`values_ok` = нет `needs_review` у значений; `warnings` = предупреждения черновика + повтор периода); `insert_statement` ключует строки по `row_index`, а не по коду. - [ ] **Step 4:** запуск, `make check` и `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py tests/test_mapping_golden.py` — PASS (golden-снимки ОСВ и все снимки форм `1.x` маппятся). - [ ] **Step 5: Commit** `feat(mapping): маппер 1.x пишет поля v5.19, file_ordinal, хранит вторую форму, MAPPING_WARNING в document_issue`. ### Task A10: Условие `RECOGNIZED` и статус ОСВ при приёме 2.0 — чистые функции **Files:** - Create: `api/src/domain/statement_status.py` - Test: `api/tests/test_domain_statement_status.py` **Interfaces:** - Consumes: `names_match`, `LineCatalog.names` (A6). - Produces (подключаются в B2, B4, B5): - `has_error_warning(warnings: Any) -> bool` — есть элемент-объект с `severity == "error"`; строки и мусор — нет; - `duplicate_period_forms(forms: Iterable[tuple[Hashable, DocumentType, int | None, PeriodQuarter | None]]) -> set[Hashable]` — ключи форм `BALANCE`/`INCOME`, у которых в файле есть другая форма того же типа с той же парой (год, квартал); формы без квартала не считаются; - `@dataclass(frozen=True) NameCheckLine`: `key`, `line_status`, `line_code`, `line_code_confidence: Decimal | None`, `line_name: str | None`, `code_fixed: bool`; `name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, form: str) -> list[Hashable]` — строки `ok` с `line_code_confidence < 100`, без `code_fixed`, чьё имя не совпало ни с одним `catalog.names(form, code)`; - `form_status_v2(*, service_done: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, period_known: bool, names_ok: bool, duplicate_in_file: bool) -> DocumentStatus`; - `osv_status_v2(*, osv_done: bool, checks_ok: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, sheet_mapping_warnings: bool) -> DocumentStatus`. - [ ] **Step 1: Write the failing tests:** ```python OK = dict(service_done=True, file_error_warning=False, counterparty=DocumentCheckResult.MATCH, period_known=True, names_ok=True, duplicate_in_file=False) def test_form_recognized(): assert form_status_v2(**OK) is DocumentStatus.RECOGNIZED @pytest.mark.parametrize("change", [{"service_done": False}, {"file_error_warning": True}, {"counterparty": DocumentCheckResult.MISMATCH}, {"period_known": False}, {"names_ok": False}, {"duplicate_in_file": True}]) def test_form_needs_check(change): assert form_status_v2(**OK | change) is DocumentStatus.NEEDS_CHECK def test_unknown_counterparty_allowed(): assert form_status_v2(**OK | {"counterparty": None}) is DocumentStatus.RECOGNIZED def test_duplicate_period_forms(): forms = [(1, BAL, 2025, Q3), (2, BAL, 2025, Q3), (3, BAL, 2024, Q4), (4, INC, 2025, Q3), (5, BAL, 2025, None), (6, BAL, 2025, None)] assert duplicate_period_forms(forms) == {1, 2} def test_has_error_warning(): assert has_error_warning([{"code": "JSON_FAILED", "severity": "error", "message": "m"}]) assert not has_error_warning([{"code": "LEGACY_WARNING", "severity": "info", "message": "m"}, "строка"]) assert not has_error_warning(None) ``` `osv_status_v2`: все условия — `RECOGNIZED`; каждое нарушенное — `NEEDS_CHECK` (параметризация, как у форм). `name_mismatches` по `fsbu4`: `1120` (50) «Нематериальные активы» → в списке; `1220` (50) «НДС, начисленный по отгрузке» → нет; `1200` (50) «Итого по разделу I» → в списке; `1500` (56) «Итого по разделу V1» → нет; `1105` (67) «АКТИВ I. Внеоборотные активы Гудвил» → нет; `1120` (50) с `code_fixed` → нет; `1110` (100) с чужим именем → нет; `9999` (50) → в списке (кода нет в словаре); пустое имя (50) → в списке. - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_status.py -v` — FAIL. - [ ] **Step 3: Implement** (docstring — §9.3 «Документ формы при приёме контракта v2.0», «Документ ОСВ при приёме контракта v2.0»). - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(domain): условие RECOGNIZED для 2.0 (Р-16, Р-18, Р-19) и статус ОСВ (Р-28)`. ### Task A11: Подтверждение формы — порядок 409 и автоподтверждение **Files:** - Modify: `api/src/services/statements.py` (`form_confirmable` → `form_blocker`), `api/src/services/documents.py` (`_check_statement`), `api/src/services/auto_confirm.py`, `api/src/repositories/statements.py`, `api/src/repositories/files.py` (`confirmed_twin`), `README.md` - Test: `api/tests/test_api_statements.py`, `api/tests/test_form_confirmable.py`, `api/tests/test_auto_confirm.py` **Interfaces:** - Consumes: агрегат и `statement_hard_failures` (A7). - Produces: - `@dataclass(frozen=True) FormBlocker`: `code: str`, `message: str`, `extra: dict[str, Any]`; - `async def form_blocker(session, document, meta) -> FormBlocker | None` — первый по порядку `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (`extra = {"lines": [{"line_id", "row_index", "line_code_text"}]}`, строки `unreadable`/`missing`, у которых хотя бы в одной ячейке `amount` или `string_value` не `null`, по `row_index`) → `DUPLICATE_FORM` (`extra = {"document_id": <подтверждённая форма того же файла, типа, года, квартала>}`) → `CHECKS_NOT_PASSED` (`extra = {"failures": [...]}`, как сейчас); - `repo.unresolved_lines(session, meta_id) -> list[tuple[uuid.UUID, int | None, str | None]]`; - `files_repo.confirmed_twin(session, document) -> uuid.UUID | None`; - `DocumentService._check_statement` поднимает `ConflictError(blocker.code, blocker.message, **blocker.extra)`; `auto_confirm` подтверждает форму, только если `form_blocker(...) is None` (`form_confirmable` удаляется). - [ ] **Step 1: Write the failing tests** (строки вставляются ORM-фабрикой на форме из фикстуры): - `test_confirm_unresolved_line_with_amount` — строка `missing` (`row_index = 40`) с суммой и жёсткое нарушение → 409 `LINES_UNRESOLVED`, `detail.lines == [{"line_id": id, "row_index": 40, "line_code_text": None}]`; - `test_confirm_unresolved_line_with_text_only` — `unreadable`, `line_code_text = "ИЧ,250"`, суммы пустые, `string_value = "211(1 |"` → 409 `LINES_UNRESOLVED`; - `test_unresolved_line_without_values_does_not_block` — `unreadable` без значений → подтверждение проходит (при прочих условиях); - `test_confirm_duplicate_form` — два ББ на 30.09.2025 в файле, первый `CONFIRMED` → второй: 409 `DUPLICATE_FORM`, `detail.document_id == str(first.id)` — Review Focus 1; - `test_conflict_order` — `PERIOD_REQUIRED` раньше `LINES_UNRESOLVED`; `LINES_UNRESOLVED` раньше `DUPLICATE_FORM`; `DUPLICATE_FORM` раньше `CHECKS_NOT_PASSED`; - `test_auto_confirm_skips_blocked_form` — форма `RECOGNIZED` со строкой `missing` с суммой → остаётся `RECOGNIZED`. - [ ] **Step 2:** запуск трёх файлов — FAIL. - [ ] **Step 3: Implement**; README «Ошибки API» — `LINES_UNRESOLVED`, `DUPLICATE_FORM`. - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM`. ### Task A12: `PATCH …/statement` — подтверждение ячейки, дата колонки, шапка **Files:** - Modify: `api/src/schemas/statements.py` (`CellPatch`, `HeaderPatch`, `ColumnPatch`, `StatementPatch`), `api/src/services/statements.py` (`patch`, `_apply_cells`, `_apply_header`, `_apply_columns`, `recompute`), `api/src/domain/statement_issues.py` (правила снятия), `api/src/repositories/statements.py` (`delete_issues`), `README.md` - Test: `api/tests/test_api_statements.py` **Interfaces:** - Consumes: `IssueDraft`, `repo.add_issues` (A9), агрегат (A7). - Produces: - `CellPatch`: `line_id | line_code`, `column_id`, `amount` (необязательно, наличие — `model_fields_set`), `confirmed: bool | None`; ровно одно из `amount`/`confirmed` (иначе 422 pydantic); `confirmed` только с `line_id` (иначе 422 pydantic); - `HeaderPatch.organization_name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None`; - `ColumnPatch {column_id: uuid.UUID, period_date: datetime.date}` (`null` — 422 pydantic); `StatementPatch.columns: list[ColumnPatch]` (`max_length=100`); - `statement_issues.HEADER_CLEARS: dict[str, frozenset[str]]` = `{"report_date": {"PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}, "inn": {"NO_ORGANIZATION_NO_INN"}, "organization_name": {"NO_ORGANIZATION_NO_INN"}}`, `COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"})`; - `repo.delete_issues(session, document_id, *, codes: Collection[str], kind: DocumentIssueKind | None = None, source: IssueSource | None = None, column_id: uuid.UUID | None = None) -> int`; - `StatementService.recompute(document, meta) -> None` — единая точка пересчёта после правки (заменяет `recompute_status`); до части Б: D-6 с `values_ok` = нет `needs_review` у значений строк не `detail`; в Б тело меняется (B2), сигнатура — нет; - сброс акцепта — только правкой данных (сумма, код, статус строки, дата колонки, поле шапки); подтверждения — нет. - [ ] **Step 1: Write the failing tests:** - `test_confirm_cell_without_change` — ячейка `needs_review = true`, акцепт стоит → `values: [{line_id, column_id, confirmed: true}]` → 200; `is_confirmed`, `confirmed_by == user`, `confirmed_at` задан, `is_modified is False`, `needs_review is False`, сумма прежняя, одно событие `RECOGNIZED_DATA_EDIT`, `checks.accepted` не `null`; - `test_unconfirm_cell` — снять (`confirmed: false`) с ячейки `confidence 80` → `is_confirmed is False`, `confirmed_by is None`, `needs_review is True`, аудит; - `test_cell_amount_and_confirmed_is_422`, `test_cell_confirmed_by_line_code_is_422` (массив), `test_confirm_missing_cell_unknown` (строка есть, записи значения нет → 422 `UNKNOWN_CELL`); - `test_patch_organization_name` — запись `NO_ORGANIZATION_NO_INN`, у шапки нет ИНН, имя владельца «ООО Ромашка» → `header: {organization_name: "ООО Ромашка"}` → `organization_name_modified is True`, `organization_name_original_value` прежнее, `counterparty_check == "MATCH"`, записи нет, аудит `before/after` по `organization_name`, акцепт сброшен; `""` и строка из 1001 символа — 422 массивом; - `test_confirm_report_date` — `needs_review_fields = ["report_date"]`, запись `REPORT_DATE_UNCERTAIN` → тот же `report_date` → `needs_review_fields == []`, записи нет, аудит, акцепт сохранён; тот же `report_date` без `needs_review_fields` → 200, событий нет; - `test_report_date_change_clears_warnings` — записи `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, `SEGMENT_COLUMNS_MISMATCH` → смена даты → остаётся только `SEGMENT_COLUMNS_MISMATCH`; - `test_inn_change_clears_no_organization`; - `test_column_date` — колонка без даты с `COLUMN_WITHOUT_DATE` (`column_id`), `report_date = null` → `columns: [{column_id, period_date: "2024-12-31"}]` → `period_date`, `period_kind == "absolute"`, записи нет, `is_current` пересчитан, `document.reporting_date == 2024-12-31` и квартал `Q4` (§9.5 п. 2), акцепт сброшен; - `test_column_date_null_is_422`, `test_unknown_column_is_422` (колонка другой формы → `UNKNOWN_COLUMN`); - `test_confirm_column_date` — `COLUMN_DATE_UNCERTAIN`, та же дата → записи нет, `period_kind == "absolute"`, аудит, акцепт сохранён; та же дата без предупреждения → 200 без событий; - `test_relative_column_becomes_absolute` — колонка `relative:prev_year_end` → дата 31.12.2024 → `period_kind == "absolute"` (вторая половина сценария — B2); - `test_segment_warning_stays_after_value_edit` — `SEGMENT_COLUMNS_MISMATCH` остаётся после правки суммы, ручное подтверждение документа проходит. - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_statements.py -v` — новые FAIL. - [ ] **Step 3: Implement**; порядок частей — P3-10; README «Ошибки API» — `UNKNOWN_COLUMN`. - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(statements): PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты`. ### Task A13: `PATCH …/statement` — строки (`lines`) **Files:** - Create: `api/src/services/statement_line_edits.py` - Modify: `api/src/schemas/statements.py` (`LinePatch`, `StatementPatch.lines`), `api/src/services/statements.py` (`patch`), `api/src/domain/statement_layout.py` (`nearest_ok_parents`), `api/src/repositories/statements.py` (`delete_line`, `remove_line_from_issues`), `README.md` - Test: `api/tests/test_api_statement_lines.py`, `api/tests/test_domain_statement_layout.py` **Interfaces:** - Consumes: `StatementService.recompute`, `_editable`, аудит (A12). - Produces: - `LinePatch {line_id: uuid.UUID, line_code: str | None = None, line_status: Literal["detail", "ok"] | None = None, confirmed: bool | None = None}` (`extra="forbid"`; `line_code` до 10 символов); `StatementPatch.lines: list[LinePatch]` (`max_length=5000`); - `async def apply_line_edits(session, *, document, meta, items: Sequence[LinePatch], user_id, before: dict, after: dict) -> bool` (возвращает «были правки данных» — для сброса акцепта): проверки до записи — `INVALID_LINE_PATCH` (не ровно одно действие; один `line_id` дважды), `UNKNOWN_LINE`, `LINE_CODE_REQUIRED` (`line_status == "ok"`), `INVALID_LINE_CODE` (не `^\d{4,5}$`), затем `LINE_CODE_TAKEN` (строка `row_index IS NULL`, `line_status = ok`, с тем же кодом и хотя бы одной непустой `amount`; 409, ничего не изменено); у всех пяти ошибок `detail` несёт `line_id` и `line_code` первого неверного элемента `lines[]` по порядку запроса (`line_id` — как в элементе, у `UNKNOWN_LINE` — чужой id; `line_code` — код элемента или `null`; у `LINE_CODE_TAKEN` — строка, которой назначается код, и этот код; P3-24); затем запись: удаление пустых шаблонных строк назначаемых кодов (значения → строка → `id` из `line_ids` записей документа), назначение (`line_status = ok`, `line_code`, `line_code_confidence = 100`, `line_code_modified = true`, `needs_review = false`), отметка `detail` (`line_code = null`), подтверждение (P3-16), пересчёт `parent_row_index` всех строк `detail`; - `nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]` — для строк `detail`: `row_index` ближайшей строки `ok` выше (строки без `row_index` родителями не бывают); временная, в B2 заменяется `detail_parents` пакета; - `repo.remove_line_from_issues(session, document_id, line_id) -> None` — `line_ids = line_ids - ''` у всех записей документа. - [ ] **Step 1: Write the failing tests:** - `test_assign_code_to_garbage_line` (AURORA-1462) — `unreadable` «ИЧ,250», `row_index = 2` → `lines: [{line_id, line_code: "1250"}]` → `line_status == "ok"`, `line_code == "1250"`, `line_code_confidence == "100.00"`, `line_code_modified is True`, `line_code_original_value is None` (БД), `row_index == 2`, `origin == "source"`, `needs_review is False`; её сумма вошла в агрегат 1250 — Review Focus 3; - `test_same_code_two_source_lines` — 1250 строке `unreadable` (`row_index 7`, без сумм), затем строке `missing` (`row_index 2`) с суммой → обе `ok` с 1250, обе в БД, 409 нет — Review Focus 3; - `test_code_taken_by_template_with_amount` — шаблонная `1250` (`row_index = null`) с суммой → `lines: [{unreadable, line_code: "1250"}, {other, line_status: "detail"}]`, `values: [...]`, `header: {units: "руб."}` → 409 `LINE_CODE_TAKEN` с `detail.line_id` строки `unreadable` и `detail.line_code == "1250"`; ни одна строка, ячейка и поле шапки не изменились, аудита нет — Review Focus 4; - `test_code_taken_by_empty_template` — шаблонная `1250` без сумм и запись `document_issue` с `line_ids = [template, other]` → шаблонной строки и её значений нет, `line_ids == [other]`, строка источника `ok` 1250 — Review Focus 4; - `test_mark_detail` — `missing` с суммой → `line_status == "detail"`, в агрегате её нет, подтверждение не даёт `LINES_UNRESOLVED`; - `test_status_ok_without_code` — `line_status: "ok"` → 422 `LINE_CODE_REQUIRED`, `detail.line_id == id`, `detail.line_code is None`; - `test_invalid_line_patch` — `{line_id}`; `{line_id, line_code, confirmed}`; один `line_id` дважды → 422 `INVALID_LINE_PATCH`, `detail.line_id` — id элемента (у повтора — повторённый id), `detail.line_code` — код элемента или `None`; - `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`, `detail.line_id` — этот id; - `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`, `detail == {code, message, line_id, line_code: "123"…}`; `"11101"` → 200; - `test_confirm_code` — строка `ok` 1120, уверенность 50, `needs_review = true`, акцепт стоит → `confirmed: true` → `is_confirmed is True`, `needs_review is False`, акцепт сохранён; `confirmed: false` → `is_confirmed is False` (P3-16); - `test_parent_moves_up` (сценарий «Родитель уходит в расшифровки») — `1140` (`row_index 3`), `1150` (`row_index 4`), `detail` 5 и 6 с `parent_row_index = 4` → `1150` отмечена `detail` → у трёх строк `parent_row_index == 3`; без строк `ok` выше — `None`; - `test_line_edit_audit_and_accept` — одно событие, `before/after` с ключом `line:` (`line_code`, `line_status`, `parent_row_index`, `is_confirmed`); назначение кода сбрасывает акцепт; - домен: `nearest_ok_parents` — `[ok r0, detail r1, ok(row None), detail r3]` → `{r1: 0, r3: 0}`. - [ ] **Step 2:** запуск двух файлов — FAIL. - [ ] **Step 3: Implement**; README «Ошибки API» — `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`. - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(statements): PATCH lines — назначение кода, расшифровка, подтверждение кода, LINE_CODE_TAKEN`. ### Завершение части А - [ ] `make check-full` (Docker: `make up`, затем `make migrate`) — PASS; вывод — в описание PR. - [ ] Финальное ревью ветки (opus), таблица находок `## Findings` в ledger, PR `tea pulls create` со ссылкой на issue А и arm-ka-docs#116; в описании — «Rulings I made», `P3-n` части А, O-1…O-10, «PR не выкладывается на контур без части Б». --- ## Часть Б > Старт — после слияния PR части А и PR плана 2 (копия `api/arm_statements/`, `mypy src scripts arm_statements`, > `statements-check` в `check-full`) в arm-core. Worktree части Б создаётся от `origin/main` с обоими слияниями. ### Task B0: Change OpenSpec части Б в arm-core **Files:** - Create: `openspec/changes/unified-normalization-core-b/` (`proposal.md`, `design.md`, `tasks.md`, спеки-дельты `osv-result-mapping`, `documents-api`, `statement-checks` (REMOVED: «копия словаря §7 ядра»), `recognition-job-exchange`) - [ ] **Step 1:** `proposal.md` — требования спеки `arm-core` из колонки «Б» таблицы покрытия; `design.md` с разделом «Decisions made without discussion» (P3-n части Б). - [ ] **Step 2:** `openspec validate unified-normalization-core-b --strict` — без ошибок. - [ ] **Step 3: Commit** `openspec(core-b): change единой нормализации, часть Б (arm-ka-docs#116)`. ### Task B1: Адаптер пакета `StatementsEngine` и вход пересчёта из БД **Files:** - Create: `api/src/services/statements_engine.py`, `api/src/domain/stored_form.py`, `api/tests/fake_engine.py` - Test: `api/tests/test_domain_stored_form.py`, `api/tests/test_statements_engine.py` **Interfaces:** - Consumes: копия `arm_statements` (имена — раздел «Публичный API пакета» плана 1; вызовы ядра — раздел «Что ядро вызывает»). - Produces: - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Что ядро вызывает»; `ArmStatementsEngine.version` возвращает `statements_version()` (содержимое `api/arm_statements/VERSION`; нет файла — `None`), `recompute` / `edition` / `code_order` / `editions` / `tolerance` берут `default_rules()` пакета, `contract_document_status` — `document_status(status_input_from_contract(document))`; - `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) -> StoredForm` (типы пакета, перечисления — `StrEnum`: `DocumentType`, `LineStatus`, `PeriodKind`, `Severity`; уверенности — `float(...)`, `NULL` значения `is_recognized` — `False`; `period_kind = NULL` — `PeriodKind.NONE`): `effective_date = document.reporting_date`; `has_organization_or_inn` — непустые `meta.organization_name` или `meta.inn`; `period_known = document.reporting_date is not None`; `needs_review_fields = tuple(meta.needs_review_fields or ())`; `accepted = meta.checks_accepted_at is not None`; `warnings` — записи `kind = warning` (`code`, `severity`); `code_fixed = bool(line.line_code_modified) or line.is_confirmed`; `value_fixed = value.is_modified or value.is_confirmed`; `text = value.source_text`; `parent_key` — ключ строки с `row_index == line.parent_row_index` (у `detail`), иначе `None`; `key` строки и колонки — `str(uuid)`; - `edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]` — пары `(line_code, line_code_confidence == 100)` строк `ok` результата по `row_index` (вход `engine.edition`); своего `StatusInput` ядро не собирает — вход статуса из документа результата строит пакет (`status_input_from_contract`, план 1 P1-27); - `FakeEngine` для модульных тестов: возвращает заданный `RecomputeResult`, заданные статус документа и редакцию, запоминает вход `StoredForm`. - [ ] **Step 1: Write the failing tests:** `test_build_stored_form_maps_flags` (шаблонная строка → `row_index is None`; правленая ячейка → `value_fixed`; подтверждённая ячейка → `value_fixed`; строка с назначенным кодом → `code_fixed`; `detail` с `parent_row_index = 3` → `parent_key` строки `row_index 3`; `warnings` — только `kind = warning`); `test_contract_document_status_matches_examples` (реальный пакет: у каждого `statementDocument` из `contracts/examples/result-statement-minimal.json` и `result-statement-excel.json` — `engine.contract_document_status(doc) == DocStatus(doc["status"])`; у минимального — `NEEDS_CHECK` из-за строки `unreadable`); `test_edition_codes_and_engine_edition` (минимальный пример: пары в порядке `row_index`, строка `unreadable` не входит; `engine.edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), FormVariant.FULL)` — `Edition`; тот же ответ, что `recompute` формы, собранной из этого результата); `test_engine_scenario_recompute_after_edit` (реальный пакет, сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230 `value_fixed`, код 1250 `code_fixed` у бывшей `unreadable`, шаблонная 1260 с суммой, `detail` с уверенностью 60 → у 1230 и у значения `detail` `needs_review` `False`, у строки 1250 `False`, соотношение `1200 = …` посчитано с 1250 и 1260; ожидаемый `RecomputeResult.checks` и `value_needs_review` — с ключами `(str(line_id), str(column_id))`, `edition` — `Edition.FSBU4` или `RAS_66N` по дате); `test_stored_form_types` (уверенность `Decimal("67.50")` в БД → `67.5` типа `float`; `is_recognized = NULL` → `False`; `period_kind = NULL` → `PeriodKind.NONE`; `line_status` — `LineStatus`); `test_engine_version_reads_file` (`engine.version == statements_version()`; `statements_version(tmp_path / "VERSION")` без файла — `None`). - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_stored_form.py tests/test_statements_engine.py -v` — FAIL. - [ ] **Step 3: Implement**; `ArmStatementsEngine` кэшируется на процесс (`functools.cache` у `default_engine`). - [ ] **Step 4:** запуск и `make check` (в т. ч. `mypy src scripts arm_statements`) — PASS. - [ ] **Step 5: Commit** `feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД`. ### Task B2: Пересчёт после правки общим кодом и §7 из `document_issue` **Files:** - Create: `api/src/services/statement_recompute.py` - Modify: `api/src/services/statements.py` (`recompute`, `checks_report`, `build_out`, `accept_checks`, `statement_hard_failures`), `api/src/services/documents.py`, `api/src/repositories/statements.py` (`replace_issues`), `api/src/schemas/statements.py` - Test: `api/tests/test_api_statements.py`, `api/tests/test_api_statement_lines.py`, `api/tests/test_statement_recompute.py` **Interfaces:** - Consumes: `StatementsEngine`, `build_stored_form` (B1), `name_mismatches`, `form_status_v2`, `has_error_warning`, `duplicate_period_forms` (A10), `IssueDraft` (A9). - Produces: - `async def recompute_form(session, *, document, meta, engine: StatementsEngine, job: RecognitionJob | None) -> None`: строит вход, вызывает `engine.recompute`, пишет `needs_review` строк и значений, `meta.form_variant`, `parent_row_index` строк `detail` (ключ → `row_index`), заменяет записи `relation`/`soft` (любого источника) и записи `core` по P3-8 (`RecomputeResult` пакета: `form_variant: FormVariant | None`, `edition: Edition | None`, `checks`, `line_needs_review`, `value_needs_review`, `detail_parents`, `status: DocStatus`), пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == DocStatus.DONE, file_error_warning = has_error_warning(job.metrics["warnings"]), counterparty, period_known = year и quarter, names_ok = нет NAME_MISMATCH, duplicate_in_file)`; - `relations_to_issues(checks: StatementChecks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — `relations_failed` → `RELATION_FAILED`/`error`, `soft_relations_failed` → `RELATION_SOFT`/`info` (`FailedRelation` плана 1); `column_id` — по `FailedRelation.column_key` (при пересчёте из БД это `str(column_id)`; при приёме `1.x` колонки уже вставлены и ключ строится так же) — по `column_index` только для проверок сервиса `2.0` (`column_key = str(column_index)`, B4); `line_ids` — все строки `ok` кодов `codes`; `expected`/`actual` — `Decimal | None` (`None` — цель или все члены пусты, план 1 P1-21; в запись — `NULL`); `message` — `f"не сошлось {relation}: {expected} ≠ {actual}"`, `None` пишется словом «пусто»; - `StatementService.recompute` вызывает `recompute_form` (тело A12 удаляется, `nearest_ok_parents` A13 удаляется); - `statement_hard_failures` и `checks.failed`/`soft_failed` ответа — из записей `relation`/`soft` (с `codes`, `line_ids`); `NOTHING_TO_ACCEPT` — нет записей `relation`; правка `header.report_date` (в т. ч. `null`) пересчитывает `period_date` колонок с `period_kind` `relative:*` через `engine.resolve_relative(PeriodKind(kind), effective_date)` до пересчёта. - [ ] **Step 1: Write the failing tests:** - `test_1x_form_after_patch_recomputed` (сценарий «1.x-документ после PATCH») — форма `1.2` с несошедшимся `1200 = 1210 + … + 1260` (запись `relation` по 1200 есть после приёма, B3; до B3 — вставить запись вручную) → правка 1230 так, что сходится → записи `relation` по 1200 нет, статус пересчитан, `STATEMENTS_VERSION_MISMATCH` нет; - `test_migrated_form_shows_violation_after_first_patch` (сценарий «Миграция на непустой базе») — форма без записей `document_issue` с несошедшимся `1600 = 1700`: `GET` — `checks.failed == []`, `POST …/confirm` — без `CHECKS_NOT_PASSED`; после любого `PATCH` — запись `RELATION_FAILED` с `codes` `["1600", "1700"]`; - `test_unconfirm_uncovered_cell` (сценарий «Снятие подтверждения») — ячейка, не покрытая соотношением → `needs_review is True` после пересчёта; - `test_statements_version_mismatch` — `job.statements_version = "abc1234"`, `engine.version = "def5678"` → запись `STATEMENTS_VERSION_MISMATCH` после `PATCH` (`FakeEngine.version` / реальный `statements_version()`); `job.statements_version = None` — нет; - `test_relative_column_follows_report_date` — колонка `relative:prev_year_end`, `PATCH header.report_date = 2025-09-30` → `period_date == 2024-12-31`; после ручной даты (A12) — не меняется; `report_date = null` → `period_date is None` у относительных; - `test_checks_from_issues_with_codes_and_line_ids` — `checks.failed[0].codes` и `line_ids` из записи; - `test_one_patch_one_recompute` — `FakeEngine` вызван один раз за `PATCH` из трёх частей, событие одно. - [ ] **Step 2:** запуск трёх файлов — FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4:** запуск и `make check` — PASS (тесты A12/A13 зелёные на общем коде). - [ ] **Step 5: Commit** `feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue`. ### Task B3: Маппер `1.x` — §7 при приёме и вариант формы (Р-30) **Files:** - Modify: `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/services/statement_recompute.py` - Test: `api/tests/test_mapping_statements.py`, `api/tests/test_api_statements.py` **Interfaces:** - Consumes: `engine.recompute` (`RecomputeResult.form_variant`, `.checks`; `.status` здесь не используется), `build_stored_form`, `relations_to_issues` (B1, B2). - Produces: `async def checks_on_intake_1x(session, *, document, meta, engine) -> bool` — один вызов `engine.recompute` по вставленной форме; пишет `meta.form_variant` и записи `relation`/`soft` (`source = core`); возвращает «жёстких нарушений нет» — условие D-6 «проверки сошлись»; `needs_review` значений и строк **не** перезаписывает (у `1.x` они по правилу приёма, A9). - [ ] **Step 1: Write the failing tests:** `test_1x_balance_with_violation` (сценарий «Форма 1.x с нарушением»: `1.2`, ББ с несошедшимся `1600 = 1700` → запись `relation` `RELATION_FAILED`, `codes == ["1600", "1700"]`, `source == "core"`, документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`); `test_1x_simplified_variant` (ББ `1.2` с 1600 и без 1100/1200 → `form_variant == "simplified"`); `test_1x_clean_income_recognized` (прежний `test_clean_income_recognized` зелёный: `RECOGNIZED` и `CONFIRMED`). - [ ] **Step 2:** запуск — FAIL. - [ ] **Step 3: Implement**; `run_checks` ядра из `_insert_statements` убрать. - [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py` — PASS. - [ ] **Step 5: Commit** `feat(mapping): §7 и вариант формы при приёме 1.x копией arm_statements (Р-30)`. ### Task B4: Маппер `2.0` — формы **Files:** - Create: `api/src/domain/statement_mapping_v2.py`, `api/tests/factories_v2.py` - Modify: `api/src/services/result_mapping.py` (`map_file`, выбор маппера), `api/src/repositories/statements.py` (`insert_statement` — черновик v2) - Test: `api/tests/test_domain_statement_mapping_v2.py`, `api/tests/test_mapping_statements_v2.py` **Interfaces:** - Consumes: модели `result_v2` (A5), `IssueDraft`/`add_issues` (A9), `form_status_v2`, `duplicate_period_forms`, `name_mismatches`, `has_error_warning` (A10), `engine.contract_document_status`, `engine.edition`, `engine.code_order`, `edition_codes` (B1). - Produces: - `build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2` — строки как пришли (`row_index`, `source_ref = ref`, `line_status`, `line_code`, `line_code_original_value = line_code`, `line_code_text`, `line_code_confidence`, `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки с `ref == parent_ref`, `needs_review`, `is_confirmed = False`); значение на каждую пару «строка × колонка» (`amount`, `string_value`, `source_text = text`, `confidence`, `is_recognized`, `needs_review`; нет в результате — пустая запись с `needs_review = False`); колонки с `period_kind`, `label_confidence`; шапка: разобранные поля, `header_source` (`{поле: {"text": <поле>_text, "confidence": <поле>_confidence}}` для 10 полей), `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value = units_text`, `organization_name_original_value = organization_name`, `inn_confidence`, `needs_review_fields`, `form_variant`; P3-9; - `service_issues(doc, *, line_ids_by_ref, column_ids_by_index, ok_line_ids_by_code) -> list[IssueDraft]` — `doc.warnings` (`warning`, `service`; `line_refs` → `line_ids`; `column_index` → `column_id`, если колонка есть, иначе `column_index`) и `doc.checks` (`relation`/`soft`, `service`; `column_id` — по `column_index` колонки: у сервиса `FailedRelation.column_key = str(column_index)`); - `core_info_issues(*, lines, edition: Edition | None, form_variant: FormVariant | None, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); `edition is None` или `form_variant is None` — проверки по редакции не пишутся; `edition = engine.edition(DocumentType(doc.document_type), edition_codes(doc), document.reporting_date, FormVariant(doc.form_variant) if doc.form_variant else None)` — без сборки `StoredForm` и пересчёта; - статус формы: `service_done = doc.status == DocStatus.DONE and engine.contract_document_status(doc.model_dump(mode="json")) == DocStatus.DONE`; `expected`/`actual` нарушений результата — `Decimal | None` (контракт: `null` — цели нет); `doc.status == "done"` при несогласии — запись `RESULT_STATUS_INCONSISTENT` (`info`, `core`, у документа); `file_error_warning = has_error_warning(result.metrics.warnings)` (P3-15); `RECOGNIZED` → автоподтверждение (как сейчас); - `job.statements_version = result.service.statements_version`; `DISCLOSURE` — один документ на файл (P3-6), предупреждения всех объединённых — в его `document_issue`. - [ ] **Step 1: Write the failing tests** (`factories_v2.statement_result_v2(...)` собирает результат `2.0` из базового словаря с переопределениями; реальный пакет): - golden на примерах docs: `result-statement-minimal.json` — строка `unreadable` с `line_code is None`, `line_code_text` как в примере, документ `NEEDS_CHECK`; `result-statement-excel.json` — `BALANCE` с записью `RELATION_SOFT`, `INCOME` со строкой `detail` и `parent_row_index` строки-родителя, один `DISCLOSURE` без строк; - `test_repeated_code_kept` — две строки `ok` 2410 (пустая и `(649)`) → две строки с разными `row_index` и `source_ref`, у каждой свои значения; - `test_line_without_code` — `unreadable`, `line_code_text = "ИЧ,250"`, сумма → `line_code is None`, `line_code_original_value is None`, `line_status == "unreadable"`, `row_index` из результата; - `test_two_balances_same_period` — два `BALANCE` 2025 Q3 → разные `file_ordinal`, оба `NEEDS_CHECK`, ни один не `CONFIRMED` — Review Focus 1; `test_two_balances_other_periods` — каждый по своим условиям; - `test_neighbor_needs_check` — ББ `done`, ОФР `needs_check`, ошибок файла нет, ИНН совпал, 30.09.2025 → ББ `CONFIRMED` (автоподтверждён), ОФР `NEEDS_CHECK`; - `test_json_failed_blocks_all` — все `done`, `metrics.warnings` с `JSON_FAILED` (`error`) → все формы `NEEDS_CHECK`; то же для `TABLE_PARSE_ERROR`; - `test_name_mismatch_neighbor_code` — 1120 (50) «Нематериальные активы» → `NEEDS_CHECK`, `NAME_MISMATCH` у строки; `test_legal_repeat_name` — 1220 (50) «НДС, начисленный по отгрузке» → `NAME_MISMATCH` нет; `test_section_total_swap` — 1200 (50) «Итого по разделу I» → `NAME_MISMATCH`; `test_section_ocr_tail` — 1500 (56) «Итого по разделу V1» → нет; - `test_done_with_unreadable_without_values` и `test_done_with_unreadable_with_amount` — `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа, не `CONFIRMED`; - `test_report_date_not_quarter_end` — 15.08.2025 → `NEEDS_CHECK`; `POST …/confirm` → 409 `PERIOD_REQUIRED`; - `test_66n_blank_in_2025` — Межениновская 3 кв. 2025 на бланке 66н с 1120 → `CODE_NOT_IN_EDITION` у 1120 нет; - `test_column_skipped_issue` — `COLUMN_SKIPPED`, `column_index = 5`, колонки 5 нет → запись `column_index == 5`, `column_id is None`; - `test_header_source_hint` — `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` → `header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67}` и то же в `GET` (`header.source`); - `test_statements_version_and_ordinals` — `service.statements_version = "abc1234"` → у задания `"abc1234"`; `BALANCE`, `INCOME` → `file_ordinal` 0, 1. - [ ] **Step 2:** запуск двух файлов — FAIL. - [ ] **Step 3: Implement**; `map_file` получает `RecognitionResult | RecognitionResultV2` и выбирает маппер по типу. - [ ] **Step 4:** запуск и `make check` — PASS. - [ ] **Step 5: Commit** `feat(mapping): маппер результата 2.0 для форм — хранение как пришло, document_issue, условие RECOGNIZED`. ### Task B5: Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата **Files:** - Modify: `api/src/services/result_mapping.py` (`_load_result`, ОСВ-часть `map_file`), `api/src/domain/osv_mapping.py` (статус по Р-28) - Test: `api/tests/test_result_mapping.py`, `api/tests/test_mapping_statements_v2.py`, `api/tests/test_api_documents.py` **Interfaces:** - Consumes: `parse_result` (A5), `osv_status_v2`, `has_error_warning` (A10), `IssueDraft` (A9). - Produces: `_load_result` → `versions.parse_result` (MAJOR 1 — маппер `1.x`, MAJOR 2 — маппер `2.0`, иное — `MappingFatal` с итогом проверки схемы в `metrics.mapping_error`, файл `ERROR`, без повтора); документ ОСВ `2.0` — статус `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; `sheet.warnings` → `document_issue` (`warning`, `service`, коды `OSV_*`, `info`). - [ ] **Step 1: Write the failing tests:** `test_osv_in_book_with_needs_check_income` (сценарий спеки: ОСВ `done` с сошедшимися контрольными суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет → ОСВ `CONFIRMED`, её `OSV_*` — в `DocumentOut.issues`); `test_osv_v2_examples` (`result-osv-minimal.json`, `result-osv-needs-check.json` — статусы по примерам); `test_two_osv_v2_ordinals` (ОСВ 60, 62 → 0, 1); `test_result_major_3_is_fatal` (`schema_version "3.0"` → файл `ERROR`, `metrics.mapping_error` начинается с «результат не соответствует схеме»); `test_result_1x_still_mapped` (`1.2` — маппер `1.x`). - [ ] **Step 2:** запуск — FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_golden.py tests/test_mapping_statements.py` — PASS. - [ ] **Step 5: Commit** `feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR`. ### Task B6: Удаление копии словаря §7 ядра, редакции из перечня пакета **Files:** - Delete: `api/rules/statement_checks.yaml` - Modify: `api/src/domain/statement_checks.py` (остаются `cross_form_check` и `subline_parents`; `RelationRule`, `CheckRules`, `load_check_rules`, `run_checks`, `FailedCheck.codes` удаляются), `api/src/services/statements.py` (`checks_report`: допуск — `engine.tolerance()` = `default_rules().checks.tolerance`), `api/src/domain/statement_lines.py` (порядок и состав редакций — `engine.editions()` = `default_rules().code_orders.editions` (`Edition`)), `CLAUDE.md` (раздел «Формы ББ/ОФР»: §7 — копия `arm_statements`, проверки хранятся в `document_issue`), `README.md` - Test: `api/tests/test_domain_statement_checks.py`, `api/tests/test_domain_statement_lines.py` **Interfaces:** - Consumes: `engine.tolerance()`, `engine.editions()`, `engine.code_order(edition, document_type, form_variant)` (B1; `Rules.code_orders.sequence` плана 1). - Produces: `cross_form_check(balance_1370, income_2400, tolerance: Decimal) -> FailedCheck | None` без изменений сигнатуры; `test_name_dictionary_codes_in_code_orders` — каждый код `statement_lines.yaml` редакции есть в перечне этой редакции копии пакета (любой вариант формы). - [ ] **Step 1: Write the failing tests:** `test_name_dictionary_codes_in_code_orders`; `test_no_core_check_rules` (`not (API_DIR / "rules" / "statement_checks.yaml").exists()` и в `src/` нет `load_check_rules`); тесты `run_checks` в `test_domain_statement_checks.py` удаляются, тесты `cross_form_check` и `subline_parents` остаются. - [ ] **Step 2:** запуск — FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4:** `make check` — PASS; `grep -rn "statement_checks.yaml\|run_checks\|load_check_rules" api/src api/tests` — пусто. - [ ] **Step 5: Commit** `refactor(statements): удалена копия словаря §7 ядра — проверки копией arm_statements`. ### Завершение части Б - [ ] `make check-full` — PASS, `statements-check` — код `0` или `10` (предупреждение «копия отстала» — в описание PR). - [ ] Финальное ревью ветки (opus), `## Findings` в ledger, PR со ссылкой на issue Б и arm-ka-docs#116; в описании — «Rulings I made», `P3-n` части Б, открытые `O-n`, напоминание о порядке выкладки (очистка → миграция → ядро А+Б → фронт; сервисы на `2.0` — после). --- ## Покрытие спеки `arm-core` | Требование спеки | Часть А | Часть Б | |---|---|---| | Хранение результата формы v2.0 (DBML v5.19) | схема A1; `file_ordinal` и Р-10 у `1.x` A9 | маппер `2.0` B4, B5 | | Признак строки, созданной правкой | A7, A13 | — | | Шапка формы — сырой текст и уверенность | схема A1; `1.x` A9; `header.source` A8 | `2.0` B4 | | Агрегат формы по коду | A7 | — | | Условие `RECOGNIZED` при приёме 2.0 | чистые функции A10; словарь A6 | B4 (страховка — `engine.contract_document_status`) | | Предупреждения и нарушения документа — `document_issue` | таблица A1; снятие правкой A12; `line_ids` A13; `issues` A8 | `relation`/`soft`, `core` B2–B4 | | Формы результата 1.x — §7 при приёме | поля и `MAPPING_WARNING` A9; `LEGACY_WARNING` A5 | B3 | | Пересчёт после правки | временный пересчёт A12 | B2 | | PATCH формы — строки, колонки, подтверждение, наименование | A12, A13 | относительные даты B2 | | Подтверждение формы — порядок 409 | A11 | `CHECKS_NOT_PASSED` из `document_issue` B2 | | Словари ядра и достройка шаблонных строк | словарь имён A6; достройка A7 | удаление копии §7, редакции из перечня B6 | | Ответ `GET …/statement` по v2.0 | A8 | `checks` из `document_issue` B2 | | Миграция DBML v5.19 | A1, A2 | сценарий «после PATCH … `RELATION_FAILED`» — B2 | | Очистка форм перед выкладкой | A4 | — | | Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 | A5, A8, A9 | B5 | | `crm_id` ГСК/ГСЗ | A3 | — | | `recognition-contract`: «сначала ядро», ack 2.0, копии обоих MAJOR | A5 | B5 |