From 7436d2efc0c6e334c563a48db0c4eaa2ad6cbbbc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 12:03:51 +0400 Subject: [PATCH] =?UTF-8?q?plan(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=203=20?= =?UTF-8?q?=E2=80=94=20arm-core:=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20v5.19,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B,=20=D0=BF=D1=80=D0=B8=D1=91?= =?UTF-8?q?=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две части — два PR в RSHB/arm-core: часть А (A0–A13) не зависит от пакета arm_statements, часть Б (B0–B6) — после плана 2; интерфейс-заглушка пакета, решения P3-1…P3-18, открытые вопросы O-1…O-10. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-plan-3-arm-core.md | 1277 +++++++++++++++++ 1 file changed, 1277 insertions(+) create mode 100644 openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md new file mode 100644 index 0000000..fa2d46f --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md @@ -0,0 +1,1277 @@ +# План 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 пакета» сверяется с интерфейсом-заглушкой ниже до старта части Б | +| 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/-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, после сверки имён пакета (O-8) | + +Зеркальный 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`, `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`, `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** до старта части Б (O-8). Ниже — имена и типы, которые ожидает +> ядро; если в плане 1 они иные, меняется только адаптер `statements_engine.py` и эта таблица, остальной код ядра +> работает с протоколом. + +```python +# api/src/services/statements_engine.py — единственная точка импорта arm_statements +class StatementsEngine(Protocol): + @property + def version(self) -> str | None: ... # содержимое api/arm_statements/VERSION; нет файла — None + def recompute(self, form: "StoredForm") -> "StoredFormResult": ... + def document_status(self, status_input: "StatusInput") -> Literal["done", "needs_check"]: ... + def code_order(self, edition: str, document_type: str, form_variant: str | None) -> tuple[str, ...]: ... + def editions(self) -> tuple[str, ...]: ... # порядок редакций перечня: ("66n", "fsbu4") + def relative_period_date(self, period_kind: str, effective_date: datetime.date | None) -> datetime.date | None: ... + def tolerance(self) -> Decimal: ... # допуск ±1 словаря §7 пакета — для межформенной проверки ядра + +def default_engine() -> StatementsEngine: ... # ArmStatementsEngine поверх копии пакета, кэш на процесс +``` + +Ожидаемые имена пакета (`arm_statements`, импорт верхнего уровня): + +| Имя | Вид | Поля / сигнатура | +|---|---|---| +| `StoredForm` | frozen dataclass | `document_type: str`, `effective_date: date \| None`, `has_organization_or_inn: bool`, `period_known: bool`, `needs_review_fields: tuple[str, ...]`, `accepted: bool`, `warnings: tuple[StoredWarning, ...]`, `columns: tuple[StoredColumn, ...]`, `lines: tuple[StoredLine, ...]` | +| `StoredWarning` | frozen dataclass | `code: str`, `severity: Literal["error", "info"]` | +| `StoredColumn` | frozen dataclass | `key: str`, `column_index: int`, `period_date: date \| None`, `period_kind: str` | +| `StoredLine` | frozen dataclass | `key: str`, `row_index: int \| None`, `line_status: Literal["ok", "unreadable", "missing", "detail"]`, `line_code: str \| None`, `line_code_confidence: Decimal \| None`, `line_name: str \| None`, `code_fixed: bool`, `parent_key: str \| None`, `values: tuple[StoredValue, ...]` | +| `StoredValue` | frozen dataclass | `column_key: str`, `amount: Decimal \| None`, `text: str \| None`, `confidence: Decimal \| None`, `is_recognized: bool \| None`, `value_fixed: bool` | +| `recompute_stored_form` | функция | `(form: StoredForm) -> StoredFormResult` | +| `StoredFormResult` | frozen dataclass | `form_variant: Literal["full", "simplified"] \| None`, `edition: str`, `checks: StatementChecks`, `line_needs_review: Mapping[str, bool]` (ключ строки), `value_needs_review: Mapping[tuple[str, str], bool]` (ключ строки, ключ колонки), `detail_parents: Mapping[str, str \| None]`, `status: Literal["done", "needs_check"]` | +| `StatementChecks` | frozen dataclass | `relations_failed: tuple[FailedRelation, ...]`, `soft_relations_failed: tuple[FailedRelation, ...]` | +| `FailedRelation` | frozen dataclass | `column_index: int`, `relation: str`, `codes: tuple[str, ...]`, `expected: Decimal \| None`, `actual: Decimal \| None` | +| `StatusInput` | frozen dataclass | `document_type: str`, `layout_found: bool`, `has_organization_or_inn: bool`, `period_known: bool`, `lines: tuple[tuple[str, bool], ...]` (`line_status`, `needs_review`), `values: tuple[tuple[str, bool], ...]` (`line_status` строки, `needs_review`), `relations_failed: int`, `accepted: bool`, `warning_severities: tuple[str, ...]` | +| `document_status` | функция | `(status_input: StatusInput) -> Literal["done", "needs_check"]` | +| `load_code_orders` | функция | `() -> CodeOrders`; `CodeOrders.order(edition, document_type, variant) -> tuple[str, ...]`, `CodeOrders.editions -> tuple[str, ...]` | +| `relative_period_date` | функция | `(period_kind: str, effective_date: date \| None) -> date \| None` (`relative:current`, `relative:prev_year_end`, `relative:prev_prev_year_end`, `relative:prev_period`) | +| `load_check_rules` | функция | `() -> CheckRules` с полем `tolerance: Decimal` | + +Ключи хоста — `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 | Очистка отказывает, если документ формы привязан к версии **любого** статуса не «в работе» (как `DELETE /files`), а не только `VERIFIED` и далее | безопаснее; владелец решает по отчёту | O-5 | +| 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` не ставится) | O-2: в спеке `arm-core` строка «Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`» | +| 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 | + +## Открытые вопросы (`O-n`) + +- **O-1.** Р-10 для результатов `1.x` (P3-4): хранить вторую форму того же типа и при `1.x` или пропускать, как раньше? + План хранит. +- **O-2.** Подтверждение кода строки (P3-16) расходится с буквой спеки `arm-core` («подтверждённый код: `line_code_confidence + = 100`, `line_code_modified = true`») и согласуется с гайдом и DBML («без изменения»). Нужна правка спеки одной строкой + или подтверждение буквы (тогда снятие подтверждения не возвращает `needs_review`). +- **O-3.** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` у пятизначных подстрок: каждая подстрока Excel даёт `info`. + Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными? +- **O-4.** Пересчёт проверок ядра `info` после правки (P3-8) спека не описывает. +- **O-5.** Отказ очистки при привязке к `CANCELLED` / `REJECTED` / маршруту согласования (P3-12) — или только `VERIFIED` и далее? +- **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`. +- **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется + по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? +- **O-8.** Имена публичного API пакета — по заглушке выше; свести с планом 1 до старта части Б. +- **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`, + `IN_WORK_STATUSES`. +- Produces: + - `@dataclass PurgePlan`: `form_only_files: list[uuid.UUID]`, `mixed_files: list[uuid.UUID]`, `form_documents: int`, + `confirmed_documents: int`, `in_work_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`. + - `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/services/statements.py` + (`build_out`), `api/src/services/documents.py` (`document_out`), `api/src/repositories/statements.py` (`issues`) +- Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.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, dict[str, + Any]]` (= `header_source` или `{}`), `organization_name_modified: bool`; `ColumnOut.period_kind`, + `label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`, `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]`; + - `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся. + +- [ ] **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` с этими полями). +- [ ] **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, ничего не изменено); затем запись: удаление пустых шаблонных строк + назначаемых кодов (значения → строка → `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`; ни одна строка, ячейка и поле шапки не изменились, аудита нет — 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`; + - `test_invalid_line_patch` — `{line_id}`; `{line_id, line_code, confirmed}`; один `line_id` дважды → 422 + `INVALID_LINE_PATCH`; + - `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`; + - `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`; `"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 и после сверки интерфейса-заглушки с планом 1 (O-8). 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` (имена — таблица «Интерфейс-заглушка пакета»). +- Produces: + - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Интерфейс-заглушка»; `ArmStatementsEngine.version` + читает `api/arm_statements/VERSION` (`strip()`), нет файла — `None`; + - `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) + -> StoredForm`: `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`; + - `status_input_from_v2(doc: StatementDocumentV2) -> StatusInput` — вход страховки статуса из результата: `layout_found = + bool(doc.lines)`, `has_organization_or_inn` — по `header.organization_name`/`header.inn`, `period_known` — + `header.report_date` или хотя бы одна колонка с `period_date`, строки и значения с `needs_review`, `relations_failed = + len(checks.relations_failed)` (нет `checks` — 0), `accepted = False`, уровни `doc.warnings`; + - `FakeEngine` для модульных тестов: возвращает заданный `StoredFormResult`, запоминает вход. + +- [ ] **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_status_input_from_v2` (на `contracts/examples/result-statement-minimal.json`: `layout_found is True`, + `relations_failed == 0`, строка `unreadable` во входе); `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); `test_engine_version_reads_file`. +- [ ] **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, пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен + `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == "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, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — `relation` → + `RELATION_FAILED`/`error`, `soft` → `RELATION_SOFT`/`info`; `column_id` по `column_index`; `line_ids` — все строки `ok` + кодов `codes`; `message` — `f"не сошлось {relation}: {expected} ≠ {actual}"`; + - `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.relative_period_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`; `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`, `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.document_status`, `engine.recompute` (только `edition`), + `engine.code_order`, `status_input_from_v2` (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`); + - `core_info_issues(*, lines, edition, form_variant, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, + `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); + - статус формы: `service_done = doc.status == "done" and engine.document_status(status_input_from_v2(doc)) == "done"`; + `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()`), `api/src/domain/statement_lines.py` (порядок и состав редакций — + `engine.editions()`), `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()` (B1). +- 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.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 |