arm-core/docs/superpowers/plans/2026-10-18-normalization-core-b.md
2026-10-18 21:22:54 +04:00

140 KiB
Raw Permalink Blame History

План 3 — arm-core: хранение v5.19, правка формы, приём контракта 2.0. План реализации

Для исполнителей-агентов: обязательный навык — superpowers:subagent-driven-development (рекомендуется: в каждой части больше 5 задач) или superpowers:executing-plans. Шаги размечены чекбоксами (- [ ]). Контроллер SDD — фоновый сабагент на модели sonnet; исполнители — по SDD Model Selection (~/.claude/CLAUDE.md, «Исполнение»).

Goal: ядро хранит формы ББ/ОФР по DBML v5.19 как пришли (строки без кода, повторы кодов, needs_review, document_issue), читает их «по коду» через один агрегат, даёт аналитику разобрать строки и подтвердить ячейки и коды через PATCH …/statement, принимает результаты 1.x и 2.0 и пересчитывает форму после правки общим кодом arm_statements.

Architecture: две части — два PR в RSHB/arm-core. Часть А не зависит от пакета: схема v5.19 (одна миграция 0007_dbml_v5_19), скрипт очистки форм, crm_id ГСК/ГСЗ, модели контракта 2.0 и приём конвертов, словарь имён с синонимами, агрегат формы по коду, новые поля API, порядок 409, PATCH (lines, columns, values[].confirmed, header.organization_name), маппер 1.x с полями v5.19 и чистые функции условия RECOGNIZED. До части Б §7 по-прежнему считает ядро на лету своей копией словаря (api/rules/statement_checks.yaml), но уже по агрегату. Часть Б стартует, когда копия пакета лежит в api/arm_statements/ (план 2): адаптер пакета за протоколом StatementsEngine, маппер 2.0, §7 при приёме 1.x, пересчёт после правки точкой входа пакета, чтение §7 только из document_issue, удаление копии словаря §7 ядра.

Tech Stack: Python 3.11, FastAPI, SQLAlchemy 2 (async) + Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, ruff, mypy; tea (Gitea), OpenSpec, codegraph.

Spec: /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/ (ветка feat/116-unified-normalization репозитория arm-ka-docs): главное — specs/arm-core/spec.md; ещё specs/recognition-contract/spec.md (окно перехода, конверт, ack), specs/statement-normalization/spec.md («Шаг 8 — статус документа и конверта», «Точка входа пакета для пересчёта по сохранённым данным»), proposal.md («Порядок выкладки», шаг 3); модель — model/data-model-v5.19.dbml и model/entities-v5.19.md (против v5.18); контракты — contracts/*.schema.json (v2.0), contracts/*.v1.schema.json, contracts/examples/, contracts/examples/v1/; правила — context/05-domain-rules.md §7, §9.3, §9.5 пп. 2–4, §11.9; гайд фронта — dev/frontend-api-guide.md §5.6.1. Решения — design.md (Р-1…Р-30, S-1…S-74; для ядра важны Р-4, Р-8, Р-10, Р-16, Р-18…Р-20, Р-23…Р-25, Р-27, Р-28, Р-30).


Место в работе и порядок

План Что Связь с этим планом
0 тест идентичности копий контракта по MAJOR (arm-core, tabular-parser, scan-gateway) слит в arm-core до задачи A5
1 пакет arm_statements (новый репозиторий) ядро вызывает только имена раздела «Публичный API пакета» плана 1 (сверено, раздел «Что ядро вызывает» ниже)
2 sync-statements: копия пакета в api/arm_statements/, statements-check в make check-full PR плана 2 в arm-core слит до части Б
docs PR #117 контракт v2.0, DBML v5.19, правила слит до слияния PR части А (dbml-check, тест идентичности против docs main)
6 arm-web: экран по новым полям, затем sync-web в arm-core/web выкладывается вместе с ядром: FileOut.job.warnings меняет тип

Выкладка на контур (шаг 3 proposal.md) — только А и Б вместе, после очистки форм purge_statements.py и вместе с фронтом плана 6. PR части А сливается в main, но снимок контура с одной частью А не делается: до части Б ядро не принимает 2.0 и не пишет document_issue §7 (O-9).

Разбивка на PR

Часть А Часть Б
Issue (RSHB/arm-core) «Единая нормализация ББ/ОФР, ядро — часть А: DBML v5.19, очистка форм, агрегат по коду, правка формы (arm-ka-docs#116)» «Единая нормализация ББ/ОФР, ядро — часть Б: приём контракта 2.0 и пересчёт общим кодом (arm-ka-docs#116)»
Ветка feat/<issue-А>-normalization-core-a feat/131-normalization-core-b
Зеркальный workspace /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/ /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/
Worktree arm-core /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/arm-core /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/arm-core
Change OpenSpec arm-core openspec/changes/unified-normalization-core-a/ openspec/changes/unified-normalization-core-b/
Задачи A0–A13 (14) B0–B6 (7)
Старт сразу (A5 — после плана 0) после слияния PR части А и PR плана 2 в arm-core

Зеркальный workspace — как в плане 0 (context/00-workspace-layout.md, «Worktree вместо веток»): в каталоге части лежат worktree arm-core/ (ветка фичи), отсоединённый worktree arm-ka-docs docs/ (до слияния #117 — на origin/feat/116-unified-normalization, после — на origin/main; в нём не коммитится) и симлинки fixtures, reference-old-arm, CLAUDE.md, .claude на основной workspace. Так ../docs из arm-core/api и DBML ?= ../../docs/model/… указывают на docs с v5.19 без переменных окружения.

Порядок создания (~/.claude/CLAUDE.md, «Роли инструментов»), на примере части А:

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/<issue-А>-normalization-core-a
cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv

Все вызовы codegraph_explore — с projectPath = абсолютный путь worktree части. PR — tea pulls create --login home --repo RSHB/arm-core, со ссылкой на issue и на arm-ka-docs#116; в описание — вывод make check-full, таблица находок и «Rulings I made». Слияние — только по команде человека.

Global Constraints

  • Источник истины по схеме — model/data-model-v5.19.dbml; src.models.DBML_VERSION = "5.19"; в Makefile DBML ?= ../../docs/model/data-model-v5.19.dbml; в arm-core/CLAUDE.md «Правила репозитория» — v5.19 и миграция 0007_dbml_v5_19.
  • Одна ревизия Alembic 0007_dbml_v5_19 (down_revision = "0006_dbml_v5_18"); только добавляет колонки, таблицу, enum'ы, индексы и заполняет значения по умолчанию; §7 не считает; arm_statements не импортирует; схема — from src.models.base import SCHEMA, не литерал.
  • Строка, созданная правкой: row_index IS NULL ⇔ origin = template. Признак line_code_original_value IS NULL для origin и для «строка не из источника» нигде не используется. Маппер 1.x и 2.0 всегда пишет row_index.
  • line_code_original_value и header_source, *_original_value правками не меняются.
  • Строки detail, unreadable, missing не входят в агрегат по коду, в §7, в межформенную проверку.
  • Деньги — Decimal / numeric(18,2), в JSON — строки; float в домене нет.
  • Порядок блокировок: задание → файл → документы → advisory ГК → версии (по id); правка формы — «файл → документ», шапка перечитывается под блокировкой с refresh=True.
  • Ошибки — DomainError: 404 NOT_FOUND, 409 ConflictError, 422 DomainValidationError, тело {"detail": {"code", "message", …}}; 422 валидации тела pydantic — массив. Коды этого плана: 409 LINE_CODE_TAKEN (line_id, line_code), LINES_UNRESOLVED (lines[] {line_id, row_index, line_code_text}), DUPLICATE_FORM (document_id), CRM_ID_EXISTS; 422 LINE_CODE_REQUIRED, INVALID_LINE_CODE, INVALID_LINE_PATCH, UNKNOWN_LINE (все четыре — с line_id, line_code элемента lines[], P3-24), UNKNOWN_COLUMN, UNKNOWN_CELL. Каталог кодов в arm-core/README.md («Ошибки API») дополняется в той задаче, что вводит код.
  • Один PATCH …/statement — один пересчёт и одна запись audit_event RECOGNIZED_DATA_EDIT (раздел RECOGNITION, object_type = "document"); запрос без изменений — 200 без аудита. Аудит — только через services/audit.py.
  • Коды предупреждений и записей document_issue — дословно: RELATION_FAILED (relation, error), RELATION_SOFT (soft, info), CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, RESULT_STATUS_INCONSISTENT, STATEMENTS_VERSION_MISMATCH, MAPPING_WARNING, LEGACY_WARNING (все — info, источник core); снимаемые правкой предупреждения сервиса — PERIOD_UNKNOWN, REPORT_DATE_UNPARSED, REPORT_DATE_UNCERTAIN, NO_ORGANIZATION_NO_INN, COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN; уровень файла error — по полю severity, не по списку кодов.
  • import arm_statements — только в api/src/services/statements_engine.py (часть Б); копию api/arm_statements/ не править (её раскладывает sync-statements, план 2).
  • Копии контракта в api/contracts/ побайтно равны docs: основные схемы v2.0 (у запроса — v1.0), *.v1.schema.json, examples/*.json (v2.0) и examples/v1/*.json; тест идентичности — правило плана 0.
  • make check (без Docker) зелёный после каждой задачи; перед PR — make check-full (нужен Docker: make up из ../docs/dev/), его вывод — в описание PR.
  • Язык кода-комментариев, docstring, коммитов, issue, PR — русский; идентификаторы — английские, как в DBML. Docstring нового алгоритма называет § правил (05-domain-rules.md) или требование спеки arm-core.
  • Коммиты заканчиваются строками Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> и Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; описание PR — строкой 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • Находки и решения — с маркерами (T<n>.<k>, C-n/I-n/M-n, R-n, O-n, H-n); решения этого плана — P3-n (ниже); исполнитель переносит их в design.md своего change'а arm-core (раздел «Decisions made without discussion»).

Review Focus

  1. Две формы одного типа и периода в одном файле (лист-продолжение, двойная печать) — оба документа хранятся с разными file_ordinal, ни один не RECOGNIZED и не автоподтверждён (Р-18); подтверждение второго после первого — 409 DUPLICATE_FORM; формы разных периодов — каждая по своим условиям; автозамена и пара межформенной проверки не падают на двух документах с одним ключом. Тесты — A9 (маппер 1.x), A11 (409), B4 (маппер 2.0).
  2. Повторы кода в агрегате — суммы строк ok с одним кодом складываются по колонке (100 + 649 = 749), пустые не превращаются в 0 (null, если непустых нет), межформенная проверка берёт сумму (500 + 300 = 800), parent_code подстроки и шаблонная достройка видят код один раз. Тесты — A7.
  3. Строка без кода при назначении кода — строка unreadable с суммами становится ok, row_index и line_code_original_value = null сохраняются (origin = source), её значения входят в агрегат и §7, родители всех строк detail пересчитаны; тот же код у другой строки источника — законный повтор, без 409. Тесты — A13.
  4. Шаблонная строка с суммами (row_index = null) под назначаемым кодом — 409 LINE_CODE_TAKEN, и ничего из запроса не применено (ни другие элементы lines, ни values, ни шапка); пустая шаблонная строка удаляется, её id уходит из document_issue.line_ids. Тесты — A13.
  5. Старые задания со строковыми warnings — metrics.warnings из строк, смесь строк и объектов, не список, объект без обязательных полей: JobBrief.warnings отдаёт структуры, строки — как LEGACY_WARNING, мусор пропускается, данные в БД не переписываются; условие RECOGNIZED не считает строки предупреждениями error. Тесты — A5, A10.

Карта файлов

Часть А

Файл Ответственность
api/src/models/statements.py колонки v5.19 у financial_statement_*, новая модель DocumentIssue
api/src/models/files.py Document.file_ordinal, частичный уникальный индекс типа, RecognitionJob.statements_version
api/src/models/directories.py RelatedGroup.crm_id (unique)
api/src/models/enums.py, api/src/models/__init__.py enum'ы v5.19 в ENUMS, DBML_VERSION = "5.19", экспорт DocumentIssue
api/alembic/versions/0007_dbml_v5_19.py миграция (upgrade с заполнением, downgrade с отказами и журналом)
api/scripts/purge_statements.py очистка форм перед выкладкой
api/src/contract/result_v2.py, api/src/contract/message.py, api/src/contract/versions.py модели контракта 2.0, разбор по MAJOR
api/src/domain/statement_lines.py словарь имён: несколько имён на код
api/src/domain/statement_names.py совпадение наименований (§11.9)
api/src/domain/statement_aggregate.py агрегат формы по коду
api/src/domain/statement_layout.py порядок строк ответа, ближайший родитель detail (временный, до Б)
api/src/domain/statement_issues.py черновик записи document_issue, правила снятия предупреждений правкой
api/src/domain/statement_status.py условие RECOGNIZED (2.0, Р-18/Р-19), статус ОСВ (Р-28), проверка наименований
api/src/domain/statement_mapping.py черновики формы 1.x с полями v5.19
api/src/repositories/statements.py вставка v5.19, агрегат, document_issue, удаление строк
api/src/services/result_mapping.py маппер 1.x: file_ordinal, Р-10, MAPPING_WARNING
api/src/services/recognition_exchange.py приём ack и конвертов 1.x/2.0, metrics.warnings структурами
api/src/services/documents.py JobBrief.warnings, DocumentOut.issues, порядок 409
api/src/services/statements.py, api/src/services/statement_line_edits.py PATCH, блокеры подтверждения, пересчёт
api/src/services/related_groups.py, api/src/common/constraint_conflicts.py crm_id ГСК/ГСЗ
api/src/schemas/statements.py, files.py, documents.py, directories.py DTO

Часть Б

Файл Ответственность
api/src/services/statements_engine.py протокол StatementsEngine и адаптер к копии arm_statements (единственный импорт пакета)
api/src/domain/stored_form.py сборка входа точки пересчёта из строк БД (чистая функция)
api/src/services/statement_recompute.py пересчёт после правки и при приёме 1.x, запись результатов
api/src/domain/statement_mapping_v2.py черновики формы и ОСВ из результата 2.0
api/src/services/result_mapping.py выбор маппера по MAJOR, маппер 2.0
удаляются: api/rules/statement_checks.yaml, §7-часть api/src/domain/statement_checks.py копия словаря §7 ядра

Что ядро вызывает у пакета (часть Б)

Источник истины по именам и типам — раздел «Публичный API пакета» плана 1 (2026-10-18-plan-1-arm-statements.md); здесь они не копируются. Ядро импортирует имена верхнего уровня arm_statements только в api/src/services/statements_engine.py и api/src/domain/stored_form.py (сборка входа пересчёта); остальной код работает с протоколом ниже.

# api/src/services/statements_engine.py — единственная точка вызова функций arm_statements
from arm_statements import (DocStatus, DocumentType, Edition, FormVariant, PeriodKind, RecomputeResult, Rules,
                            StoredForm, choose_edition, default_rules, document_status, recompute, resolve_relative,
                            status_input_from_contract, statements_version)

class StatementsEngine(Protocol):  # тонкая обёртка: подмена в модульных тестах (FakeEngine), кэш правил на процесс
    @property
    def version(self) -> str | None: ...  # statements_version(); нет файла VERSION — None
    def recompute(self, form: StoredForm) -> RecomputeResult: ...  # recompute(form, rules)
    def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ...
        # document_status(status_input_from_contract(document)) — statementDocument результата 2.0 как dict
    def edition(self, document_type: DocumentType, codes: Sequence[tuple[str, bool]],
                effective_date: datetime.date | None, form_variant: FormVariant | None) -> Edition | None: ...
        # choose_edition(document_type, codes, effective_date, rules, variant=form_variant)
    def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: ...
        # rules.code_orders.sequence(edition, document_type, form_variant); DISCLOSURE → ()
    def editions(self) -> tuple[Edition, ...]: ...  # rules.code_orders.editions — (RAS_66N, FSBU4)
    def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ...
        # ABSOLUTE / NONE → None
    def tolerance(self) -> Decimal: ...  # rules.checks.tolerance — Decimal("1"), для межформенной проверки ядра

def default_engine() -> StatementsEngine: ...  # ArmStatementsEngine(default_rules()), кэш на процесс
Вызов ядра Имя плана 1 Где используется
engine.recompute(form) recompute(form: StoredForm, rules) -> RecomputeResult B2 (после PATCH), B3 (приём 1.x)
engine.contract_document_status(doc) document_status(status_input_from_contract(doc)) B4 (страховка статуса результата 2.0)
engine.edition(...) choose_edition(document_type, codes, effective, rules, *, variant) B4 (редакция формы 2.0 для CODE_NOT_IN_EDITION)
engine.code_order(...) default_rules().code_orders.sequence(edition, form, variant) -> tuple[str, ...] B4, B6
engine.editions() default_rules().code_orders.editions -> tuple[Edition, ...] B6
engine.resolve_relative(...) resolve_relative(kind, effective) -> date | None B2 (правка header.report_date)
engine.tolerance() default_rules().checks.tolerance -> Decimal B6
engine.version statements_version() B2 (STATEMENTS_VERSION_MISMATCH)
StoredForm, StoredColumn, StoredLine, StoredValue, StoredWarning одноимённые dataclass-ы пакета B1 (build_stored_form)
FailedRelation, StatementChecks из RecomputeResult.checks B2 (relations_to_issues)

to_contract / warning_to_contract ядру не нужны: ядро читает контракт, а не пишет его. StatusInput ядро не собирает — вход статуса из документа результата строит пакет (status_input_from_contract), поэтому страховка статуса считает ровно то, что посчитал сервис (план 1, P1-27).

Соглашения на границе ядра и пакета (все — в stored_form.py / адаптере, остальной код ядра их не видит):

  • Перечисления пакета — StrEnum (подкласс str, значения — строки контракта и БД): строка БД превращается в LineStatus(...), DocumentType(...), PeriodKind(...), Severity(...) при сборке StoredForm; period_kind = NULL в БД → PeriodKind.NONE. Обратно значения пишутся в БД как str(...) / .value.
  • Уверенности пакета — float | None (0–100), в БД — Numeric(5,2): при сборке float(confidence), None остаётся None. StoredValue.is_recognized — bool: NULL в БД читается как False.
  • StoredColumn.column_index: int | None (колонка без номера столбца допустима); ключи строки и колонки — str(uuid).
  • RecomputeResult.edition и engine.edition(...) — Edition | None; None при нераспознанной редакции (нет даты и ничья): проверки по редакции (CODE_NOT_IN_EDITION) тогда не пишутся. Пары codes для engine.edition — строки ok результата по row_index: (line_code, line_code_confidence == 100) (у результата 2.0 кодов, правленых аналитиком, нет); пересчёт после правки берёт ту же функцию внутри recompute — редакция приёма и пересчёта совпадает.
  • FailedRelation несёт column_index: int | None и column_key: str; при пересчёте из БД column_key — это StoredColumn.key, то есть str(column_id): колонка находится по ключу без обратного сопоставления по индексу. expected и actual — Decimal | None (None — цель или все члены пусты, план 1 P1-21); document_issue.expected / actual — numeric(18,2) без NOT NULL, IssueDraft.expected / actual — Decimal | None.

Ключи хоста — str(uuid) строки и колонки (key, column_key, parent_key).


Решения плана без обсуждения (P3-n)

Маркер Решение Обоснование Цена ошибки
P3-1 Миграция пишет header_source с ключами inn, report_date, units, organization_name: text — из inn_original_value, report_date_original_value, units_original_value, organization_name; confidence — inn_confidence у inn, у остальных null спека: «из *_original_value и inn_confidence»; прочих полей в v5.18 сырыми нет подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой
P3-2 organization_name_modified — false у всех форм (миграция и оба маппера), не NULL как inn_modified в маппере 1.x; API отдаёт bool одна строка UPDATE
P3-3 needs_review_fields: маппер 1.x пишет [], миграция оставляет NULL, чтение NULL = [] спека миграции поле не заполняет нет
P3-4 Маппер 1.x тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары period_year, period_quarter получают MAPPING_WARNING «в файле есть другая форма <тип> за тот же период» и по D-6 — NEEDS_CHECK (аналог Р-18) частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR O-1: если владелец хочет прежний пропуск — вернуть ветку continue
P3-5 Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в document_issue документа листа (warning, MAPPING_WARNING, info, core) при любом MAJOR; строки в metrics.mapping_warnings остаются спека: DocumentOut.issues ОСВ — «вместе с предупреждениями маппинга ядра этого листа» нет
P3-6 file_ordinal объединённого документа DISCLOSURE / OTHER — индекс первого такого документа в documents[] несозданный документ «номер не занимает» нет
P3-7 CODE_NOT_IN_DICTIONARY и CODE_NOT_IN_EDITION — одна запись на код документа (line_ids — все строки ok с кодом), пятизначные коды тоже проверяются; при приёме 1.x проверки ядра уровня info не пишутся спека для 1.x перечисляет только §7 и MAPPING_WARNING O-3 (поток info у пятизначных)
P3-8 После правки записи core CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH и STATEMENTS_VERSION_MISMATCH пересчитываются (заменяются); NAME_MISMATCH не ставится строке с code_fixed; MAPPING_WARNING, RESULT_STATUS_INCONSISTENT, LEGACY_WARNING сохраняются код строки меняется правкой; страховка статуса — факт приёма O-4
P3-9 Результат 2.0: значение с column_index вне колонок формы и повтор column_index колонки — первое остаётся, MAPPING_WARNING FK значения на колонку; уникальность (meta_id, column_index) нет
P3-10 Части PATCH применяются в порядке lines → values → columns → header; вся адресация (422) проверяется до первой записи; UNKNOWN_CELL по line_code — по кодам строк ok с учётом назначений lines этого запроса; один line_id дважды в lines — 422 INVALID_LINE_PATCH атомарность и предсказуемость нет
P3-11 reference_name — первое имя кода в редакции ответа (edition по дате); нет редакции или кода — null спека: «первое имя кода в словаре»; edition ответа — по дате нет (Р-29)
P3-12 Очистка отказывает, только если документ формы упомянут в ex_snapshot_item или привязан к версии в статусе «Верифицирована» или дальше: VERIFIED или ARCHIVED с ex_snapshot.source_version_id = версия; привязки к версиям в работе, на согласовании, REJECTED, CANCELLED (и архивированным отменённым) снимаются, статус версий не меняется решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет архивированная версия без снимка ЕХ (из CANCELLED) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ
P3-13 Коды выхода purge_statements.py: 0 — отчёт или выполнено, 1 — отказ (п. 2 спеки), 2 — ошибка аргументов как у check_dbml_vs_orm.py нет
P3-14 До части Б §7 считает ядро на лету (statement_checks.yaml) по агрегату; checks.failed[].codes берутся из правила, line_ids — по агрегату часть А должна быть самосогласованной временный код удаляется в B6
P3-15 Для условия RECOGNIZED 2.0 предупреждения файла берутся из metrics.warnings файла результата; в recognition_job.metrics.warnings — из конверта (inbox) маппер читает файл результата; по контракту наборы одинаковы нет
P3-16 Подтверждение кода (lines[] {confirmed: true}) ставит is_confirmed = true и needs_review = false, line_code_confidence и line_code_modified не меняет; назначение кода — confidence = 100, line_code_modified = true иначе исходная уверенность теряется, а снятие подтверждения не может вернуть needs_review (гайд §5.6.1: «при false — пересчитывается»); аналогия с ячейкой (is_modified не ставится); подтверждено владельцем 18.10.2026 (Р-34), спека arm-core, §9.5 п. 9 и гайд §5.6.1 приведены к этому нет
P3-17 Порядок строк ответа: строки ok (с шаблонными) — по коду, затем row_index (NULL последними), затем id; за каждой строкой ok источника — её «вложения» по row_index: строки detail с этим родителем и строки unreadable / missing / detail без родителя, у которых она — ближайшая предыдущая по row_index строка ok источника; вложения без такой строки — в начале спека «Ответ GET …/statement» нет
P3-18 Изменение crm_id ГСК/ГСЗ пишет одно событие GROUP_CHANGE с before/after всех изменённых полей вместо MEMBERSHIP_CHANGE спека: «изменение — аудит GROUP_CHANGE» O-6
P3-19 Ядро зовёт у пакета choose_edition (engine.edition) и status_input_from_contract (engine.contract_document_status) вместо сборки StoredForm ради редакции и своего status_input_from_v2; to_contract ядру не нужен одно правило с сервисом и с recompute (план 1, P1-25, P1-27); пары (код, уверенный) — из строк ok результата по row_index при ошибке сборки пар редакция приёма разойдётся с редакцией после правки — тест B1 сравнивает их
P3-20 header.source.<поле>.confidence — строка Decimal с двумя знаками ("61.54"), как прочие уверенности API; в JSONB header_source остаётся число как пришло фронт читает все уверенности одним способом (план 6, A8) округление до сотых в подсказке; исходное число — в БД
P3-21 StatementHeaderOut.okei — из financial_statement_meta.okei (колонка есть с v5.18), только чтение; в PATCH header не входит спека header контракта несёт okei, экран показывает issues с field = "okei" у поля нет
P3-22 Справочник строк для подсказки кода — поле StatementOut.edition_lines [{code, name}], а не отдельный эндпоинт: данные уже загружены (LineCatalog, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) ответ GET тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же LineCatalog.edition_lines
P3-23 DocumentBrief.issue_counts {error, info} — все записи document_issue документа (и relation / soft, в том числе акцептованные) по severity; один групповой запрос на файл панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа
P3-24 Ошибки элемента lines[] (409 LINE_CODE_TAKEN, 422 LINE_CODE_REQUIRED, INVALID_LINE_CODE, INVALID_LINE_PATCH, UNKNOWN_LINE) несут в detail line_id и line_code первого неверного элемента; LINE_CODE_REQUIRED добавлен к четырём кодам запроса фронта экран ставит ошибку у поля нужной строки, не угадывая (план 6, O-2); одно правило для всех ошибок элемента при нескольких неверных элементах фронт видит только первый — как и прежде

Открытые вопросы (O-n)

  • O-1. Р-10 для результатов 1.x (P3-4): хранить вторую форму того же типа и при 1.x или пропускать, как раньше? План хранит.
  • O-2. Закрыт решением владельца 18.10.2026 (Р-34): подтверждение кода уверенность не меняет (P3-16); буква спеки arm-core, 05-domain-rules.md §9.5 п. 9 и гайда §5.6.1 приведена к этому правилу.
  • O-3. CODE_NOT_IN_DICTIONARY / CODE_NOT_IN_EDITION у пятизначных подстрок: каждая подстрока Excel даёт info. Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными?
  • O-4. Пересчёт проверок ядра info после правки (P3-8) спека не описывает.
  • O-5. Закрыт решением владельца 18.10.2026 (Р-35): отказ только при верифицированной версии (P3-12); спека arm-core «Очистка форм перед выкладкой» п. 2 приведена к этому правилу, сценарий «Форма в версии на согласовании или отклонённой» добавлен.
  • O-6. Действие аудита при смене crm_id ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут MEMBERSHIP_CHANGE.
  • O-7. Конверт с неизвестным MAJOR (3.0) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется по сроку); сценарий спеки «файл получает ERROR с mapping_error» выполняется для файла результата. Оставить?
  • O-8. Закрыт 18.10.2026 сведением с планом 1 (его P1-21…P1-31): (а) типы Rules.code_orders.sequence(...) -> tuple[str, ...] (параметр variant: FormVariant, у DISCLOSURE — ()), .editions -> tuple[Edition, ...], Rules.checks.tolerance -> Decimal записаны в API пакета; (б) редакция без пересчёта — choose_edition (engine.edition, B4); (в) resolve_relative(ABSOLUTE | NONE, …) → None; (г) вход статуса из документа результата — status_input_from_contract пакета (engine.contract_document_status), status_input_from_v2 не пишется.
  • O-9. Выкладка: PR части А в main без снимка контура до части Б; встроенный фронт arm-core/web получает новые типы только через sync-web после плана 6 — выкладка ядра и фронта одним снимком.
  • O-10. Метрики fields_* ядро сейчас не считает по строкам формы (копирует из конверта, recognition_exchange.FIELD_COUNTERS): переводить на агрегат нечего. Подтвердить, что спека («метрики fields_*») не требует нового расчёта.

Часть А

Task A0: Change OpenSpec части А в arm-core

Files:

  • Create: openspec/changes/unified-normalization-core-a/proposal.md, design.md, tasks.md, specs/{data-model,osv-result-mapping,documents-api,primary-files,recognition-job-exchange,directories-api,statement-checks}/spec.md

Interfaces:

  • Produces: change, на который ссылаются коммиты и PR части А; design.md с разделом ## Decisions made without discussion (туда переносятся P3-n части А и рулинги контроллера).

  • Step 1: proposal.md — ссылка на change docs (unified-statement-normalization, arm-ka-docs#116), перечень требований спеки arm-core, которые закрывает часть А (по таблице «Покрытие спеки» ниже), и что остаётся части Б.

  • Step 2: спеки-дельты: переносят в формулировках capability arm-core требования спеки arm-core docs из колонки «А» таблицы покрытия (MODIFIED у data-model, osv-result-mapping, documents-api, primary-files, recognition-job-exchange, directories-api; statement-checks — MODIFIED «§7 по агрегату по коду»).

  • Step 3: openspec validate unified-normalization-core-a --strict — без ошибок.

  • Step 4: Commit openspec(core-a): change единой нормализации, часть А (arm-ka-docs#116).

Task A1: Схема v5.19 — модели и upgrade миграции

Files:

  • Modify: api/src/models/statements.py, api/src/models/files.py, api/src/models/directories.py, api/src/models/enums.py, api/src/models/__init__.py, api/src/repositories/statements.py (delete_file_statements), api/tests/factories.py (make_document), Makefile, CLAUDE.md
  • Create: api/alembic/versions/0007_dbml_v5_19.py
  • Test: api/tests/test_migrations.py, api/tests/test_api_files_delete.py, api/tests/test_mapping_statements.py

Interfaces:

  • Produces (ORM, имена колонок — как в DBML v5.19, новые колонки — в конце класса в порядке ADD COLUMN миграции):

    • enum'ы StatementLineStatus {ok, unreadable, missing, detail}, StatementFormVariant {full, simplified}, DocumentIssueKind {warning, relation, soft}, IssueSeverity {error, info}, IssueSource {service, core} — в ENUMS под именами statement_line_status, statement_form_variant, document_issue_kind, issue_severity, issue_source;
    • FinancialStatementLine: row_index: int | None, source_ref: str | None, line_status: StatementLineStatus (server_default 'ok'), line_code_text: str | None, parent_row_index: int | None, needs_review: bool (server_default false), is_confirmed: bool (server_default false); индексы ix_financial_statement_line_meta_id_line_code (не уникален) и uq_financial_statement_line_meta_id_source_ref (unique, postgresql_where=source_ref IS NOT NULL);
    • FinancialStatementValue: source_text, needs_review (false), is_confirmed (false), confirmed_by (FK app_user.id), confirmed_at;
    • FinancialStatementColumn: period_kind: str | None, label_confidence: Decimal | None (Numeric(5,2));
    • FinancialStatementMeta: form_variant, needs_review_fields (jsonb), header_source (jsonb), organization_name_original_value, organization_name_modified;
    • Document.file_ordinal: int (SmallInteger, server_default 0); uq_document_file_type_account — unique, postgresql_nulls_not_distinct=True, postgresql_where=document_type NOT IN ('BALANCE', 'INCOME'); uq_document_stored_file_id_file_ordinal (stored_file_id, file_ordinal, unique);
    • RecognitionJob.statements_version: str | None; RelatedGroup.crm_id: str | None (unique=True);
    • DocumentIssue (document_issue): поля DBML, ix_document_issue_document_id.
  • Produces (репозиторий): delete_file_statements(session, file_id) удаляет и document_issue документов файла (первым).

  • Produces (фабрика): make_document(...) без явного file_ordinal берёт следующий свободный номер в файле.

  • Step 1: Write the failing test — test_upgrade_0007_backfills_v5_19_columns в test_migrations.py (по образцу test_upgrade_0006_backfills_consolidation_id): на ревизии 0006_dbml_v5_18 вставить ЮЛ, файл, INCOME (created_at = t0) и BALANCE (t1 > t0), шапку ББ с inn_original_value = "7701234567", inn_confidence = 87, report_date_original_value = "30.09.2025", units_original_value = "тыс. руб.", organization_name = "ООО Ромашка"; колонки c1 (period_date = 2025-09-30) и c2 (NULL); строки 1600 и 1100 (оригинал = код) и 1250 (line_code_original_value = NULL, сумма 5); значения: (conf 100, rec true, mod false), (conf 80, rec true, mod false), (conf 80, mod true), (conf NULL, rec false), (string_value "12 3"); ГСК без crm_id. Затем upgrade head и проверки:

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 ревизии:
-- 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. В каждом:

with pytest.raises(AssertionError) as error:
    await alembic(scratch_db_url, "downgrade", V5_18)
assert "откат v5.19 невозможен: повтор кода строки в форме: 1" in str(error.value)
assert await _scalars(conn, f"SELECT version_num FROM {SCHEMA}.alembic_version") == ["0007_dbml_v5_19"]
assert await count(conn, "financial_statement_line") == lines_before  # данные не изменены

test_downgrade_0007_drops_signals_and_logs_counts: две ячейки is_confirmed = true, одна запись document_issue, без несовместимых данных → откат проходит, в выводе alembic financial_statement_value.is_confirmed: 2 и document_issue: 1; после отката колонок v5.19 нет, вставка второй строки с тем же кодом в форму — IntegrityError; upgrade head снова проходит.

  • Step 2: make migration-cycle — новые тесты FAIL.
  • Step 3: Implement downgrade(): пять SELECT count(*) до любого DDL; затем журнал; затем удаление таблицы document_issue, индексов и колонок v5.19, восстановление uq_financial_statement_line_meta_id_line_code (unique) и полного uq_document_file_type_account (NULLS NOT DISTINCT), удаление enum'ов.
  • Step 4: make migration-cycle — PASS (полный цикл upgrade → downgrade base → upgrade тоже).
  • Step 5: Commit feat(migrations): откат v5.19 с отказом на несовместимых данных и журналом потерь.

Task A3: crm_id ГСК/ГСЗ

Files:

  • Modify: api/src/services/related_groups.py, api/src/schemas/directories.py, api/src/common/constraint_conflicts.py, README.md («Ошибки API»)
  • Test: api/tests/test_api_related_group_crm_id.py (переписать: сейчас проверяет «принимается и отбрасывается»)

Interfaces:

  • Consumes: RelatedGroup.crm_id (A1).

  • Produces: RelatedGroupCreate.crm_id / RelatedGroupUpdate.crm_id сохраняются (пустая строка после обрезки — None; явный null в PATCH очищает); RelatedGroupOut.crm_id и RelatedGroupDetail.crm_id — из БД; конфликт — 409 CRM_ID_EXISTS «ГСК/ГСЗ с таким crm_id уже существует» (проверка до вставки и запись uq_related_group_crm_id в CONSTRAINTS на гонку); аудит изменения — P3-18.

  • Step 1: Write the failing tests: test_create_related_group_stores_crm_id (POST с crm_id = " 51-03-0001 " → 201, crm_id == "51-03-0001", GET деталей отдаёт его); test_patch_related_group_crm_id_audited (смена → событие GROUP_CHANGE, before_value["crm_id"] is None, after_value["crm_id"] == "77-02-0002"); test_crm_id_taken_is_409 (вторая ГСЗ с тем же crm_id, в т. ч. в другой ГК → 409 CRM_ID_EXISTS, групп не прибавилось); test_patch_crm_id_null_clears.

  • Step 2: cd api && .venv/bin/pytest tests/test_api_related_group_crm_id.py -v — FAIL.

  • Step 3: Implement — по образцу CompanyGroupService._check_crm_id_free; docstring схем — без «принимается и отбрасывается».

  • Step 4: тот же запуск — PASS; make check — PASS.

  • Step 5: Commit feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112).

Task A4: Очистка форм перед выкладкой purge_statements.py

Files:

  • Create: api/scripts/purge_statements.py
  • Modify: README.md (раздел «Выкладка v5.19: очистка форм»)
  • Test: api/tests/test_purge_statements.py (маркер integration)

Interfaces:

  • Consumes: files_repo.delete_file_rows, files_repo.expire_s3_objects, statements_repo.delete_file_statements (уже чистит document_issue, A1), consolidation_documents.bound_versions / unbind_documents, AuditService.record, ex_snapshot (source_version_id).

  • Produces:

    • @dataclass PurgePlan: form_only_files: list[uuid.UUID], mixed_files: list[uuid.UUID], form_documents: int, confirmed_documents: int, unverified_bindings: int, blocking: list[BlockingVersion] (version_id, status, document_id), superseded_with_osv: list[uuid.UUID] (файлы, заменённые удаляемым файлом, в которых есть ОСВ);
    • async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan — только чтение;
    • async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None;
    • def render_report(plan: PurgePlan) -> str;
    • async def run(argv: Sequence[str]) -> int — --dry-run (по умолчанию), --apply, --include-mixed; коды P3-13; запуск cd api && .venv/bin/python -m scripts.purge_statements [--apply] [--include-mixed], одна транзакция.
  • Step 1: Write the failing tests (БД теста, фабрики tests/factories.py):

    • test_dry_run_reports_and_changes_nothing: файл только с ББ+ОФР (ББ CONFIRMED, привязан к версии INITIATED), смешанный файл (ОСВ + ББ), файл только с ОСВ → run([]) → код 0; в отчёте строки файлов только с формами: 1, смешанных файлов: 1, документов форм: 3, подтверждённых документов: 1, привязок к неверифицированным версиям: 1; число строк всех таблиц не изменилось.
    • test_apply_refuses_verified_binding: ББ привязан к версии VERIFIED → run(["--apply"]) → код 1, в выводе id версии и VERIFIED; база не изменена. То же для документа в ex_snapshot_item.document_id и для версии ARCHIVED, у которой есть ex_snapshot с её source_version_id.
    • test_apply_ignores_unverified_versions (сценарий «Форма в версии на согласовании или отклонённой», P3-12): ББ привязан к версии VERIFICATION_RF, ОФР — к REJECTED, ещё одна форма — к CANCELLED и к ARCHIVED без ex_snapshot → run(["--apply"]) → код 0; привязки сняты, формы удалены, статусы всех версий прежние.
    • test_apply_deletes_form_only_file: файл с ББ, ОФР, DISCLOSURE, комментарием к ББ, заданием; файл old с superseded_by_file_id = удаляемый → после --apply: файла, документов, financial_statement_*, комментариев, заданий нет; old.superseded_by_file_id is None, old.status == SUPERSEDED; s3_object.expires_at выставлен; привязка снята, статус версии не изменился; событие FILE_DELETE с user_id is None, description == "очистка форм перед v5.19", before_value — карточка файла.
    • test_apply_mixed_keeps_osv_by_default: смешанный файл → удалены только BALANCE/INCOME с financial_statement_*, комментариями и привязками; файл, ОСВ (osv_entry), задания остаются; событие FILE_DELETE по файлу с after_value["deleted_documents"] — список id; --apply --include-mixed — файл удалён целиком.
    • test_osv_and_directories_untouched: число строк osv_entry, consolidation*, correction, legal_entity, audit_event (кроме новых FILE_DELETE) не изменилось.
    • test_reupload_after_purge_not_duplicate (сценарий «Очистка и повторная загрузка»): после --apply PrimaryFileService.find_duplicate(entity_id, sha256 удалённого файла) — None.
  • Step 2: cd api && .venv/bin/pytest -m integration tests/test_purge_statements.py -v — FAIL.

  • Step 3: Implement. Отбор: документы BALANCE/INCOME у файлов любого статуса. Отказ — P3-12 и ex_snapshot_item. Блокировки — порядок файл → документы → версии (bound_versions). Файлы только с формами — delete_file_rows (без запрета FILE_HAS_CONFIRMED_DOCUMENTS), до него — обнуление superseded_by_file_id ссылающихся файлов и unbind_documents; объекты S3 — expire_s3_objects по префиксам файла, как в PrimaryFileService.delete. КФО в ядре ещё нет — пересчитывать нечего (написать в docstring).

  • Step 4: тот же запуск — PASS; make check — PASS (mypy src scripts).

  • Step 5: Commit feat(scripts): purge_statements — очистка форм ББ/ОФР перед выкладкой v5.19 (Р-24, Р-27).

Task A5: Контракт 2.0 — копии, модели, приём конвертов, JobBrief.warnings

Files:

  • Modify: api/contracts/** (копии docs), api/src/contract/common.py, api/src/contract/message.py, api/src/contract/__init__.py, api/src/services/recognition_exchange.py, api/src/services/documents.py (job_brief), api/src/schemas/files.py
  • Create: api/src/contract/result_v2.py, api/src/contract/versions.py
  • Test: api/tests/test_contract.py, api/tests/test_recognition_inbox.py, api/tests/test_api_files_read.py

Interfaces:

  • Consumes: тест идентичности плана 0 (api/tests/contract_identity.py).

  • Produces:

    • versions.major(schema_version: str) -> int; versions.parse_message(data: dict[str, Any]) -> RecognitionResultMessage | RecognitionResultMessageV2 (MAJOR 1 / 2, иное — ValidationError); versions.parse_result(content: bytes) -> RecognitionResult | RecognitionResultV2 (то же);
    • result_v2: FileWarning {code, severity: Literal["error", "info"], message, locator?}, DocumentWarning {code, severity, message, line_refs?, column_index?, field?}, RecognitionResultV2, StatementDocumentV2 (document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"], form_variant, status, warnings, header, columns, lines, checks?), StatementHeaderV2 (10 полей + <поле>_text, <поле>_confidence, needs_review_fields), StatementColumnV2, StatementLineV2, StatementValueV2, StatementChecksV2, FailedRelationV2, OsvDocumentV2 (поля OsvDocument + status, warnings), MetricsV2 (warnings: list[FileWarning]) — поля и обязательность по recognition-result.schema.json v2.0;
    • RecognitionResultMessageV2 — конверт v2.0 (metrics: MetricsV2);
    • recognition_job.metrics.warnings пишется списком объектов {code, severity, message, locator}: у конверта 2.0 — как пришли, у 1.x — каждая строка как {"code": "LEGACY_WARNING", "severity": "info", "message": s, "locator": None};
    • schemas.files.FileWarningOut {code: str, severity: Literal["error", "info"], message: str, locator: str | None}; JobBrief.warnings: list[FileWarningOut]; JobBrief.mapping_warnings — по-прежнему list[str];
    • documents.file_warnings(value: Any) -> list[FileWarningOut] — строки → LEGACY_WARNING, объекты с code, severity ∈ {error, info}, message — как есть, прочее пропускается.
  • Step 1: скопировать из ../docs/contracts/ (worktree docs/ зеркала) в api/contracts/: основные схемы (результат и конверт v2.0, запрос v1.0), recognition-result.v1.schema.json, recognition-result-message.v1.schema.json, examples/*.json, examples/v1/*.json. make check — тест идентичности PASS, тесты примеров FAIL (модели v1).

  • Step 2: Write the failing tests: таблица примеров test_contract.py разделена на v2 (examples/*.json → схемы и модели v2) и v1 (examples/v1/*.json → схемы .v1 и модели v1); test_parse_message_by_major ("1.0" → RecognitionResultMessage, "2.0" → RecognitionResultMessageV2, "3.0" → ValidationError); test_inbox_ack_v2_moves_job_to_processing (ack processing со schema_version "2.0" → задание processing); test_inbox_v1_warnings_wrapped (конверт 1.0 с metrics.warnings = ["sheet=ОСВ 60 строки без родителя"] → job.metrics["warnings"] == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", "locator": None}]); test_inbox_v2_warnings_kept (JSON_FAILED, error, locator "file:a.json" → как пришло); test_job_brief_converts_legacy_strings (в БД metrics.warnings = ["sheet=ОСВ 60 строки без родителя"] → FileOut.job.warnings == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", "locator": None}], БД не изменилась); test_job_brief_mixed_and_garbage_warnings (["a", {"code": "JSON_FAILED", "severity": "error", "message": "m", "locator": "x"}, 5, {"code": "X"}] → два элемента: LEGACY_WARNING «a» и JSON_FAILED; metrics.warnings = "строка" → []) — Review Focus 5.

  • Step 3: make check — новые тесты FAIL.

  • Step 4: Implement. SCHEMA_VERSION_PATTERN моделей v1 остаётся ^1\.\d+$; у v2 — ^2\.\d+$; RESULT_SCHEMA_VERSION_PATTERN конверта v2 — ^\d+\.\d+$. recognition_exchange._parse_message → parse_message; _apply_ack и применение итога принимают оба класса конверта (общие поля одинаковы); _metrics_json оборачивает строки 1.x. _load_result в result_mapping.py в части А не меняется (файл 2.0 до части Б не мапится — MappingFatal «результат не соответствует схеме»).

  • Step 5: make check — PASS.

  • Step 6: Commit feat(contract): копии контракта обоих MAJOR, модели v2.0, приём конвертов 1.x/2.0, предупреждения файла структурами.

Task A6: Словарь имён с синонимами и совпадение наименований

Files:

  • Modify: api/src/domain/statement_lines.py, api/rules/statement_lines.yaml
  • Create: api/src/domain/statement_names.py
  • Test: api/tests/test_domain_statement_lines.py, api/tests/test_domain_statement_names.py

Interfaces:

  • Produces:

    • LineCatalog.template_lines(edition: str | None, form: str) -> tuple[TemplateLine, ...] — по одной строке на код, первое имя, порядок первого появления (заменяет lines(); вызывающие — services/statements.py);
    • LineCatalog.reference_name(edition: str | None, form: str, code: str) -> str | None — первое имя кода (P3-11);
    • LineCatalog.names(form: str, code: str) -> tuple[str, ...] — все имена кода во всех редакциях;
    • LineCatalog.edition_codes(edition: str, form: str) -> frozenset[str];
    • загрузчик не запрещает повтор кода в редакции; код — четыре цифры, как сейчас;
    • statement_names.normalize(text: str) -> str; statement_names.section_number(normalized: str) -> str | None; statement_names.names_match(recognized: str | None, references: Iterable[str]) -> bool (правило спеки «Совпадение наименований»: нижний регистр, ё → е, всё кроме букв и цифр — пробел, схлопывание, strip; равенство, или более короткая (≥ 5 символов) входит в более длинную, или difflib.SequenceMatcher(None, a, b).ratio() >= 0.8; «итого по разделу» — сравнение номеров разделов из букв i, v, x (кириллические і, х → латиница), остаток слова отбрасывается; номер не выделен — только равенство строк; пустое имя или нет справочных — False).
  • Step 1: Write the failing tests:

@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 (домен):

def test_repeated_code_sums():
    agg = aggregate_by_code([ok("2410", r=1, c1="100"), ok("2410", r=5, c1="649")])
    assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5)
def test_empty_amounts_stay_null():
    assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1=None)])["1110"].amounts[C1] is None
    assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5")
def test_non_ok_lines_excluded():
    agg = aggregate_by_code([line("detail", None, r=2, c1="7"), line("unreadable", None, r=3, c1="9"),
                             line("missing", None, r=4, c1="1"), ok("1250", r=5, c1="3")])
    assert list(agg) == ["1250"]
def test_template_line_counts_as_ok():
    agg = aggregate_by_code([ok("1250", r=None, c1="10")])
    assert agg["1250"].amounts[C1] == Decimal("10") and agg["1250"].has_source_line is False

и API: test_cross_form_uses_aggregate (ББ: в колонке отчётной даты две строки 1370 — исходная с 500 и добавленная строка ok с row_index = 99, 300; ОФР с 2400 = 800 − 1370(нач. года) → checks.cross_form is None; при 2400 = 500 − 1370(нач. года) → cross_form с expected/actual по 800) — Review Focus 2; test_parent_code_with_repeated_parent (две строки 1150, подстрока 11501 → parent_code == "1150" один раз); test_template_not_completed_for_code_with_ok_line и test_template_completed_for_code_only_unreadable (у формы нет строки ok 1250, есть unreadable «ИЧ,250» → в ответе достроенная пустая 1250 с id = null и строка unreadable отдельно); test_patch_by_line_code_with_ok_line_is_unknown_cell (код с существующей строкой ok → 422 UNKNOWN_CELL); test_origin_by_row_index (строка источника с line_code_original_value = null, row_index = 7 → origin == "source").

  • Step 2: cd api && .venv/bin/pytest tests/test_domain_statement_aggregate.py tests/test_api_statements.py -v — FAIL.
  • Step 3: Implement; удалить statement_amounts и has_lines — «есть строки» = есть строка с row_index IS NOT NULL (repo.has_source_lines); load_form — строки по line_code, row_index NULLS LAST, id.
  • Step 4: тот же запуск и make check — PASS (старые тесты test_api_statements.py про шаблонные строки зелёные).
  • Step 5: Commit feat(statements): агрегат формы по коду — проверки, межформенная, parent_code, достройка, правка по коду.

Task A8: Ответ GET …/statement и DocumentOut.issues по v5.19

Files:

  • Create: api/src/domain/statement_layout.py
  • Modify: api/src/schemas/statements.py, api/src/schemas/documents.py, api/src/schemas/files.py (DocumentBrief), api/src/services/statements.py (build_out), api/src/services/documents.py (document_out, file_out — issue_counts рядом с counts комментариев), api/src/services/primary_files.py (сборка FileOut.documents), api/src/repositories/statements.py (issues, issue_counts_by_document), api/src/domain/statement_lines.py (LineCatalog.edition_lines)
  • Test: api/tests/test_domain_statement_layout.py, api/tests/test_api_statements.py, api/tests/test_api_documents.py, api/tests/test_api_files_read.py, api/tests/test_domain_statement_lines.py

Interfaces:

  • Consumes: CodeAggregate (A7), LineCatalog.reference_name (A6).

  • Produces:

    • @dataclass(frozen=True) LayoutLine: key: Hashable, row_index: int | None, line_status: str, line_code: str | None, parent_row_index: int | None; order_lines(lines: Sequence[LayoutLine]) -> list[Hashable] — P3-17;
    • repo.issues(session, document_id) -> list[DocumentIssue] (по created_at, id);
    • IssueOut {id, kind, code, severity, message, line_ids: list[uuid.UUID], column_id, column_index, field, source, relation, codes: list[str], expected, actual} — общий для StatementOut.issues и DocumentOut.issues;
    • StatementDocumentOut.form_variant; StatementHeaderOut.needs_review_fields: list[str], source: dict[str, HeaderSourceOut] (= header_source или {}; HeaderSourceOut {text: str | None, confidence: Decimal | None} — число из JSONB приводится Decimal(str(x)).quantize(Decimal("0.01")) и отдаётся строкой, как прочие уверенности API, P3-20), organization_name_modified: bool, okei: str | None (financial_statement_meta.okei, P3-21); ColumnOut.period_kind, label_confidence; LineOut + row_index, source_ref, line_status, line_code_text, line_code_confidence: Decimal | None (строка), reference_name, parent_row_index, needs_review, is_confirmed, line_code_modified; ValueOut + text (source_text), needs_review, is_confirmed; FailedCheckOut + codes, line_ids; StatementOut.issues: list[IssueOut];
    • StatementOut.edition_lines: list[EditionLineOut], EditionLineOut {code: str, name: str} — справочник строк для подсказки кода (P3-22): строки словаря имён формы document.document_type в редакции ответа (edition), по одной на код, имя — первое (как reference_name), в порядке словаря; edition is None — строки всех редакций словаря по порядку редакций, по одной на код (имя — из первой редакции, где код есть); LineCatalog.edition_lines(form: str, edition: str | None) -> list[tuple[str, str]];
    • DocumentOut.issues: list[IssueOut] (у любого документа); DocumentOut.warnings остаётся;
    • DocumentBrief.issue_counts: IssueCountsOut {error: int, info: int} (а значит и DocumentOut.issue_counts) — число записей document_issue документа по severity, всех видов (warning, relation, soft), P3-23; repo.issue_counts_by_document(session, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, IssueCounts] — один запрос GROUP BY document_id, severity на список документов (как comments_count_by_document); документа нет в ответе — {error: 0, info: 0}.
  • Step 1: Write the failing tests:

def test_order_lines_attachments():
    lines = [L("m0", 0, "missing", None, None), L("1230", 1, "ok", "1230", None), L("u2", 2, "unreadable", None, None),
             L("1210", 3, "ok", "1210", None), L("d4", 4, "detail", None, 3), L("t1250", None, "ok", "1250", None),
             L("2410b", 9, "ok", "2410", None), L("2410a", 6, "ok", "2410", None)]
    assert order_lines(lines) == ["m0", "1210", "d4", "1230", "u2", "t1250", "2410a", "2410b"]
def test_detail_without_parent_follows_previous_ok():
    assert order_lines([L("1110", 0, "ok", "1110", None), L("d1", 1, "detail", None, None)]) == ["1110", "d1"]

API: test_get_statement_v5_19_fields_on_1x_form (форма из фикстуры 1.2: у строк line_status == "ok", row_index — целые с 0, source_ref is None, origin == "source", needs_review is False, is_confirmed is False; у значений text == string_value; document.form_variant is None; header.needs_review_fields == []; header.organization_name_modified is False; columns[].period_kind ∈ {"absolute", "none"}; issues == []); test_reference_name_first_name (fsbu4, строка 1520 → reference_name == "Кредиторская задолженность"); test_issues_in_statement_and_document (вставленная запись document_issue warning COLUMN_SKIPPED с column_index = 5, column_id = null → она в StatementOut.issues и DocumentOut.issues с этими полями); test_line_code_confidence_and_okei (строка с line_code_confidence = 67.50 → "67.50"; meta.okei = "384" → header.okei == "384"); test_header_source_confidence_is_decimal_string (header_source = {"inn": {"text": "x", "confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}} → header.source.inn.confidence == "61.54", header.source.units.confidence == "100.00"; confidence: null → null); test_edition_lines (форма fsbu4 ББ → edition_lines — коды редакции fsbu4 ББ словаря имён по одному, 1520 с именем «Кредиторская задолженность», 1120 нет; форма без даты (edition is None) → коды обеих редакций, 1120 есть, повторов кода нет); test_issue_counts_in_file_and_document (у ББ записи warning error, relation error, soft info, у ОФР нет → в GET /files/{id} documents[]: ББ issue_counts == {"error": 2, "info": 1}, ОФР {"error": 0, "info": 0}; GET /documents/{id} — те же числа; число SQL-запросов на GET /files не растёт с числом документов).

  • Step 2: запуск трёх файлов — FAIL.
  • Step 3: Implement; порядок lines ответа — order_lines (достроенные шаблонные — как строки ok без row_index).
  • Step 4: запуск и make check — PASS.
  • Step 5: Commit feat(api): StatementOut и DocumentOut по v5.19 — статусы строк, needs_review, issues, порядок строк.

Task A9: Маппер 1.x — поля v5.19, file_ordinal, вторая форма, MAPPING_WARNING

Files:

  • Create: api/src/domain/statement_issues.py
  • Modify: api/src/domain/statement_mapping.py, api/src/repositories/statements.py (insert_statement, add_issues), api/src/services/result_mapping.py (map_file, _map_statement_docs, _insert_statements)
  • Test: api/tests/test_domain_statement_mapping.py, api/tests/test_mapping_statements.py, api/tests/test_result_mapping.py

Interfaces:

  • Produces:

    • @dataclass(frozen=True) IssueDraft: kind: DocumentIssueKind, code: str, severity: IssueSeverity, message: str, source: IssueSource, line_ids: tuple[uuid.UUID, ...] = (), column_id: uuid.UUID | None = None, column_index: int | None = None, field: str | None = None, relation: str | None = None, codes: tuple[str, ...] = (), expected: Decimal | None = None, actual: Decimal | None = None; mapping_warning(message: str) -> IssueDraft;
    • repo.add_issues(session, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now) -> None;
    • LineDraft + row_index: int (индекс строки в doc.lines результата), line_status = "ok", line_code_original_value, source_ref = None, line_code_text = None, needs_review = False; ValueDraft + source_text (= string_value), needs_review (= НЕ value_clean(confidence, is_recognized)); колонки + period_kind (absolute при period_date, иначе none), label_confidence = None; шапка + header_source (10 полей шапки результата: text из одноимённого поля 1.x, у report_date — report_date_text, confidence — где есть в 1.x, у okei оба None), organization_name_original_value, organization_name_modified = False, needs_review_fields = [], form_variant = None;
    • value_clean(confidence: Decimal | None, is_recognized: bool | None) -> bool (выделено из values_ok);
    • каждый создаваемый документ — file_ordinal = индекс в result.documents (P3-6 для DISCLOSURE/OTHER);
    • предупреждения черновика формы → MAPPING_WARNING документа; предупреждения листа ОСВ — тоже (P3-5); Р-10 — P3-4.
  • Step 1: Write the failing tests:

    • test_two_osv_sheets_file_ordinals — результат 1.x с ОСВ 60 и 62 → file_ordinal 0 и 1, файл PROCESSED;
    • test_pdf_balance_income_1x_ordinals — 1.2 с BALANCE, INCOME → 0 и 1;
    • test_duplicate_osv_sheet_takes_no_ordinal — ОСВ 60, ОСВ 60, ББ → 0 и 2;
    • test_1x_line_fields — строки 0..n: row_index == i, line_status == "ok", line_code_original_value == line_code, source_ref is None; значения confidence 100, is_recognized true → needs_review is False, confidence 80 → True (сценарий «Полярность needs_review при приёме 1.x»); source_text == string_value;
    • test_1x_duplicate_code_keeps_first — повтор 2410 в 1.2: хранится первая строка, row_index второй не занят, document_issue MAPPING_WARNING с текстом о повторе;
    • test_1x_second_form_same_period_stored (заменяет test_duplicate_form_in_file_skipped) — два BALANCE на 30.09.2025 → два документа, оба NEEDS_CHECK, у каждого MAPPING_WARNING «в файле есть другая форма BALANCE за тот же период», ни один не CONFIRMED — Review Focus 1;
    • test_1x_second_form_other_period — BALANCE на 30.09.2025 и на 31.12.2024 → у каждого свой статус, предупреждения о повторе нет;
    • test_osv_orphans_mapping_warning_issue — лист с сиротами → у документа ОСВ MAPPING_WARNING с текстом сирот, строка в mapping_warnings тоже есть;
    • test_1x_header_source — header_source["inn"] == {"text": inn, "confidence": inn_confidence}, header_source["report_date"]["text"] == report_date_text.
  • Step 2: запуск трёх файлов — FAIL.

  • Step 3: Implement; статус форм 1.x — D-6, как сейчас (values_ok = нет needs_review у значений; warnings = предупреждения черновика + повтор периода); insert_statement ключует строки по row_index, а не по коду.

  • Step 4: запуск, make check и cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py tests/test_mapping_golden.py — PASS (golden-снимки ОСВ и все снимки форм 1.x маппятся).

  • Step 5: Commit feat(mapping): маппер 1.x пишет поля v5.19, file_ordinal, хранит вторую форму, MAPPING_WARNING в document_issue.

Task A10: Условие RECOGNIZED и статус ОСВ при приёме 2.0 — чистые функции

Files:

  • Create: api/src/domain/statement_status.py
  • Test: api/tests/test_domain_statement_status.py

Interfaces:

  • Consumes: names_match, LineCatalog.names (A6).

  • Produces (подключаются в B2, B4, B5):

    • has_error_warning(warnings: Any) -> bool — есть элемент-объект с severity == "error"; строки и мусор — нет;
    • duplicate_period_forms(forms: Iterable[tuple[Hashable, DocumentType, int | None, PeriodQuarter | None]]) -> set[Hashable] — ключи форм BALANCE/INCOME, у которых в файле есть другая форма того же типа с той же парой (год, квартал); формы без квартала не считаются;
    • @dataclass(frozen=True) NameCheckLine: key, line_status, line_code, line_code_confidence: Decimal | None, line_name: str | None, code_fixed: bool; name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, form: str) -> list[Hashable] — строки ok с line_code_confidence < 100, без code_fixed, чьё имя не совпало ни с одним catalog.names(form, code);
    • form_status_v2(*, service_done: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, period_known: bool, names_ok: bool, duplicate_in_file: bool) -> DocumentStatus;
    • osv_status_v2(*, osv_done: bool, checks_ok: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, sheet_mapping_warnings: bool) -> DocumentStatus.
  • Step 1: Write the failing tests:

OK = dict(service_done=True, file_error_warning=False, counterparty=DocumentCheckResult.MATCH,
          period_known=True, names_ok=True, duplicate_in_file=False)
def test_form_recognized(): assert form_status_v2(**OK) is DocumentStatus.RECOGNIZED
@pytest.mark.parametrize("change", [{"service_done": False}, {"file_error_warning": True},
    {"counterparty": DocumentCheckResult.MISMATCH}, {"period_known": False}, {"names_ok": False},
    {"duplicate_in_file": True}])
def test_form_needs_check(change): assert form_status_v2(**OK | change) is DocumentStatus.NEEDS_CHECK
def test_unknown_counterparty_allowed():
    assert form_status_v2(**OK | {"counterparty": None}) is DocumentStatus.RECOGNIZED
def test_duplicate_period_forms():
    forms = [(1, BAL, 2025, Q3), (2, BAL, 2025, Q3), (3, BAL, 2024, Q4), (4, INC, 2025, Q3), (5, BAL, 2025, None),
             (6, BAL, 2025, None)]
    assert duplicate_period_forms(forms) == {1, 2}
def test_has_error_warning():
    assert has_error_warning([{"code": "JSON_FAILED", "severity": "error", "message": "m"}])
    assert not has_error_warning([{"code": "LEGACY_WARNING", "severity": "info", "message": "m"}, "строка"])
    assert not has_error_warning(None)

osv_status_v2: все условия — RECOGNIZED; каждое нарушенное — NEEDS_CHECK (параметризация, как у форм). name_mismatches по fsbu4: 1120 (50) «Нематериальные активы» → в списке; 1220 (50) «НДС, начисленный по отгрузке» → нет; 1200 (50) «Итого по разделу I» → в списке; 1500 (56) «Итого по разделу V1» → нет; 1105 (67) «АКТИВ I. Внеоборотные активы Гудвил» → нет; 1120 (50) с code_fixed → нет; 1110 (100) с чужим именем → нет; 9999 (50) → в списке (кода нет в словаре); пустое имя (50) → в списке.

  • Step 2: cd api && .venv/bin/pytest tests/test_domain_statement_status.py -v — FAIL.
  • Step 3: Implement (docstring — §9.3 «Документ формы при приёме контракта v2.0», «Документ ОСВ при приёме контракта v2.0»).
  • Step 4: запуск и make check — PASS.
  • Step 5: Commit feat(domain): условие RECOGNIZED для 2.0 (Р-16, Р-18, Р-19) и статус ОСВ (Р-28).

Task A11: Подтверждение формы — порядок 409 и автоподтверждение

Files:

  • Modify: api/src/services/statements.py (form_confirmable → form_blocker), api/src/services/documents.py (_check_statement), api/src/services/auto_confirm.py, api/src/repositories/statements.py, api/src/repositories/files.py (confirmed_twin), README.md
  • Test: api/tests/test_api_statements.py, api/tests/test_form_confirmable.py, api/tests/test_auto_confirm.py

Interfaces:

  • Consumes: агрегат и statement_hard_failures (A7).

  • Produces:

    • @dataclass(frozen=True) FormBlocker: code: str, message: str, extra: dict[str, Any];
    • async def form_blocker(session, document, meta) -> FormBlocker | None — первый по порядку PERIOD_REQUIRED → LINES_UNRESOLVED (extra = {"lines": [{"line_id", "row_index", "line_code_text"}]}, строки unreadable/missing, у которых хотя бы в одной ячейке amount или string_value не null, по row_index) → DUPLICATE_FORM (extra = {"document_id": <подтверждённая форма того же файла, типа, года, квартала>}) → CHECKS_NOT_PASSED (extra = {"failures": [...]}, как сейчас);
    • repo.unresolved_lines(session, meta_id) -> list[tuple[uuid.UUID, int | None, str | None]];
    • files_repo.confirmed_twin(session, document) -> uuid.UUID | None;
    • DocumentService._check_statement поднимает ConflictError(blocker.code, blocker.message, **blocker.extra); auto_confirm подтверждает форму, только если form_blocker(...) is None (form_confirmable удаляется).
  • Step 1: Write the failing tests (строки вставляются ORM-фабрикой на форме из фикстуры):

    • test_confirm_unresolved_line_with_amount — строка missing (row_index = 40) с суммой и жёсткое нарушение → 409 LINES_UNRESOLVED, detail.lines == [{"line_id": id, "row_index": 40, "line_code_text": None}];
    • test_confirm_unresolved_line_with_text_only — unreadable, line_code_text = "ИЧ,250", суммы пустые, string_value = "211(1 |" → 409 LINES_UNRESOLVED;
    • test_unresolved_line_without_values_does_not_block — unreadable без значений → подтверждение проходит (при прочих условиях);
    • test_confirm_duplicate_form — два ББ на 30.09.2025 в файле, первый CONFIRMED → второй: 409 DUPLICATE_FORM, detail.document_id == str(first.id) — Review Focus 1;
    • test_conflict_order — PERIOD_REQUIRED раньше LINES_UNRESOLVED; LINES_UNRESOLVED раньше DUPLICATE_FORM; DUPLICATE_FORM раньше CHECKS_NOT_PASSED;
    • test_auto_confirm_skips_blocked_form — форма RECOGNIZED со строкой missing с суммой → остаётся RECOGNIZED.
  • Step 2: запуск трёх файлов — FAIL.

  • Step 3: Implement; README «Ошибки API» — LINES_UNRESOLVED, DUPLICATE_FORM.

  • Step 4: запуск и make check — PASS.

  • Step 5: Commit feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM.

Task A12: PATCH …/statement — подтверждение ячейки, дата колонки, шапка

Files:

  • Modify: api/src/schemas/statements.py (CellPatch, HeaderPatch, ColumnPatch, StatementPatch), api/src/services/statements.py (patch, _apply_cells, _apply_header, _apply_columns, recompute), api/src/domain/statement_issues.py (правила снятия), api/src/repositories/statements.py (delete_issues), README.md
  • Test: api/tests/test_api_statements.py

Interfaces:

  • Consumes: IssueDraft, repo.add_issues (A9), агрегат (A7).

  • Produces:

    • CellPatch: line_id | line_code, column_id, amount (необязательно, наличие — model_fields_set), confirmed: bool | None; ровно одно из amount/confirmed (иначе 422 pydantic); confirmed только с line_id (иначе 422 pydantic);
    • HeaderPatch.organization_name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None;
    • ColumnPatch {column_id: uuid.UUID, period_date: datetime.date} (null — 422 pydantic); StatementPatch.columns: list[ColumnPatch] (max_length=100);
    • statement_issues.HEADER_CLEARS: dict[str, frozenset[str]] = {"report_date": {"PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}, "inn": {"NO_ORGANIZATION_NO_INN"}, "organization_name": {"NO_ORGANIZATION_NO_INN"}}, COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"});
    • repo.delete_issues(session, document_id, *, codes: Collection[str], kind: DocumentIssueKind | None = None, source: IssueSource | None = None, column_id: uuid.UUID | None = None) -> int;
    • StatementService.recompute(document, meta) -> None — единая точка пересчёта после правки (заменяет recompute_status); до части Б: D-6 с values_ok = нет needs_review у значений строк не detail; в Б тело меняется (B2), сигнатура — нет;
    • сброс акцепта — только правкой данных (сумма, код, статус строки, дата колонки, поле шапки); подтверждения — нет.
  • Step 1: Write the failing tests:

    • test_confirm_cell_without_change — ячейка needs_review = true, акцепт стоит → values: [{line_id, column_id, confirmed: true}] → 200; is_confirmed, confirmed_by == user, confirmed_at задан, is_modified is False, needs_review is False, сумма прежняя, одно событие RECOGNIZED_DATA_EDIT, checks.accepted не null;
    • test_unconfirm_cell — снять (confirmed: false) с ячейки confidence 80 → is_confirmed is False, confirmed_by is None, needs_review is True, аудит;
    • test_cell_amount_and_confirmed_is_422, test_cell_confirmed_by_line_code_is_422 (массив), test_confirm_missing_cell_unknown (строка есть, записи значения нет → 422 UNKNOWN_CELL);
    • test_patch_organization_name — запись NO_ORGANIZATION_NO_INN, у шапки нет ИНН, имя владельца «ООО Ромашка» → header: {organization_name: "ООО Ромашка"} → organization_name_modified is True, organization_name_original_value прежнее, counterparty_check == "MATCH", записи нет, аудит before/after по organization_name, акцепт сброшен; "" и строка из 1001 символа — 422 массивом;
    • test_confirm_report_date — needs_review_fields = ["report_date"], запись REPORT_DATE_UNCERTAIN → тот же report_date → needs_review_fields == [], записи нет, аудит, акцепт сохранён; тот же report_date без needs_review_fields → 200, событий нет;
    • test_report_date_change_clears_warnings — записи PERIOD_UNKNOWN, REPORT_DATE_UNPARSED, REPORT_DATE_UNCERTAIN, SEGMENT_COLUMNS_MISMATCH → смена даты → остаётся только SEGMENT_COLUMNS_MISMATCH;
    • test_inn_change_clears_no_organization;
    • test_column_date — колонка без даты с COLUMN_WITHOUT_DATE (column_id), report_date = null → columns: [{column_id, period_date: "2024-12-31"}] → period_date, period_kind == "absolute", записи нет, is_current пересчитан, document.reporting_date == 2024-12-31 и квартал Q4 (§9.5 п. 2), акцепт сброшен;
    • test_column_date_null_is_422, test_unknown_column_is_422 (колонка другой формы → UNKNOWN_COLUMN);
    • test_confirm_column_date — COLUMN_DATE_UNCERTAIN, та же дата → записи нет, period_kind == "absolute", аудит, акцепт сохранён; та же дата без предупреждения → 200 без событий;
    • test_relative_column_becomes_absolute — колонка relative:prev_year_end → дата 31.12.2024 → period_kind == "absolute" (вторая половина сценария — B2);
    • test_segment_warning_stays_after_value_edit — SEGMENT_COLUMNS_MISMATCH остаётся после правки суммы, ручное подтверждение документа проходит.
  • Step 2: cd api && .venv/bin/pytest tests/test_api_statements.py -v — новые FAIL.

  • Step 3: Implement; порядок частей — P3-10; README «Ошибки API» — UNKNOWN_COLUMN.

  • Step 4: запуск и make check — PASS.

  • Step 5: Commit feat(statements): PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты.

Task A13: PATCH …/statement — строки (lines)

Files:

  • Create: api/src/services/statement_line_edits.py
  • Modify: api/src/schemas/statements.py (LinePatch, StatementPatch.lines), api/src/services/statements.py (patch), api/src/domain/statement_layout.py (nearest_ok_parents), api/src/repositories/statements.py (delete_line, remove_line_from_issues), README.md
  • Test: api/tests/test_api_statement_lines.py, api/tests/test_domain_statement_layout.py

Interfaces:

  • Consumes: StatementService.recompute, _editable, аудит (A12).

  • Produces:

    • LinePatch {line_id: uuid.UUID, line_code: str | None = None, line_status: Literal["detail", "ok"] | None = None, confirmed: bool | None = None} (extra="forbid"; line_code до 10 символов); StatementPatch.lines: list[LinePatch] (max_length=5000);
    • async def apply_line_edits(session, *, document, meta, items: Sequence[LinePatch], user_id, before: dict, after: dict) -> bool (возвращает «были правки данных» — для сброса акцепта): проверки до записи — INVALID_LINE_PATCH (не ровно одно действие; один line_id дважды), UNKNOWN_LINE, LINE_CODE_REQUIRED (line_status == "ok"), INVALID_LINE_CODE (не ^\d{4,5}$), затем LINE_CODE_TAKEN (строка row_index IS NULL, line_status = ok, с тем же кодом и хотя бы одной непустой amount; 409, ничего не изменено); у всех пяти ошибок detail несёт line_id и line_code первого неверного элемента lines[] по порядку запроса (line_id — как в элементе, у UNKNOWN_LINE — чужой id; line_code — код элемента или null; у LINE_CODE_TAKEN — строка, которой назначается код, и этот код; P3-24); затем запись: удаление пустых шаблонных строк назначаемых кодов (значения → строка → id из line_ids записей документа), назначение (line_status = ok, line_code, line_code_confidence = 100, line_code_modified = true, needs_review = false), отметка detail (line_code = null), подтверждение (P3-16), пересчёт parent_row_index всех строк detail;
    • nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None] — для строк detail: row_index ближайшей строки ok выше (строки без row_index родителями не бывают); временная, в B2 заменяется detail_parents пакета;
    • repo.remove_line_from_issues(session, document_id, line_id) -> None — line_ids = line_ids - '<id>' у всех записей документа.
  • Step 1: Write the failing tests:

    • test_assign_code_to_garbage_line (AURORA-1462) — unreadable «ИЧ,250», row_index = 2 → lines: [{line_id, line_code: "1250"}] → line_status == "ok", line_code == "1250", line_code_confidence == "100.00", line_code_modified is True, line_code_original_value is None (БД), row_index == 2, origin == "source", needs_review is False; её сумма вошла в агрегат 1250 — Review Focus 3;
    • test_same_code_two_source_lines — 1250 строке unreadable (row_index 7, без сумм), затем строке missing (row_index 2) с суммой → обе ok с 1250, обе в БД, 409 нет — Review Focus 3;
    • test_code_taken_by_template_with_amount — шаблонная 1250 (row_index = null) с суммой → lines: [{unreadable, line_code: "1250"}, {other, line_status: "detail"}], values: [...], header: {units: "руб."} → 409 LINE_CODE_TAKEN с detail.line_id строки unreadable и detail.line_code == "1250"; ни одна строка, ячейка и поле шапки не изменились, аудита нет — Review Focus 4;
    • test_code_taken_by_empty_template — шаблонная 1250 без сумм и запись document_issue с line_ids = [template, other] → шаблонной строки и её значений нет, line_ids == [other], строка источника ok 1250 — Review Focus 4;
    • test_mark_detail — missing с суммой → line_status == "detail", в агрегате её нет, подтверждение не даёт LINES_UNRESOLVED;
    • test_status_ok_without_code — line_status: "ok" → 422 LINE_CODE_REQUIRED, detail.line_id == id, detail.line_code is None;
    • test_invalid_line_patch — {line_id}; {line_id, line_code, confirmed}; один line_id дважды → 422 INVALID_LINE_PATCH, detail.line_id — id элемента (у повтора — повторённый id), detail.line_code — код элемента или None;
    • test_unknown_line — строка другой формы → 422 UNKNOWN_LINE, detail.line_id — этот id;
    • test_invalid_line_code — "123", "123456", "12a4" → 422 INVALID_LINE_CODE, detail == {code, message, line_id, line_code: "123"…}; "11101" → 200;
    • test_confirm_code — строка ok 1120, уверенность 50, needs_review = true, акцепт стоит → confirmed: true → is_confirmed is True, needs_review is False, акцепт сохранён; confirmed: false → is_confirmed is False (P3-16);
    • test_parent_moves_up (сценарий «Родитель уходит в расшифровки») — 1140 (row_index 3), 1150 (row_index 4), detail 5 и 6 с parent_row_index = 4 → 1150 отмечена detail → у трёх строк parent_row_index == 3; без строк ok выше — None;
    • test_line_edit_audit_and_accept — одно событие, before/after с ключом line:<id> (line_code, line_status, parent_row_index, is_confirmed); назначение кода сбрасывает акцепт;
    • домен: nearest_ok_parents — [ok r0, detail r1, ok(row None), detail r3] → {r1: 0, r3: 0}.
  • Step 2: запуск двух файлов — FAIL.

  • Step 3: Implement; README «Ошибки API» — LINE_CODE_TAKEN, LINE_CODE_REQUIRED, INVALID_LINE_CODE, INVALID_LINE_PATCH, UNKNOWN_LINE.

  • Step 4: запуск и make check — PASS.

  • Step 5: Commit feat(statements): PATCH lines — назначение кода, расшифровка, подтверждение кода, LINE_CODE_TAKEN.

Завершение части А

  • make check-full (Docker: make up, затем make migrate) — PASS; вывод — в описание PR.
  • Финальное ревью ветки (opus), таблица находок ## Findings в ledger, PR tea pulls create со ссылкой на issue А и arm-ka-docs#116; в описании — «Rulings I made», P3-n части А, O-1…O-10, «PR не выкладывается на контур без части Б».

Часть Б

Старт — после слияния PR части А и PR плана 2 (копия api/arm_statements/, mypy src scripts arm_statements, statements-check в check-full) в arm-core. Worktree части Б создаётся от origin/main с обоими слияниями.

Task B0: Change OpenSpec части Б в arm-core

Files:

  • Create: openspec/changes/unified-normalization-core-b/ (proposal.md, design.md, tasks.md, спеки-дельты osv-result-mapping, documents-api, statement-checks (REMOVED: «копия словаря §7 ядра»), recognition-job-exchange)

  • Step 1: proposal.md — требования спеки arm-core из колонки «Б» таблицы покрытия; design.md с разделом «Decisions made without discussion» (P3-n части Б).

  • Step 2: openspec validate unified-normalization-core-b --strict — без ошибок.

  • Step 3: Commit openspec(core-b): change единой нормализации, часть Б (arm-ka-docs#116).

Task B1: Адаптер пакета StatementsEngine и вход пересчёта из БД

Files:

  • Create: api/src/services/statements_engine.py, api/src/domain/stored_form.py, api/tests/fake_engine.py
  • Test: api/tests/test_domain_stored_form.py, api/tests/test_statements_engine.py

Interfaces:

  • Consumes: копия arm_statements (имена — раздел «Публичный API пакета» плана 1; вызовы ядра — раздел «Что ядро вызывает»).

  • Produces:

    • StatementsEngine (протокол) и default_engine() — как в разделе «Что ядро вызывает»; ArmStatementsEngine.version возвращает statements_version() (содержимое api/arm_statements/VERSION; нет файла — None), recompute / edition / code_order / editions / tolerance берут default_rules() пакета, contract_document_status — document_status(status_input_from_contract(document));
    • build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) -> StoredForm (типы пакета, перечисления — StrEnum: DocumentType, LineStatus, PeriodKind, Severity; уверенности — float(...), NULL значения is_recognized — False; period_kind = NULL — PeriodKind.NONE): effective_date = document.reporting_date; has_organization_or_inn — непустые meta.organization_name или meta.inn; period_known = document.reporting_date is not None; needs_review_fields = tuple(meta.needs_review_fields or ()); accepted = meta.checks_accepted_at is not None; warnings — записи kind = warning (code, severity); code_fixed = bool(line.line_code_modified) or line.is_confirmed; value_fixed = value.is_modified or value.is_confirmed; text = value.source_text; parent_key — ключ строки с row_index == line.parent_row_index (у detail), иначе None; key строки и колонки — str(uuid);
    • edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]] — пары (line_code, line_code_confidence == 100) строк ok результата по row_index (вход engine.edition); своего StatusInput ядро не собирает — вход статуса из документа результата строит пакет (status_input_from_contract, план 1 P1-27);
    • FakeEngine для модульных тестов: возвращает заданный RecomputeResult, заданные статус документа и редакцию, запоминает вход StoredForm.
  • Step 1: Write the failing tests: test_build_stored_form_maps_flags (шаблонная строка → row_index is None; правленая ячейка → value_fixed; подтверждённая ячейка → value_fixed; строка с назначенным кодом → code_fixed; detail с parent_row_index = 3 → parent_key строки row_index 3; warnings — только kind = warning); test_contract_document_status_matches_examples (реальный пакет: у каждого statementDocument из contracts/examples/result-statement-minimal.json и result-statement-excel.json — engine.contract_document_status(doc) == DocStatus(doc["status"]); у минимального — NEEDS_CHECK из-за строки unreadable); test_edition_codes_and_engine_edition (минимальный пример: пары в порядке row_index, строка unreadable не входит; engine.edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), FormVariant.FULL) — Edition; тот же ответ, что recompute формы, собранной из этого результата); test_engine_scenario_recompute_after_edit (реальный пакет, сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230 value_fixed, код 1250 code_fixed у бывшей unreadable, шаблонная 1260 с суммой, detail с уверенностью 60 → у 1230 и у значения detail needs_review False, у строки 1250 False, соотношение 1200 = … посчитано с 1250 и 1260; ожидаемый RecomputeResult.checks и value_needs_review — с ключами (str(line_id), str(column_id)), edition — Edition.FSBU4 или RAS_66N по дате); test_stored_form_types (уверенность Decimal("67.50") в БД → 67.5 типа float; is_recognized = NULL → False; period_kind = NULL → PeriodKind.NONE; line_status — LineStatus); test_engine_version_reads_file (engine.version == statements_version(); statements_version(tmp_path / "VERSION") без файла — None).

  • Step 2: cd api && .venv/bin/pytest tests/test_domain_stored_form.py tests/test_statements_engine.py -v — FAIL.

  • Step 3: Implement; ArmStatementsEngine кэшируется на процесс (functools.cache у default_engine).

  • Step 4: запуск и make check (в т. ч. mypy src scripts arm_statements) — PASS.

  • Step 5: Commit feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД.

Task B2: Пересчёт после правки общим кодом и §7 из document_issue

Files:

  • Create: api/src/services/statement_recompute.py
  • Modify: api/src/services/statements.py (recompute, checks_report, build_out, accept_checks, statement_hard_failures), api/src/services/documents.py, api/src/repositories/statements.py (replace_issues), api/src/schemas/statements.py
  • Test: api/tests/test_api_statements.py, api/tests/test_api_statement_lines.py, api/tests/test_statement_recompute.py

Interfaces:

  • Consumes: StatementsEngine, build_stored_form (B1), name_mismatches, form_status_v2, has_error_warning, duplicate_period_forms (A10), IssueDraft (A9).

  • Produces:

    • async def recompute_form(session, *, document, meta, engine: StatementsEngine, job: RecognitionJob | None) -> None: строит вход, вызывает engine.recompute, пишет needs_review строк и значений, meta.form_variant, parent_row_index строк detail (ключ → row_index), заменяет записи relation/soft (любого источника) и записи core по P3-8 (RecomputeResult пакета: form_variant: FormVariant | None, edition: Edition | None, checks, line_needs_review, value_needs_review, detail_parents, status: DocStatus), пишет STATEMENTS_VERSION_MISMATCH (info, core), если job.statements_version не None и не равен engine.version; статус (кроме CONFIRMED) — form_status_v2(service_done = result.status == DocStatus.DONE, file_error_warning = has_error_warning(job.metrics["warnings"]), counterparty, period_known = year и quarter, names_ok = нет NAME_MISMATCH, duplicate_in_file);
    • relations_to_issues(checks: StatementChecks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft] — relations_failed → RELATION_FAILED/error, soft_relations_failed → RELATION_SOFT/info (FailedRelation плана 1); column_id — по FailedRelation.column_key (при пересчёте из БД это str(column_id); при приёме 1.x колонки уже вставлены и ключ строится так же) — по column_index только для проверок сервиса 2.0 (column_key = str(column_index), B4); line_ids — все строки ok кодов codes; expected/actual — Decimal | None (None — цель или все члены пусты, план 1 P1-21; в запись — NULL); message — f"не сошлось {relation}: {expected} ≠ {actual}", None пишется словом «пусто»;
    • StatementService.recompute вызывает recompute_form (тело A12 удаляется, nearest_ok_parents A13 удаляется);
    • statement_hard_failures и checks.failed/soft_failed ответа — из записей relation/soft (с codes, line_ids); NOTHING_TO_ACCEPT — нет записей relation; правка header.report_date (в т. ч. null) пересчитывает period_date колонок с period_kind relative:* через engine.resolve_relative(PeriodKind(kind), effective_date) до пересчёта.
  • Step 1: Write the failing tests:

    • test_1x_form_after_patch_recomputed (сценарий «1.x-документ после PATCH») — форма 1.2 с несошедшимся 1200 = 1210 + … + 1260 (запись relation по 1200 есть после приёма, B3; до B3 — вставить запись вручную) → правка 1230 так, что сходится → записи relation по 1200 нет, статус пересчитан, STATEMENTS_VERSION_MISMATCH нет;
    • test_migrated_form_shows_violation_after_first_patch (сценарий «Миграция на непустой базе») — форма без записей document_issue с несошедшимся 1600 = 1700: GET — checks.failed == [], POST …/confirm — без CHECKS_NOT_PASSED; после любого PATCH — запись RELATION_FAILED с codes ["1600", "1700"];
    • test_unconfirm_uncovered_cell (сценарий «Снятие подтверждения») — ячейка, не покрытая соотношением → needs_review is True после пересчёта;
    • test_statements_version_mismatch — job.statements_version = "abc1234", engine.version = "def5678" → запись STATEMENTS_VERSION_MISMATCH после PATCH (FakeEngine.version / реальный statements_version()); job.statements_version = None — нет;
    • test_relative_column_follows_report_date — колонка relative:prev_year_end, PATCH header.report_date = 2025-09-30 → period_date == 2024-12-31; после ручной даты (A12) — не меняется; report_date = null → period_date is None у относительных;
    • test_checks_from_issues_with_codes_and_line_ids — checks.failed[0].codes и line_ids из записи;
    • test_one_patch_one_recompute — FakeEngine вызван один раз за PATCH из трёх частей, событие одно.
  • Step 2: запуск трёх файлов — FAIL.

  • Step 3: Implement.

  • Step 4: запуск и make check — PASS (тесты A12/A13 зелёные на общем коде).

  • Step 5: Commit feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue.

Task B3: Маппер 1.x — §7 при приёме и вариант формы (Р-30)

Files:

  • Modify: api/src/services/result_mapping.py (_insert_statements), api/src/services/statement_recompute.py
  • Test: api/tests/test_mapping_statements.py, api/tests/test_api_statements.py

Interfaces:

  • Consumes: engine.recompute (RecomputeResult.form_variant, .checks; .status здесь не используется), build_stored_form, relations_to_issues (B1, B2).

  • Produces: async def checks_on_intake_1x(session, *, document, meta, engine) -> bool — один вызов engine.recompute по вставленной форме; пишет meta.form_variant и записи relation/soft (source = core); возвращает «жёстких нарушений нет» — условие D-6 «проверки сошлись»; needs_review значений и строк не перезаписывает (у 1.x они по правилу приёма, A9).

  • Step 1: Write the failing tests: test_1x_balance_with_violation (сценарий «Форма 1.x с нарушением»: 1.2, ББ с несошедшимся 1600 = 1700 → запись relation RELATION_FAILED, codes == ["1600", "1700"], source == "core", документ NEEDS_CHECK; POST …/confirm без акцепта — 409 CHECKS_NOT_PASSED); test_1x_simplified_variant (ББ 1.2 с 1600 и без 1100/1200 → form_variant == "simplified"); test_1x_clean_income_recognized (прежний test_clean_income_recognized зелёный: RECOGNIZED и CONFIRMED).

  • Step 2: запуск — FAIL.

  • Step 3: Implement; run_checks ядра из _insert_statements убрать.

  • Step 4: запуск, make check, cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py — PASS.

  • Step 5: Commit feat(mapping): §7 и вариант формы при приёме 1.x копией arm_statements (Р-30).

Task B4: Маппер 2.0 — формы

Files:

  • Create: api/src/domain/statement_mapping_v2.py, api/tests/factories_v2.py
  • Modify: api/src/services/result_mapping.py (map_file, выбор маппера), api/src/repositories/statements.py (insert_statement — черновик v2)
  • Test: api/tests/test_domain_statement_mapping_v2.py, api/tests/test_mapping_statements_v2.py

Interfaces:

  • Consumes: модели result_v2 (A5), IssueDraft/add_issues (A9), form_status_v2, duplicate_period_forms, name_mismatches, has_error_warning (A10), engine.contract_document_status, engine.edition, engine.code_order, edition_codes (B1).

  • Produces:

    • build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2 — строки как пришли (row_index, source_ref = ref, line_status, line_code, line_code_original_value = line_code, line_code_text, line_code_confidence, line_name, line_name_confidence, parent_row_index — row_index строки с ref == parent_ref, needs_review, is_confirmed = False); значение на каждую пару «строка × колонка» (amount, string_value, source_text = text, confidence, is_recognized, needs_review; нет в результате — пустая запись с needs_review = False); колонки с period_kind, label_confidence; шапка: разобранные поля, header_source ({поле: {"text": <поле>_text, "confidence": <поле>_confidence}} для 10 полей), inn_original_value = inn, report_date_original_value = report_date_text, units_original_value = units_text, organization_name_original_value = organization_name, inn_confidence, needs_review_fields, form_variant; P3-9;
    • service_issues(doc, *, line_ids_by_ref, column_ids_by_index, ok_line_ids_by_code) -> list[IssueDraft] — doc.warnings (warning, service; line_refs → line_ids; column_index → column_id, если колонка есть, иначе column_index) и doc.checks (relation/soft, service; column_id — по column_index колонки: у сервиса FailedRelation.column_key = str(column_index));
    • core_info_issues(*, lines, edition: Edition | None, form_variant: FormVariant | None, catalog, engine) -> list[IssueDraft] — CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION (P3-7), NAME_MISMATCH (строка); edition is None или form_variant is None — проверки по редакции не пишутся; edition = engine.edition(DocumentType(doc.document_type), edition_codes(doc), document.reporting_date, FormVariant(doc.form_variant) if doc.form_variant else None) — без сборки StoredForm и пересчёта;
    • статус формы: service_done = doc.status == DocStatus.DONE and engine.contract_document_status(doc.model_dump(mode="json")) == DocStatus.DONE; expected/actual нарушений результата — Decimal | None (контракт: null — цели нет); doc.status == "done" при несогласии — запись RESULT_STATUS_INCONSISTENT (info, core, у документа); file_error_warning = has_error_warning(result.metrics.warnings) (P3-15); RECOGNIZED → автоподтверждение (как сейчас);
    • job.statements_version = result.service.statements_version; DISCLOSURE — один документ на файл (P3-6), предупреждения всех объединённых — в его document_issue.
  • Step 1: Write the failing tests (factories_v2.statement_result_v2(...) собирает результат 2.0 из базового словаря с переопределениями; реальный пакет):

    • golden на примерах docs: result-statement-minimal.json — строка unreadable с line_code is None, line_code_text как в примере, документ NEEDS_CHECK; result-statement-excel.json — BALANCE с записью RELATION_SOFT, INCOME со строкой detail и parent_row_index строки-родителя, один DISCLOSURE без строк;
    • test_repeated_code_kept — две строки ok 2410 (пустая и (649)) → две строки с разными row_index и source_ref, у каждой свои значения;
    • test_line_without_code — unreadable, line_code_text = "ИЧ,250", сумма → line_code is None, line_code_original_value is None, line_status == "unreadable", row_index из результата;
    • test_two_balances_same_period — два BALANCE 2025 Q3 → разные file_ordinal, оба NEEDS_CHECK, ни один не CONFIRMED — Review Focus 1; test_two_balances_other_periods — каждый по своим условиям;
    • test_neighbor_needs_check — ББ done, ОФР needs_check, ошибок файла нет, ИНН совпал, 30.09.2025 → ББ CONFIRMED (автоподтверждён), ОФР NEEDS_CHECK;
    • test_json_failed_blocks_all — все done, metrics.warnings с JSON_FAILED (error) → все формы NEEDS_CHECK; то же для TABLE_PARSE_ERROR;
    • test_name_mismatch_neighbor_code — 1120 (50) «Нематериальные активы» → NEEDS_CHECK, NAME_MISMATCH у строки; test_legal_repeat_name — 1220 (50) «НДС, начисленный по отгрузке» → NAME_MISMATCH нет; test_section_total_swap — 1200 (50) «Итого по разделу I» → NAME_MISMATCH; test_section_ocr_tail — 1500 (56) «Итого по разделу V1» → нет;
    • test_done_with_unreadable_without_values и test_done_with_unreadable_with_amount — NEEDS_CHECK, RESULT_STATUS_INCONSISTENT у документа, не CONFIRMED;
    • test_report_date_not_quarter_end — 15.08.2025 → NEEDS_CHECK; POST …/confirm → 409 PERIOD_REQUIRED;
    • test_66n_blank_in_2025 — Межениновская 3 кв. 2025 на бланке 66н с 1120 → CODE_NOT_IN_EDITION у 1120 нет;
    • test_column_skipped_issue — COLUMN_SKIPPED, column_index = 5, колонки 5 нет → запись column_index == 5, column_id is None;
    • test_header_source_hint — organization_name_text = "ООО «Ромашкa»", organization_name_confidence = 67 → header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67} и то же в GET (header.source);
    • test_statements_version_and_ordinals — service.statements_version = "abc1234" → у задания "abc1234"; BALANCE, INCOME → file_ordinal 0, 1.
  • Step 2: запуск двух файлов — FAIL.

  • Step 3: Implement; map_file получает RecognitionResult | RecognitionResultV2 и выбирает маппер по типу.

  • Step 4: запуск и make check — PASS.

  • Step 5: Commit feat(mapping): маппер результата 2.0 для форм — хранение как пришло, document_issue, условие RECOGNIZED.

Task B5: Маппер 2.0 — ОСВ, выбор по MAJOR, приём файла результата

Files:

  • Modify: api/src/services/result_mapping.py (_load_result, ОСВ-часть map_file), api/src/domain/osv_mapping.py (статус по Р-28)
  • Test: api/tests/test_result_mapping.py, api/tests/test_mapping_statements_v2.py, api/tests/test_api_documents.py

Interfaces:

  • Consumes: parse_result (A5), osv_status_v2, has_error_warning (A10), IssueDraft (A9).

  • Produces: _load_result → versions.parse_result (MAJOR 1 — маппер 1.x, MAJOR 2 — маппер 2.0, иное — MappingFatal с итогом проверки схемы в metrics.mapping_error, файл ERROR, без повтора); документ ОСВ 2.0 — статус osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings); sheet.warnings → document_issue (warning, service, коды OSV_*, info).

  • Step 1: Write the failing tests: test_osv_in_book_with_needs_check_income (сценарий спеки: ОСВ done с сошедшимися контрольными суммами + ОФР needs_check, конверт needs_check, ошибок файла нет → ОСВ CONFIRMED, её OSV_* — в DocumentOut.issues); test_osv_v2_examples (result-osv-minimal.json, result-osv-needs-check.json — статусы по примерам); test_two_osv_v2_ordinals (ОСВ 60, 62 → 0, 1); test_result_major_3_is_fatal (schema_version "3.0" → файл ERROR, metrics.mapping_error начинается с «результат не соответствует схеме»); test_result_1x_still_mapped (1.2 — маппер 1.x).

  • Step 2: запуск — FAIL.

  • Step 3: Implement.

  • Step 4: запуск, make check, cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_golden.py tests/test_mapping_statements.py — PASS.

  • Step 5: Commit feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR.

Task B6: Удаление копии словаря §7 ядра, редакции из перечня пакета

Files:

  • Delete: api/rules/statement_checks.yaml
  • Modify: api/src/domain/statement_checks.py (остаются cross_form_check и subline_parents; RelationRule, CheckRules, load_check_rules, run_checks, FailedCheck.codes удаляются), api/src/services/statements.py (checks_report: допуск — engine.tolerance() = default_rules().checks.tolerance), api/src/domain/statement_lines.py (порядок и состав редакций — engine.editions() = default_rules().code_orders.editions (Edition)), CLAUDE.md (раздел «Формы ББ/ОФР»: §7 — копия arm_statements, проверки хранятся в document_issue), README.md
  • Test: api/tests/test_domain_statement_checks.py, api/tests/test_domain_statement_lines.py

Interfaces:

  • Consumes: engine.tolerance(), engine.editions(), engine.code_order(edition, document_type, form_variant) (B1; Rules.code_orders.sequence плана 1).

  • Produces: cross_form_check(balance_1370, income_2400, tolerance: Decimal) -> FailedCheck | None без изменений сигнатуры; test_name_dictionary_codes_in_code_orders — каждый код statement_lines.yaml редакции есть в перечне этой редакции копии пакета (любой вариант формы).

  • Step 1: Write the failing tests: test_name_dictionary_codes_in_code_orders; test_no_core_check_rules (not (API_DIR / "rules" / "statement_checks.yaml").exists() и в src/ нет load_check_rules); тесты run_checks в test_domain_statement_checks.py удаляются, тесты cross_form_check и subline_parents остаются.

  • Step 2: запуск — FAIL.

  • Step 3: Implement.

  • Step 4: make check — PASS; grep -rn "statement_checks.yaml\|run_checks\|load_check_rules" api/src api/tests — пусто.

  • Step 5: Commit refactor(statements): удалена копия словаря §7 ядра — проверки копией arm_statements.

Завершение части Б

  • make check-full — PASS, statements-check — код 0 или 10 (предупреждение «копия отстала» — в описание PR).
  • Финальное ревью ветки (opus), ## Findings в ledger, PR со ссылкой на issue Б и arm-ka-docs#116; в описании — «Rulings I made», P3-n части Б, открытые O-n, напоминание о порядке выкладки (очистка → миграция → ядро А+Б → фронт; сервисы на 2.0 — после).

Покрытие спеки arm-core

Требование спеки Часть А Часть Б
Хранение результата формы v2.0 (DBML v5.19) схема A1; file_ordinal и Р-10 у 1.x A9 маппер 2.0 B4, B5
Признак строки, созданной правкой A7, A13 —
Шапка формы — сырой текст и уверенность схема A1; 1.x A9; header.source A8 2.0 B4
Агрегат формы по коду A7 —
Условие RECOGNIZED при приёме 2.0 чистые функции A10; словарь A6 B4 (страховка — engine.contract_document_status)
Предупреждения и нарушения документа — document_issue таблица A1; снятие правкой A12; line_ids A13; issues A8 relation/soft, core B2–B4
Формы результата 1.x — §7 при приёме поля и MAPPING_WARNING A9; LEGACY_WARNING A5 B3
Пересчёт после правки временный пересчёт A12 B2
PATCH формы — строки, колонки, подтверждение, наименование A12, A13 относительные даты B2
Подтверждение формы — порядок 409 A11 CHECKS_NOT_PASSED из document_issue B2
Словари ядра и достройка шаблонных строк словарь имён A6; достройка A7 удаление копии §7, редакции из перечня B6
Ответ GET …/statement по v2.0 A8 checks из document_issue B2
Миграция DBML v5.19 A1, A2 сценарий «после PATCH … RELATION_FAILED» — B2
Очистка форм перед выкладкой A4 —
Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 A5, A8, A9 B5
crm_id ГСК/ГСЗ A3 —
recognition-contract: «сначала ядро», ack 2.0, копии обоих MAJOR A5 B5