140 KiB
План 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"; вMakefileDBML ?= ../../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: 404NOT_FOUND, 409ConflictError, 422DomainValidationError, тело{"detail": {"code", "message", …}}; 422 валидации тела pydantic — массив. Коды этого плана: 409LINE_CODE_TAKEN(line_id,line_code),LINES_UNRESOLVED(lines[] {line_id, row_index, line_code_text}),DUPLICATE_FORM(document_id),CRM_ID_EXISTS; 422LINE_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_eventRECOGNIZED_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
- Две формы одного типа и периода в одном файле (лист-продолжение, двойная печать) — оба документа хранятся с
разными
file_ordinal, ни один неRECOGNIZEDи не автоподтверждён (Р-18); подтверждение второго после первого — 409DUPLICATE_FORM; формы разных периодов — каждая по своим условиям; автозамена и пара межформенной проверки не падают на двух документах с одним ключом. Тесты — A9 (маппер1.x), A11 (409), B4 (маппер2.0). - Повторы кода в агрегате — суммы строк
okс одним кодом складываются по колонке (100 + 649 = 749), пустые не превращаются в 0 (null, если непустых нет), межформенная проверка берёт сумму (500 + 300 = 800),parent_codeподстроки и шаблонная достройка видят код один раз. Тесты — A7. - Строка без кода при назначении кода — строка
unreadableс суммами становитсяok,row_indexиline_code_original_value = nullсохраняются (origin = source), её значения входят в агрегат и §7, родители всех строкdetailпересчитаны; тот же код у другой строки источника — законный повтор, без 409. Тесты — A13. - Шаблонная строка с суммами (
row_index = null) под назначаемым кодом — 409LINE_CODE_TAKEN, и ничего из запроса не применено (ни другие элементыlines, ниvalues, ни шапка); пустая шаблонная строка удаляется, еёidуходит изdocument_issue.line_ids. Тесты — A13. - Старые задания со строковыми
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-coredocs из колонки «А» таблицы покрытия (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(FKapp_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.
- enum'ы
-
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— из БД; конфликт — 409CRM_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, в т. ч. в другой ГК → 409CRM_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(сценарий «Очистка и повторная загрузка»): после--applyPrimaryFileService.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.jsonv2.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/(worktreedocs/зеркала) в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(ackprocessingсо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_ordinal0 и 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_issueMAPPING_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) с суммой и жёсткое нарушение → 409LINES_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 |"→ 409LINES_UNRESOLVED;test_unresolved_line_without_values_does_not_block—unreadableбез значений → подтверждение проходит (при прочих условиях);test_confirm_duplicate_form— два ББ на 30.09.2025 в файле, первыйCONFIRMED→ второй: 409DUPLICATE_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(строка есть, записи значения нет → 422UNKNOWN_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: "руб."}→ 409LINE_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], строка источникаok1250 — Review Focus 4;test_mark_detail—missingс суммой →line_status == "detail", в агрегате её нет, подтверждение не даётLINES_UNRESOLVED;test_status_ok_without_code—line_status: "ok"→ 422LINE_CODE_REQUIRED,detail.line_id == id,detail.line_code is None;test_invalid_line_patch—{line_id};{line_id, line_code, confirmed}; одинline_idдважды → 422INVALID_LINE_PATCH,detail.line_id— id элемента (у повтора — повторённый id),detail.line_code— код элемента илиNone;test_unknown_line— строка другой формы → 422UNKNOWN_LINE,detail.line_id— этот id;test_invalid_line_code—"123","123456","12a4"→ 422INVALID_LINE_CODE,detail == {code, message, line_id, line_code: "123"…};"11101"→ 200;test_confirm_code— строкаok1120, уверенность 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),detail5 и 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, PRtea 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(реальный пакет, сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230value_fixed, код 1250code_fixedу бывшейunreadable, шаблонная 1260 с суммой,detailс уверенностью 60 → у 1230 и у значенияdetailneeds_reviewFalse, у строки 1250False, соотношение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_parentsA13 удаляется);statement_hard_failuresиchecks.failed/soft_failedответа — из записейrelation/soft(сcodes,line_ids);NOTHING_TO_ACCEPT— нет записейrelation; правкаheader.report_date(в т. ч.null) пересчитываетperiod_dateколонок сperiod_kindrelative:*через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→ записьrelationRELATION_FAILED,codes == ["1600", "1700"],source == "core", документNEEDS_CHECK;POST …/confirmбез акцепта — 409CHECKS_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— две строкиok2410 (пустая и(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— дваBALANCE2025 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→ 409PERIOD_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_ordinal0, 1.
- golden на примерах docs:
-
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 |