arm-ka-docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md
Andrey Epifancev 14dcb0fbee openspec(#116): план 1 — пакет общего кода arm_statements (репозиторий arm-statements), публичный API
19 задач: каркас и make check, типы и сериализация, словари и загрузчик, разборы текста/кода/дат/сумм, шапка,
шаги 2–8, функция статуса, конвейер кандидата, точка входа пересчёта, golden на обезличенных входах aep_3, 1С и НПЗ.
Решения автора плана P1-1…P1-20, открытые вопросы O-1…O-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 12:11:11 +04:00

184 KiB
Raw Blame History

План 1 — пакет общего кода arm_statements (репозиторий arm-statements). План реализации

Для исполнителей-агентов: обязательный навык — superpowers:subagent-driven-development (в плане 19 задач: по ~/.claude/CLAUDE.md — SDD, контроллер — фоновый сабагент на модели sonnet; исполнители задач — sonnet: готового кода в плане нет, есть сигнатуры, тесты и правила спеки; финальное ревью ветки — opus). Шаги размечены чекбоксами (- [ ]).

Goal: новый репозиторий arm-statements с пакетом arm_statements/ — общие шаги 2–8 нормализации ББ/ОФР, типы шага 1, общие разборы текста, словари и точка входа пересчёта, с тестами внутри пакета и зелёным make check; публичный API пакета зафиксирован в конце плана для планов 2–5.

Architecture: пакет — чистые функции над frozen-dataclass-ами (stdlib, без pydantic): адаптер хоста строит FormCandidate, normalize_candidate проводит его через шаги 2–8 и возвращает StatementDocument или предупреждения файла; ядро пересчитывает сохранённую форму функцией recompute(StoredForm). Шаги 6–8 у обоих путей идут через один внутренний вид строк (EvalLine), поэтому правила §7, покрытия, знака, структуры и статуса у сервиса и ядра — один код. Словари — YAML в arm_statements/rules/, загрузчик load_rules проверяет их при загрузке. Тесты пакета — arm_statements/tests/ (копируются в хосты вместе с пакетом), только синтетика и обезличенные копии.

Tech Stack: Python 3.11, PyYAML 6.0.3 (единственная зависимость), pytest 9.1.1, ruff 0.16.10, mypy 2.4.0, uv, GNU make, Gitea (tea), git worktree.

Spec: openspec/changes/unified-statement-normalization/ (репозиторий arm-ka-docs, ветка feat/116-unified-normalization): главное — specs/statement-normalization/spec.md (все требования); что пакет отдаёт хостам — specs/recognition-contract/spec.md («Файл результата»); перечни кодов — docs/contracts/statement-code-orders.yaml; форма результата — docs/contracts/recognition-result.schema.json v2.0; docs/context/05-domain-rules.md §7 («Самопроверка в сервисах») и §11 (§11.3–§11.9); proposal.md; design.md — справка (Р-n, S-n), спека важнее.

Предусловия (для человека или контроллера, до задачи 1)

  • П-1. Человек создаёт в Gitea пустой репозиторий RSHB/arm-statements (ветка по умолчанию main). Агент репозитории не создаёт.
  • П-2. Клон — /Users/andrey.epifancev/projects/awp-ca/arm-statements (рядом с остальными репозиториями workspace; sync-statements плана 2 ищет источник именно там). Обвязка подключается ~/.claude/harness/adopt-project.sh — единственная разрешённая запись в main; после неё в main есть хотя бы один коммит (иначе worktree не создать).
  • П-3. Контроллер: issue в RSHB/arm-statements (tea issues create --login home --repo RSHB/arm-statements, заголовок «Пакет общего кода нормализации ББ/ОФР (#116 docs)», ссылка на RSHB/arm-ka-docs#116 и этот план); worktree /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statements на ветке feat/<issue>-arm-statements-package; в worktree — codegraph init; в зеркальном workspace .wt/normalization/ симлинки fixtures, reference-old-arm, CLAUDE.md, .claude (как в 00-workspace-layout.md).
  • П-4. В тексте плана $R = /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statements; все команды — из $R, git — git -C "$R".
  • П-5. Перечень кодов копируется байтово из /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml (ветка docs feat/116-unified-normalization, до слияния docs PR #117 в main docs его нет). Если файл в ветке docs поменяется до слияния PR этого плана — копия обновляется тем же PR.

Global Constraints

  • Python 3.11; типы — @dataclass(frozen=True, slots=True) и enum.StrEnum из stdlib; pydantic не используется. Единственная зависимость рантайма — PyYAML==6.0.3 (есть в зеркале банка), список — [project].dependencies в pyproject.toml (его читает sync-statements --check).
  • Деньги — decimal.Decimal, квантование Decimal("0.01") с ROUND_HALF_UP; float в суммах запрещён. Уверенность — float 0–100. Даты — datetime.date.
  • Соответствия «код → наименование» в пакете нет нигде — ни в словарях, ни в коде (Р-3).
  • arm_statements/rules/statement_code_orders.yaml — байтовая копия docs/contracts/statement-code-orders.yaml; руками не правится.
  • Тесты пакета не читают ничего вне arm_statements/ (ни соседние репозитории, ни fixtures/, ни корень репозитория: в хосте корнем будет api/ хоста); реальные названия организаций, ИНН и адреса в тестах запрещены — только обезличенные («ООО «Пример»», ИНН 7700000000).
  • Код и тесты пакета проходят самый строгий конфиг хостов без исключений по файлам: ruff line-length = 120, target-version = "py311", select = ["E", "F", "I", "UP", "B", "PL"], без per-file-ignores; ruff format; mypy strict = true (надмножество строгих флагов scan-gateway и arm-core). PLR2004 в тестах не отключается: числа сравнивать кортежами/списками (assert cols == (2, 4, 5)), через D("…") или именованные константы модуля; # noqa — только с причиной в той же строке; # type: ignore запрещён (warn_unused_ignores).
  • Код тестов в плане фиксирует входы, значения и утверждения; аннотации типов вспомогательных функций тестов, которых требует mypy strict, исполнитель дописывает сам (поведение теста при этом не меняется). Если утверждение теста противоречит спеке — источник истины спека: исполнитель отмечает расхождение в отчёте, а не подгоняет код.
  • Тесты: arm_statements/tests/ с __init__.py (имена модулей не конфликтуют с тестами хоста) и своим conftest.py без autouse; без своих маркеров pytest (хосты запускают --strict-markers), без asyncio.
  • Импорты внутри пакета — абсолютные from arm_statements.<модуль> import …; пакет не импортирует ничего из хоста (src.*). Словари читаются по Path(__file__).parent / "rules".
  • Файла arm_statements/VERSION в репозитории нет (его пишет только sync-statements в копии хоста); путь — в .gitignore.
  • Код предупреждения и его уровень берутся только из словаря rules/statement_warnings.yaml (таблица спеки «Шаг 8 — предупреждения»); тексты message — по-русски, свободные, кроме оговорённого в задачах.
  • Детерминизм: одинаковый вход — одинаковый выход (без времени, случайности и зависимости от порядка set).
  • Публичный API — только имена из arm_statements/__init__.py __all__ (раздел «Публичный API пакета»); остальные модули доступны тестам пакета, но хосты их не импортируют.
  • Язык комментариев, docstring, сообщений предупреждений, коммитов — русский; идентификаторы — английские.
  • Коммиты заканчиваются строками 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); решения этого плана — P1-n (раздел в конце), контроллер переносит их в design.md docs (## Decisions made without discussion).

Review Focus

Входы с реальных файлов (fixtures aep_3, НПЗ, выгрузки 1С), которые сценарии спеки описывают по одному, а встретятся вместе. Каждый пункт закреплён тестом задачи 19 на обезличенной копии внутри пакета (arm_statements/tests/golden/):

  1. Мусорные коды aep_3 в одной таблице ББ — «НИ», «изо :», «| 140» среди нормальных кодов: «| 140» → строка ok 1140 с неуверенным кодом, «НИ» и «изо :» → строки detail с parent_ref ближайшей ok, ни одна строка не пропала, row_index сквозной и ref уникальны.
  2. Грязные суммы ОФР в одной колонке — «(7744536}», «211(1 |», «42831 1» вместе со скобочными 2210/2220: −7744536 со знаком «скобки» подтверждён голосованием, «211(1 |» → amount = null, needs_review = true, 428311 покрыт сошедшимся соотношением; документ needs_check (держит null-ячейка).
  3. Склейка «1500\n1700» посреди ББ — две строки row:i#1, row:i#2, суммы 917775 и 2371333, ROW_SPLIT (info), соседние ref не пересекаются; при сошедшихся 1500 = … и 1700 = … подсветки нет.
  4. Титульный блок «Коды» выгрузки 1С — под «Коды» ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ, а у таблицы подписи «Код» нет: столбец кодов — по правилу (б), строк с кодом 12267 нет (титульный блок — роль title), ББ done.
  5. Лист-продолжение НПЗ с одной строкой 2400 в другой колонке — ОФР на двух листах, у второго нет шапки, код 2400 стоит левее, чем на первом листе: ослабленный поиск столбца, значения 2400 под колонками первого листа без сдвига, locator из двух листов, предупреждений SEGMENT_* нет, ОФР done.

Структура репозитория

arm-statements/
├── pyproject.toml          # [project] (имя, зависимости), [project.optional-dependencies].dev, ruff, mypy, pytest
├── Makefile                # venv, lint, test, check, golden-update
├── README.md               # назначение, раскладка копией, публичный API, как менять словари
├── .gitignore              # Python + arm_statements/VERSION + .codegraph/ .superpowers/ .bounded
└── arm_statements/
    ├── __init__.py         # публичный API (__all__)
    ├── version.py          # statements_version()
    ├── model.py            # перечисления и dataclass-ы входа и выхода
    ├── serialize.py        # to_jsonable(), candidate_from_dict()
    ├── loader.py           # Rules, load_rules(), default_rules(), RulesError
    ├── issues.py           # make_warning()
    ├── text.py             # буквы-двойники, нормализация подписей, «цифра под видом буквы»
    ├── codes.py            # разбор кода, отрезки кодов, присоединение листа
    ├── dates.py            # parse_date, подпись колонки → период, ожидаемые даты
    ├── amounts.py          # parse_amount, build_value (шаг 4, ячейка)
    ├── header.py           # разбор шапки, type_hint, тип маркера продолжения
    ├── layout.py           # шаг 2: столбец кодов, нумерация, строка заголовка, роли, наименование
    ├── columns.py          # шаг 2: колонки значений, подписи, даты, номер колонки
    ├── segments.py         # шаг 2: сегменты, сопоставление колонок, find_layout()
    ├── rows.py             # шаг 3: строки данных, статусы, разлепление
    ├── lines.py            # шаги 4–5: StatementLine, предупреждения единиц
    ├── evaluation.py       # EvalLine / EvalValue — общий вид строк для шагов 6–8
    ├── checks.py           # шаг 7: §7, вариант формы
    ├── structure.py        # шаг 8: перечни кодов, редакция, подтверждение кода
    ├── review.py           # шаг 8: needs_review значений и строк, поля шапки, evaluate()
    ├── status.py           # функция статуса документа и конверта
    ├── pipeline.py         # шаг 6 и normalize_candidate() / normalize()
    ├── recompute.py        # StoredForm → RecomputeResult
    ├── rules/
    │   ├── statement_checks.yaml       # §7: полный и упрощённый наборы
    │   ├── statement_periods.yaml      # подписи периодов: относительные, текстовые, числовые
    │   ├── statement_headers.yaml      # заголовки форм, ОКУД, маркер продолжения, подписи колонок, метки шапки Excel
    │   ├── statement_units.yaml        # единицы: написания и ОКЕИ
    │   ├── statement_code_ranges.yaml  # отрезки кодов, пороги столбца кодов
    │   ├── statement_code_orders.yaml  # байтовая копия docs/contracts/statement-code-orders.yaml
    │   ├── statement_warnings.yaml     # код предупреждения → уровень и область
    │   └── ocr_lookalikes.yaml         # буквы-двойники
    └── tests/
        ├── __init__.py
        ├── conftest.py     # фикстура rules (default_rules())
        ├── builders.py     # ocr(), excel(), hdr(), place() — сборка FormCandidate для тестов
        ├── golden/         # обезличенные кандидаты *.candidate.json и эталоны *.expected.json
        └── test_*.py

Доноры логики (переносить правило, а не код целиком; смотреть через codegraph_explore с projectPath /Users/andrey.epifancev/projects/awp-ca/<repo>): tabular-parser api/src/parser/statements/{columns,lines,checks, number_format,detect,merge}.py, api/src/parser/numbers.py, api/rules/statement_*.yaml; scan-gateway api/src/normalizer/ocr/{text,dates,amounts,rows,layout}.py, api/rules/ocr_lookalikes.yaml, api/rules/statement_labels.yaml, api/tests/unit/normalizer/test_dates.py; arm-core api/src/domain/statement_checks.py (subline_parents, run_checks).


Task 1: Каркас репозитория и make check

Files:

  • Create: $R/pyproject.toml, $R/Makefile, $R/README.md, $R/.gitignore
  • Create: $R/arm_statements/__init__.py, $R/arm_statements/version.py
  • Create: $R/arm_statements/tests/__init__.py, $R/arm_statements/tests/conftest.py
  • Test: $R/arm_statements/tests/test_version.py

Interfaces:

  • Consumes: —

  • Produces: statements_version(path: Path | None = None) -> str | None в arm_statements/version.py (по умолчанию Path(__file__).parent / "VERSION"; содержимое без пробелов по краям; нет файла или пусто — None); цели Makefile venv, lint, test, check, golden-update.

  • Step 1: Write the failing test

from pathlib import Path

from arm_statements.version import statements_version

FULL_HASH = "abc1234def5678abc1234def5678abc1234def56"


def test_version_absent_in_source() -> None:
    assert statements_version() is None


def test_version_read_from_file(tmp_path: Path) -> None:
    marker = tmp_path / "VERSION"
    marker.write_text(f"{FULL_HASH}\n", encoding="utf-8")
    assert statements_version(marker) == FULL_HASH


def test_version_empty_file_is_none(tmp_path: Path) -> None:
    marker = tmp_path / "VERSION"
    marker.write_text("  \n", encoding="utf-8")
    assert statements_version(marker) is None
  • Step 2: Создать конфиг и Makefile

pyproject.toml: [project] name = "arm-statements", version = "0", requires-python = ">=3.11,<3.12", dependencies = ["PyYAML==6.0.3"]; [project.optional-dependencies] dev = ["ruff==0.16.10", "mypy==2.4.0", "pytest==9.1.1", "types-PyYAML==6.0.12.20260906"]; [tool.ruff] и [tool.ruff.lint] — как в Global Constraints, [tool.ruff.lint.isort] known-first-party = ["arm_statements"]; [tool.mypy] python_version = "3.11", strict = true, mypy_path = ".", explicit_package_bases = true; [tool.pytest.ini_options] testpaths = ["arm_statements/tests"], pythonpath = ["."], addopts = "--tb=short --strict-markers". Без [build-system] (пакет не собирается в wheel).

Makefile (рецепты из корня, окружение .venv, BIN := $(CURDIR)/.venv/bin): venv — uv venv --python 3.11 --allow-existing .venv и uv pip install --python .venv/bin/python -r pyproject.toml --extra dev; lint — ruff check ., ruff format --check ., mypy arm_statements; test — pytest; check: lint test; golden-update — GOLDEN_UPDATE=1 $(BIN)/pytest arm_statements/tests/test_golden.py (тест задачи 19). help с описанием целей.

.gitignore: шаблон Python (как у scan-gateway) + arm_statements/VERSION, .codegraph/, .superpowers/, .bounded.

conftest.py: пока пустой модуль с docstring (фикстура rules появится в задаче 3). README.md: назначение пакета, ссылка на спеку, «копия раскладывается make -C docs sync-statements, прямые правки копии запрещены», «как проверить: make venv && make check»; раздел «Публичный API» дописывается в задаче 19.

  • Step 3: Run test to verify it fails

Run: make venv && .venv/bin/pytest arm_statements/tests/test_version.py -v Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.version'

  • Step 4: Implement statements_version(path: Path | None = None) -> str | None in arm_statements/version.py

__init__.py пока экспортирует только statements_version.

  • Step 5: Run checks

Run: make check Expected: ruff, format, mypy — без ошибок; 3 passed.

  • Step 6: Commit
git -C "$R" add pyproject.toml Makefile README.md .gitignore arm_statements
git -C "$R" commit -m "feat: каркас пакета arm_statements, make check, маркер версии копии"

Task 2: Типы входа и выхода, сериализация, сборщики тестовых кандидатов

Files:

  • Create: $R/arm_statements/model.py, $R/arm_statements/serialize.py, $R/arm_statements/tests/builders.py
  • Test: $R/arm_statements/tests/test_model.py

Interfaces:

  • Consumes: —

  • Produces (arm_statements/model.py; все dataclass-ы frozen=True, slots=True, последовательности — tuple):

    • class Source(StrEnum): OCR = "ocr", EXCEL = "excel"
    • class DocumentType(StrEnum): BALANCE, INCOME, DISCLOSURE (значения = имена)
    • class FormVariant(StrEnum): FULL = "full", SIMPLIFIED = "simplified"
    • class Edition(StrEnum): RAS_66N = "66n", FSBU4 = "fsbu4"
    • class PeriodKind(StrEnum): ABSOLUTE = "absolute", CURRENT = "relative:current", PREV_YEAR_END = "relative:prev_year_end", PREV_PREV_YEAR_END = "relative:prev_prev_year_end", PREV_PERIOD = "relative:prev_period", NONE = "none"
    • class LineStatus(StrEnum): OK = "ok", UNREADABLE = "unreadable", MISSING = "missing", DETAIL = "detail"
    • class Severity(StrEnum): ERROR = "error", INFO = "info"
    • class DocStatus(StrEnum): DONE = "done", NEEDS_CHECK = "needs_check"
    • class EnvelopeStatus(StrEnum): DONE = "done", NEEDS_CHECK = "needs_check", ERROR = "error"
    • class SignForm(StrEnum): PARENTHESES = "parentheses", MINUS = "minus", UNSIGNED = "unsigned"
    • class RowRole(StrEnum): TITLE = "title", HEADER = "header", NUMBERING = "numbering", DATA = "data"
    • CellValue = Decimal | date | bool | None
    • Cell(text: str, value: CellValue, confidence: float, ref: str)
    • Evidence(okud: Cell | None = None, title: Cell | None = None)
    • HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None) — десять полей, порядок как здесь; кортеж имён — константа HEADER_FIELDS: tuple[str, ...]
    • StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None)
    • FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), warnings: tuple[StatementWarning, ...] = ()) — позиция ячейки в строке = номер столбца сетки; строки могут быть разной длины (недостающие позиции = None)
    • HeaderField(text: str | None, confidence: float | None, value: str | None)
    • DateField(text: str | None, confidence: float | None, value: date | None)
    • StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField, okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField, address: HeaderField, needs_review_fields: tuple[str, ...] = ())
    • StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None, period_kind: PeriodKind)
    • StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None, confidence: float, is_recognized: bool, needs_review: bool = False)
    • StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None, line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None, parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])
    • FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], expected: Decimal, actual: Decimal) — column_key: у сервиса str(column_index), у ядра — ключ колонки хоста
    • StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])
    • StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str, header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...], checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])
    • CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])
    • NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])
  • Produces (arm_statements/serialize.py):

    • to_jsonable(obj: object) -> Any — dataclass → dict по полям, StrEnum → значение, Decimal → str, date → ISO, tuple/list → list, остальное как есть; у Cell.value — тег: {"decimal": "106311"}, {"date": "2025-09-30"}, {"bool": true}, null
    • candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate — обратная к to_jsonable для кандидата
  • Produces (arm_statements/tests/builders.py, только для тестов):

    • ocr(rows: Sequence[Sequence[str | tuple[str, float] | None]], *, type_hint: DocumentType | None = None, header: HeaderCells | None = None, title: str | None = None, okud: str | None = None, locator: str = "file:t.json;table:0") -> FormCandidate — строка r → ячейки Cell(text, None, conf, f"row:{r}"), str — уверенность 100, (text, conf) — заданная, None — позиции нет
    • excel(sheets: Sequence[tuple[str, Sequence[Sequence[str | Decimal | None]]]], *, type_hint=None, header=None, title: str | None = None, okud: str | None = None, warnings: Sequence[StatementWarning] = ()) -> FormCandidate — листы подряд в одной сетке, segment_breaks — первая строка каждого листа после первого, locator — sheet:<A>[,<B>…], ref — <лист>!<A1> по правилу Excel (кавычки, если в имени есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф удваивается); Decimal → Cell(text=<с пробелами тысяч>, value=v, 100)
    • hdr(**fields: str | tuple[str, float]) -> HeaderCells
    • place(cells: Mapping[int, str | Decimal | tuple[str, float]], width: int | None = None) -> list[...] — строка с ячейками в заданных столбцах, остальные None
  • Step 1: Write the failing test

import json
from datetime import date
from decimal import Decimal as D

from arm_statements.model import Cell, DocumentType, Evidence, FormCandidate, HeaderCells, Source
from arm_statements.serialize import candidate_from_dict, to_jsonable
from arm_statements.tests.builders import excel, ocr


def test_candidate_roundtrip_through_json() -> None:
    cand = FormCandidate(
        source=Source.EXCEL,
        locator="sheet:Баланс",
        type_hint=DocumentType.BALANCE,
        evidence=Evidence(okud=Cell("0710001", None, 100.0, "Баланс!F3"), title=None),
        header=HeaderCells(report_date=Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!C5")),
        rows=((Cell("106 311", D("106311"), 100.0, "Баланс!D27"), None, Cell("да", True, 100.0, "Баланс!F27")),),
    )
    data = json.loads(json.dumps(to_jsonable(cand), ensure_ascii=False))
    assert data["rows"][0][0]["value"] == {"decimal": "106311"}
    assert data["header"]["report_date"]["value"] == {"date": "2025-09-30"}
    assert candidate_from_dict(data) == cand


def test_excel_builder_refs_follow_excel_quoting() -> None:
    cand = excel([("Баланс", [["", "", "", "1110"]]), ("Бухгалтерский баланс. Лист 1", [["", "", "1150"]])])
    assert cand.rows[0][3] is not None and cand.rows[0][3].ref == "Баланс!D1"
    assert cand.rows[1][2] is not None and cand.rows[1][2].ref == "'Бухгалтерский баланс. Лист 1'!C1"
    assert cand.segment_breaks == (1,)
    assert cand.locator == "sheet:Баланс,Бухгалтерский баланс. Лист 1"


def test_ocr_builder_cells() -> None:
    cand = ocr([["Выручка", ("[190 :", 25.0), None]])
    cell = cand.rows[0][1]
    assert cell == Cell("[190 :", None, 25.0, "row:0")
    assert cand.rows[0][2] is None and cand.source is Source.OCR
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_model.py -v Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.model'

  • Step 3: Implement types, to_jsonable, candidate_from_dict, builders — по Interfaces. to_jsonable — dataclasses.fields рекурсивно (не asdict: нужен тег у Cell.value).

  • Step 4: Run tests and lint

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2–8, сериализация кандидата в json"

Task 3: Словари и загрузчик

Files:

  • Create: $R/arm_statements/rules/*.yaml (восемь файлов из «Структуры репозитория»), $R/arm_statements/loader.py, $R/arm_statements/issues.py
  • Modify: $R/arm_statements/tests/conftest.py (фикстура rules)
  • Test: $R/arm_statements/tests/test_loader.py

Interfaces:

  • Consumes: перечисления из model.py.
  • Produces (arm_statements/loader.py):
    • class RulesError(ValueError) — словарь не прошёл проверку (сообщение называет файл и ключ)
    • class CodeSection(StrEnum): BALANCE, INCOME, CAPITAL, CASH_FLOW, NOTES, TARGET_USE
    • CodeRange(section: CodeSection, low: int, high: int); CodeRanges(ranges: tuple[CodeRange, ...], min_share: Decimal, min_codes: int)
    • RelationRule(form: DocumentType, variant: FormVariant, relation: str, target: str, terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str]) — terms: (+1 | -1, код)
    • CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, relations: tuple[RelationRule, ...]) с методами relations_for(form: DocumentType, variant: FormVariant) -> tuple[RelationRule, ...] и abs_codes(form: DocumentType, variant: FormVariant) -> frozenset[str] (объединение abs_terms набора)
    • PeriodPattern(kind: str, pattern: re.Pattern[str]); PeriodRules(relative: tuple[tuple[PeriodKind, re.Pattern[str]], ...], absolute: tuple[PeriodPattern, ...], months: tuple[tuple[str, int], ...], min_year: int, max_year: int, max_text_length: int)
    • HeaderRules(form_titles: Mapping[DocumentType, tuple[re.Pattern[str], ...]], okud: Mapping[DocumentType, tuple[re.Pattern[str], ...]], okud_code: re.Pattern[str], continuation: tuple[re.Pattern[str], ...], code_caption: re.Pattern[str], name_caption: re.Pattern[str], explanations_caption: re.Pattern[str], column_exclusions: tuple[re.Pattern[str], ...], labels: Mapping[str, tuple[re.Pattern[str], ...]], auditor: tuple[re.Pattern[str], ...], legal_form: tuple[re.Pattern[str], ...])
    • UnitRules(spellings: tuple[tuple[re.Pattern[str], str], ...], okei: Mapping[str, str], thousands: str, fractional_share_percent: int)
    • CodeOrders(sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]]) с методом sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]
    • WarningRule(code: str, severity: Severity, scope: Literal["document", "file"])
    • Rules(checks: CheckRules, periods: PeriodRules, headers: HeaderRules, units: UnitRules, code_ranges: CodeRanges, code_orders: CodeOrders, lookalikes: Mapping[str, str], warnings: Mapping[str, WarningRule])
    • load_rules(directory: Path | None = None) -> Rules; default_rules() -> Rules (functools.cache, каталог Path(__file__).parent / "rules")
  • Produces (arm_statements/issues.py): make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None) -> StatementWarning — уровень из rules.warnings[code]; неизвестный код — KeyError.
  • Produces (conftest.py): фикстура rules() -> Rules (default_rules()).

Содержимое словарей (ключи фиксированы; значения — из доноров и спеки):

  • statement_checks.yaml: tolerance: 1, rounding_tolerance_percent: 0.001, subline_check: true, relations: — элементы {form, variant, relation, strict, abs_terms?}. variant: full — все 13 строк tabular-parser api/rules/statement_checks.yaml дословно (с 1105 в 1100, 1215 в 1200, 1530 в 1500). variant: simplified (спека «Шаг 7», §7 «Упрощённый набор»): BALANCE "1600 = 1150 + 1170 + 1210 + 1230 + 1250" strict; BALANCE "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550" strict; BALANCE "1600 = 1700" strict; INCOME "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410" не strict, abs_terms: ["2120", "2330", "2350", "2410"].
  • statement_periods.yaml: max_text_length: 200; years: {min: 1990, max: 2100}; months: — основа → номер (январ: 1 … ма: 5 … декабр: 12, как _MONTHS scan-gateway dates.py); relative: — по порядку {kind: "relative:prev_prev_year_end", pattern: 'предшествующ\w*\s+предыдущ'}, {kind: "relative:prev_year_end", pattern: 'декабр\w*\s+предыдущ'}, {kind: "relative:prev_period", pattern: 'аналогичн\w*\s+период\w*\s+предыдущ'}, {kind: "relative:current", pattern: 'отч[её]тн\w*\s+(дат|период)'}; absolute: — по приоритету §11.3 п. 3 элементы {kind, pattern} с kind ∈ {range_end, day_month_year, quarter, half, months, month_range, year} и именованными группами d, m (месяц числом), mw (месяц словом), y, q (номер квартала/полугодия словом или римской/арабской цифрой), n (число месяцев); регулярки — из _PATTERNS scan-gateway dates.py (они надмножество словаря tabular-parser statement_periods.yaml; for_year tabular-parser покрывает числовой разбор «2024»).
  • statement_headers.yaml: из tabular-parser api/rules/statement_headers.yaml — form_titles (+ заголовки statement_labels.yaml scan-gateway, «пояснени…», «^расшифровк» → DISCLOSURE), okud, okud_code, continuation, labels, auditor, legal_form; новые ключи captions: {code: '^код(ы|\s+строки|\s+показателя)?$', name: '^наименование', explanations: '^пояснени', exclusions: ['изменени', 'отклонени', '%', 'темп', 'доля', 'примечани']}. Ключи line_codes, units_thousands, fractional_share_percent, column_numbering, code_caption, explanations_caption в пакет не переносятся (заменены отдельными словарями и правилами).
  • statement_units.yaml: units: — список {canonical, patterns} по порядку: тыс. руб. ('тыс\w*\.?\s*руб', 'тысяч\w*\s+рубл'), млн руб. ('млн\w*\.?\s*руб', 'миллион\w*\s+рубл'), руб. ('руб'); okei: {"384": "тыс. руб.", "383": "руб.", "385": "млн руб."}; thousands: "тыс. руб."; fractional_share_percent: 10.
  • statement_code_ranges.yaml: sections: {BALANCE: [1100, 1799], INCOME: [2100, 2999], CAPITAL: [3100, 3699], CASH_FLOW: [4100, 4499], NOTES: [5000, 5999], TARGET_USE: [6100, 6499]}, min_share: 0.6, min_codes: 3.
  • statement_code_orders.yaml: cp из П-5.
  • statement_warnings.yaml: warnings: {<CODE>: {severity, scope}} — ровно таблица спеки (тест ниже).
  • ocr_lookalikes.yaml: lookalikes: — О о O o → 0, З з → 3, б → 6, S s → 5, I l | і → 1, Ч → 4.

Проверки загрузчика (RulesError): каждая регулярка компилируется; relation — <4 цифры> = <4 цифры> ([+-] <4 цифры>)*, abs_terms ⊆ членов; variant ∈ {full, simplified}; code_orders.version == 1; в каждой редакции и форме в full каждый код встречается 1 + (число его вхождений в repeats) раз, в simplified — не чаще; все коды перечня — четыре цифры; severity ∈ {error, info}, scope ∈ {document, file}; у units.okei значения есть среди canonical.

  • Step 1: Write the failing test
import re
from pathlib import Path

import pytest
import yaml

from arm_statements.loader import RulesError, Rules, load_rules
from arm_statements.model import DocumentType, Edition, FormVariant, Severity

RULES_DIR = Path(__file__).parent.parent / "rules"

SPEC_WARNINGS = {
    "JSON_FAILED": ("error", "file"), "TABLE_PARSE_ERROR": ("error", "file"), "TABLE_LOST": ("error", "file"),
    "TABLE_SKIPPED": ("info", "file"), "CODE_COLUMN_NOT_FOUND": ("error", "document"),
    "SEGMENT_WITHOUT_CODES": ("error", "document"), "SHEET_NOT_JOINED": ("error", "document"),
    "CONTINUATION_TYPE_CONFLICT": ("error", "document"), "SEGMENT_COLUMNS_MISMATCH": ("error", "document"),
    "COLUMN_WITHOUT_DATE": ("error", "document"), "COLUMN_DATE_UNCERTAIN": ("error", "document"),
    "HEADER_ROW_NOT_FOUND": ("error", "document"), "TYPE_CONFLICT": ("error", "document"),
    "NO_ORGANIZATION_NO_INN": ("error", "document"), "PERIOD_UNKNOWN": ("error", "document"),
    "REPORT_DATE_UNCERTAIN": ("error", "document"), "XLSB_SCALE_SUSPECT": ("error", "document"),
    "INN_NOT_FOUND": ("info", "document"), "INN_UNCERTAIN": ("info", "document"),
    "ORGANIZATION_NOT_FOUND": ("info", "document"), "ORGANIZATION_UNCERTAIN": ("info", "document"),
    "REPORT_DATE_UNPARSED": ("info", "document"), "UNITS_NOT_FOUND": ("info", "document"),
    "FILE_TYPE_MISMATCH": ("info", "file"), "COLUMN_SKIPPED": ("info", "document"),
    "FRACTIONAL_IN_THOUSANDS": ("info", "document"), "XLSB_NO_FORMATS": ("info", "document"),
    "NUMBER_FORMAT_UNPARSED": ("info", "document"), "VALUE_OUT_OF_RANGE": ("info", "document"),
    "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"),
}


def test_warning_table_matches_spec(rules: Rules) -> None:
    assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS


def test_simplified_income_relation_is_soft(rules: Rules) -> None:
    (rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
    assert rule.target == "2400" and not rule.strict
    assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
    assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
        {"2120", "2210", "2220", "2330", "2350", "2410"}
    )


def test_code_orders_with_repeats(rules: Rules) -> None:
    fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
    assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
    ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL)
    assert "1120" in ras and ras.index("1110") < ras.index("1100")


def test_no_code_to_name_mapping_anywhere() -> None:
    code = re.compile(r"^\d{4,5}$")

    def walk(node: object) -> None:
        if isinstance(node, dict):
            for key, value in node.items():
                assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value))
                walk(value)
        elif isinstance(node, list):
            for item in node:
                walk(item)

    for path in RULES_DIR.glob("*.yaml"):
        walk(yaml.safe_load(path.read_text(encoding="utf-8")))


def test_broken_repeat_count_rejected(tmp_path: Path) -> None:
    for path in RULES_DIR.glob("*.yaml"):
        (tmp_path / path.name).write_bytes(path.read_bytes())
    orders = tmp_path / "statement_code_orders.yaml"
    orders.write_text(orders.read_text(encoding="utf-8").replace('- "1520"   # «Обязательство', '# "1520"'),
                      encoding="utf-8")
    with pytest.raises(RulesError, match="statement_code_orders"):
        load_rules(tmp_path)


def test_unknown_severity_rejected(tmp_path: Path) -> None:
    for path in RULES_DIR.glob("*.yaml"):
        (tmp_path / path.name).write_bytes(path.read_bytes())
    target = tmp_path / "statement_warnings.yaml"
    target.write_text(target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1),
                      encoding="utf-8")
    with pytest.raises(RulesError, match="statement_warnings"):
        load_rules(tmp_path)


def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None:
    from arm_statements.issues import make_warning

    warning = make_warning("COLUMN_SKIPPED", "колонка «Изменение, %» пропущена", rules, column_index=5)
    assert (warning.severity, warning.column_index) == (Severity.INFO, 5)
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_loader.py -v Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.loader'

  • Step 3: Write the eight YAML files and implement loader.py, issues.py, фикстуру rules — по Interfaces и перечню выше. YAML читается yaml.safe_load, результат приводится к типам через явные проверки (isinstance) — Any из YAML не утекает в Rules.

  • Step 4: Verify byte copy and run tests

Run: cmp arm_statements/rules/statement_code_orders.yaml /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml && make check Expected: cmp молчит; всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: словари нормализации (§7, периоды, заголовки, единицы, коды, предупреждения) и загрузчик"

Task 4: Очистка OCR-текста и разбор кода

Files:

  • Create: $R/arm_statements/text.py, $R/arm_statements/codes.py
  • Test: $R/arm_statements/tests/test_text_codes.py

Interfaces:

  • Consumes: Rules (lookalikes, code_ranges, headers).

  • Produces (arm_statements/text.py):

    • fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str — NBSP → пробел; если первая строка текста состоит только из цифр, пробелов, |, точек, запятых и двоеточий — | заменяется пробелом (разделитель клеток, «2|0|2|4|» → «2 0 2 4 »); затем двойники → цифры вплотную к цифре в несколько проходов (anywhere=True — без условия соседства: для нумерации и «цифры под видом буквы»)
    • has_digit(text: str | None) -> bool
    • is_disguised_digit(text: str, lookalikes: Mapping[str, str]) -> bool — после fix_lookalikes(anywhere=True) есть цифра и нет других букв ('I', '| О' → True; 'Косарев', '(расшифровка подписи)' → False)
    • normalize_caption(text: str | None) -> str — нижний регистр, ё → е, всё, кроме букв, цифр и %, → пробел, пробелы схлопнуты
  • Produces (arm_statements/codes.py):

    • CodeParse(code: str | None, fixed: bool) — fixed: при разборе заменён двойник
    • parse_code(text: str | None, rules: Rules) -> CodeParse — однострочный текст по спеке «Диапазоны кодов и разбор кода»: убрать пробелы → двойники (с |; если результат не код — повтор без |) → краевая пунктуация → ^\d{4,5} (\.0)?$, первые четыре цифры в объединении отрезков; многострочный текст → CodeParse(None, False)
    • is_code_cell(text: str | None, rules: Rules) -> bool — однострочный — parse_code(...).code is not None; многострочный — каждая непустая часть код
    • code_parts(text: str | None) -> tuple[str, ...] — непустые строки ячейки, обрезанные
    • section_of(code: str, rules: Rules) -> CodeSection | None — отрезок по первым четырём цифрам
    • majority_section(codes: Iterable[str], rules: Rules) -> CodeSection | None — отрезок большинства (ничья — первый по порядку отрезков словаря; пусто — None)
    • document_type_of(section: CodeSection) -> DocumentType — BALANCE/INCOME как есть, прочие → DISCLOSURE
    • sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, form_amount_columns: int, rules: Rules) -> bool — правило листа без маркера и шапки (спека «Шаг 1 — многостраничная форма»): все коды в отрезке формы и при трёх и более кодах число колонок сумм совпадает; кодов нет — False (для адаптера Excel плана 3)
  • Step 1: Write the failing test

import pytest

from arm_statements.codes import is_code_cell, majority_section, parse_code, sheet_joins
from arm_statements.loader import CodeSection, Rules
from arm_statements.model import DocumentType
from arm_statements.text import fix_lookalikes, is_disguised_digit, normalize_caption


@pytest.mark.parametrize(
    ("raw", "code", "fixed"),
    [
        ("1220 ,", "1220", False),
        ("| 140", "1140", True),
        ("I1О0", "1100", True),
        ("1110.0", "1110", False),
        ("12301", "12301", False),
        ("3450", "3450", False),
        ("2024", None, False),
        ("1050", None, False),
        ("7450", None, False),
        ("ИЧ,250", None, False),
        ("изо :", None, False),
        ("НИ", None, False),
        ("1500\n1700", None, False),
        ("2", None, False),
    ],
)
def test_parse_code(rules: Rules, raw: str, code: str | None, fixed: bool) -> None:
    assert parse_code(raw, rules) == (code, fixed)


def test_multiline_code_cell(rules: Rules) -> None:
    assert is_code_cell("1500\n1700", rules)
    assert not is_code_cell("44 30\nI |", rules)


def test_lookalikes_only_near_digit(rules: Rules) -> None:
    assert fix_lookalikes("2ОО5", rules.lookalikes) == "2005"
    assert fix_lookalikes("2|0|2|4|", rules.lookalikes).split() == ["2", "0", "2", "4"]
    assert fix_lookalikes("Основные средства", rules.lookalikes) == "Основные средства"
    assert fix_lookalikes("З", rules.lookalikes) == "З"
    assert fix_lookalikes("З", rules.lookalikes, anywhere=True) == "3"


def test_disguised_digit(rules: Rules) -> None:
    assert is_disguised_digit("I", rules.lookalikes) and is_disguised_digit("| О", rules.lookalikes)
    assert not is_disguised_digit("Косарев", rules.lookalikes)
    assert not is_disguised_digit("(расшифровка подписи)", rules.lookalikes)


def test_caption_normalization() -> None:
    assert normalize_caption("Код  строки") == "код строки"
    assert normalize_caption("Изменение, %") == "изменение %"
    assert normalize_caption("Наименование\nпоказателя") == "наименование показателя"


def test_majority_section_and_join(rules: Rules) -> None:
    assert majority_section(["1110", "1150", "2110"], rules) is CodeSection.BALANCE
    assert sheet_joins(DocumentType.INCOME, ["2400"], 1, 3, rules)
    assert not sheet_joins(DocumentType.BALANCE, ["1110", "1150", "1100"], 4, 3, rules)
    assert not sheet_joins(DocumentType.BALANCE, ["2110"], 3, 3, rules)
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_text_codes.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement text.py and codes.py по Interfaces (донор — scan-gateway text.py::fix_lookalikes, tabular-parser columns.py::line_code). Краевая пунктуация — всё, что не буква и не цифра, по краям после удаления пробелов.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: буквы-двойники, нормализация подписей, разбор кода строки и отрезки кодов"

Task 5: Даты и подписи колонок

Files:

  • Create: $R/arm_statements/dates.py
  • Test: $R/arm_statements/tests/test_dates.py

Interfaces:

  • Consumes: Rules.periods, Rules.headers.column_exclusions, fix_lookalikes, normalize_caption.

  • Produces (arm_statements/dates.py):

    • parse_date(text: str | None, rules: Rules) -> date | None — текст уже после очистки; длиннее max_text_length (без пробелов по краям) → None; сначала текстовые формы absolute по порядку словаря по всему тексту, затем числовой разбор первой строки, затем всего текста через пробел (алгоритм _numeric_date, _from_digits, _from_parts, _year scan-gateway dates.py: три группы; 8 цифр ДДММГГГГ / ГГГГММДД / ГГГГДДММ; 4 → 31.12 года в [min_year, max_year]; 6 → ДДММГГ; больше трёх групп — первые три; двузначный год → 20YY)
    • PeriodLabel(kind: PeriodKind, date: date | None, excluded: bool, bare_year: bool)
    • parse_period_label(cell: Cell | None, rules: Rules) -> PeriodLabel — None/пустой текст → (NONE, None, False, False); Cell.value — date → ABSOLUTE с этой датой; иначе fix_lookalikes(text); исключение (column_exclusions по normalize_caption) → excluded=True, kind=NONE; относительная подпись (словарь relative, по порядку) → её kind, date=None; иначе parse_date → ABSOLUTE; иначе NONE. bare_year — текст после очистки — четыре цифры года
    • resolve_relative(kind: PeriodKind, effective: date | None) -> date | None — CURRENT → effective; PREV_YEAR_END → 31.12 года − 1; PREV_PREV_YEAR_END → 31.12 года − 2; PREV_PERIOD → effective − 1 год (29.02 → 28.02); effective is None или не относительный вид → None
    • expected_dates(effective: date) -> frozenset[date] — effective, она же − 1 и − 2 года (29.02 → 28.02), 31.12 года − 1 и года − 2
  • Step 1: Write the failing test

from datetime import date

import pytest

from arm_statements.dates import expected_dates, parse_date, parse_period_label, resolve_relative
from arm_statements.loader import Rules
from arm_statements.model import Cell, PeriodKind
from arm_statements.text import fix_lookalikes

# Обязательные строки scan-gateway test_dates.py::test_parses_real_forms (спека «Шаг 2 — подпись и дата колонки»)
REAL_FORMS = [
    ("на 30 сентября 2025 г.", date(2025, 9, 30)), ("На 30 июня\n2025 г", date(2025, 6, 30)),
    ("31 декабря 2024 года", date(2024, 12, 31)), ("30 сентября 2025 r.", date(2025, 9, 30)),
    ("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), ("I квартал 2026", date(2026, 3, 31)),
    ("1-й кв. 2026", date(2026, 3, 31)), ("первый квартал 2025 г.", date(2025, 3, 31)),
    ("1 полугодие 2026", date(2026, 6, 30)), ("1 п/г 2026", date(2026, 6, 30)),
    ("9 месяцев 2025", date(2025, 9, 30)), ("9 мес. 2025", date(2025, 9, 30)),
    ("За январь - июнь\n2025 г.", date(2025, 6, 30)), ("январь – сентябрь 2025", date(2025, 9, 30)),
    ("за 2025 г.", date(2025, 12, 31)), ("2024 год", date(2024, 12, 31)), ("30.09.2025", date(2025, 9, 30)),
    ("2025 03 31", date(2025, 3, 31)), ("30/09/25", date(2025, 9, 30)), ("2 0 2 4", date(2024, 12, 31)),
    ("30 09 2 0 2 5", date(2025, 9, 30)), ("30092025", date(2025, 9, 30)), ("20250930", date(2025, 9, 30)),
    ("30 09 25", date(2025, 9, 30)), ("30.09.2025 0:00:00", date(2025, 9, 30)), ("2|0|2|4|", date(2024, 12, 31)),
    ("3l.12.2О24", date(2024, 12, 31)),
]


@pytest.mark.parametrize(("raw", "expected"), REAL_FORMS)
def test_parses_real_forms(rules: Rules, raw: str, expected: date) -> None:
    assert parse_date(fix_lookalikes(raw, rules.lookalikes), rules) == expected
    label = parse_period_label(Cell(raw, None, 100.0, "row:0"), rules)
    assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, expected)


@pytest.mark.parametrize("raw", ["Общество с ограниченной ответственностью", "", "+", "тыс. руб.", "1999999999999"])
def test_not_a_date(rules: Rules, raw: str) -> None:
    assert parse_date(raw, rules) is None


def test_long_text_not_parsed(rules: Rules) -> None:
    assert parse_date("на 30 сентября 2025 г." + "x" * 200, rules) is None


@pytest.mark.parametrize(
    ("raw", "kind"),
    [
        ("На отчетную дату", PeriodKind.CURRENT),
        ("За отчётный период", PeriodKind.CURRENT),
        ("На 31 декабря предыдущего года", PeriodKind.PREV_YEAR_END),
        ("На 31 декабря года, предшествующего предыдущему", PeriodKind.PREV_PREV_YEAR_END),
        ("За аналогичный период предыдущего года", PeriodKind.PREV_PERIOD),
    ],
)
def test_relative_labels(rules: Rules, raw: str, kind: PeriodKind) -> None:
    assert parse_period_label(Cell(raw, None, 100.0, "row:0"), rules).kind is kind


def test_exclusion_and_excel_date_value(rules: Rules) -> None:
    assert parse_period_label(Cell("Изменение, %", None, 100.0, "A!F1"), rules).excluded
    label = parse_period_label(Cell("30.09.2025", date(2025, 9, 30), 100.0, "A!D1"), rules)
    assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, date(2025, 9, 30))
    assert parse_period_label(Cell("2024", None, 100.0, "A!E1"), rules).bare_year


def test_resolve_relative_and_expected() -> None:
    eff = date(2025, 9, 30)
    assert resolve_relative(PeriodKind.PREV_YEAR_END, eff) == date(2024, 12, 31)
    assert resolve_relative(PeriodKind.PREV_PREV_YEAR_END, eff) == date(2023, 12, 31)
    assert resolve_relative(PeriodKind.PREV_PERIOD, date(2024, 2, 29)) == date(2023, 2, 28)
    assert resolve_relative(PeriodKind.CURRENT, None) is None
    assert expected_dates(eff) == frozenset(
        {eff, date(2024, 9, 30), date(2023, 9, 30), date(2024, 12, 31), date(2023, 12, 31)}
    )
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_dates.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement dates.py — обработчик на каждый kind словаря absolute (код), регулярки — словарь. Длину проверять до fix_lookalikes и регулярок (backtracking, решение № 31 среза 3a).

  • Step 4: Run tests

Run: make check Expected: всё зелёное; тест длинного текста — доли секунды.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: словарь периодов — даты, относительные подписи бланка 66н, исключения колонок"

Task 6: Сумма и значение ячейки (шаг 4, ячейка)

Files:

  • Create: $R/arm_statements/amounts.py
  • Test: $R/arm_statements/tests/test_amounts.py

Interfaces:

  • Consumes: Source, Cell, StatementValue, SignForm, Rules.lookalikes, fix_lookalikes.

  • Produces (arm_statements/amounts.py):

    • AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None) — sign_form есть, когда ядро числа допустимо (стадия 4 пройдена), иначе None
    • parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse — стадии 1–6 спеки «Шаг 4 — значение ячейки» дословно; единственное место, где разбор зависит от source
    • sign_form_of(text: str | None, rules: Rules) -> SignForm | None — форма знака по тексту (parse_amount(text, Source.OCR, rules).sign_form; от источника не зависит)
    • ValueBuild(value: StatementValue, out_of_range: bool)
    • build_value(cell: Cell | None, column_index: int, source: Source, rules: Rules, *, split_part: bool = False) -> ValueBuild — cell is None → amount=None, string_value=None, text=None, confidence=100.0, is_recognized=True; cell.value — Decimal → квантование half-up до 0,01, больше 16 целых цифр → amount=None, string_value=text, is_recognized=False, out_of_range=True; date/bool → amount=None, string_value=text, is_recognized=False; иначе — parse_amount(cell.text, ...); текст из нескольких непустых строк (после strip) при split_part=False → amount=None, string_value=text, is_recognized=False; split_part=True → is_recognized=False всегда; text — всегда cell.text, confidence — cell.confidence
  • Step 1: Write the failing test

from datetime import date
from decimal import Decimal as D

import pytest

from arm_statements.amounts import build_value, parse_amount, sign_form_of
from arm_statements.loader import Rules
from arm_statements.model import Cell, SignForm, Source

OCR, EXCEL = Source.OCR, Source.EXCEL


@pytest.mark.parametrize(
    ("raw", "amount", "string_value", "recognized"),
    [
        ("1 2О5", D("1205"), None, False),
        ("42831 1", D("428311"), None, False),
        ("(7744536}", D("-7744536"), None, False),
        ("211(1 |", None, "211(1 |", False),
        ("(Ю4)", None, "(Ю4)", False),
        (": : 2856983 = ■", D("2856983"), None, False),
        ("| : 122477", D("122477"), None, False),
        ("|122477", D("122477"), None, False),
        ("{38378)", None, "{38378)", False),
        ("4,200", D("4200"), None, True),
        ("1,234,567", D("1234567"), None, True),
        ("837.994", D("837994"), None, True),
        ("1 234.567", D("1234567"), None, True),
        ("4,2", D("4.2"), None, True),
        ("1 234,56", D("1234.56"), None, True),
        ("1.234,56", D("123456"), None, False),
        ("0,125", None, "0,125", False),
        ("1 234 567", D("1234567"), None, True),
        ("(1 234)", D("-1234"), None, True),
        ("−500", D("-500"), None, True),
        ("12:34", None, "12:34", False),
        ("1 234 руб.", D("1234"), None, False),
        ("—", None, None, True),
        ("-", None, None, True),
        ("", None, None, True),
        ("I", None, "I", False),
    ],
)
def test_parse_amount_ocr(rules: Rules, raw: str, amount: D | None, string_value: str | None, recognized: bool) -> None:
    parsed = parse_amount(raw, OCR, rules)
    assert (parsed.amount, parsed.string_value, parsed.is_recognized) == (amount, string_value, recognized)


def test_comma_depends_on_source(rules: Rules) -> None:
    assert parse_amount("4,200", EXCEL, rules).amount == D("4.20")
    assert parse_amount("1.500", EXCEL, rules).amount == D("1.5")


def test_sign_forms(rules: Rules) -> None:
    assert sign_form_of("(7744536}", rules) is SignForm.PARENTHESES
    assert sign_form_of("-1 234", rules) is SignForm.MINUS
    assert sign_form_of("38378", rules) is SignForm.UNSIGNED
    assert sign_form_of("{38378)", rules) is None


def test_build_value_excel_number_and_out_of_range(rules: Rules) -> None:
    ok = build_value(Cell("106 311", D("106311.456"), 100.0, "Баланс!D27"), 3, EXCEL, rules)
    assert (ok.value.amount, ok.value.is_recognized, ok.out_of_range) == (D("106311.46"), True, False)
    big = build_value(Cell("1E+17", D("123456789012345678"), 100.0, "Баланс!D28"), 3, EXCEL, rules)
    assert (big.value.amount, big.value.string_value, big.value.is_recognized, big.out_of_range) == (
        None, "1E+17", False, True,
    )


def test_build_value_date_missing_multiline_split(rules: Rules) -> None:
    as_date = build_value(Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!D3"), 3, EXCEL, rules).value
    assert (as_date.amount, as_date.string_value, as_date.is_recognized) == (None, "30.09.2025", False)
    missing = build_value(None, 4, OCR, rules).value
    assert (missing.amount, missing.string_value, missing.text, missing.is_recognized) == (None, None, None, True)
    assert missing.confidence == pytest.approx(100.0)
    multi = build_value(Cell("917 775\n2 371 333", None, 100.0, "row:9"), 2, OCR, rules).value
    assert (multi.amount, multi.string_value, multi.is_recognized) == (None, "917 775\n2 371 333", False)
    part = build_value(Cell("917 775", None, 100.0, "row:9#1"), 2, OCR, rules, split_part=True).value
    assert (part.amount, part.is_recognized) == (D("917775"), False)
    dash = build_value(Cell("—", None, 100.0, "row:3"), 2, OCR, rules).value
    assert (dash.amount, dash.string_value, dash.is_recognized, dash.text) == (None, None, True, "—")
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_amounts.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement amounts.py. Стадии по спеке; уточнения, которые спека оставляет: «лучшая догадка» при пробеле не по группам и смешанные разделители — цифры подряд (не длиннее 16, иначе amount=None, string_value=text); текст без пробелов с более чем 16 целыми цифрами — amount=None, string_value=text, is_recognized=False (без предупреждения, P1-10). Пробелы — \s, NBSP  , тонкий  ,  . Удаление разделителей тысяч по шаблону исправлением не считается.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: parse_amount по стадиям (ядро числа, краевой мусор, скобки, разделители) и значение ячейки"

Task 7: Шапка кандидата и тип-подсказка

Files:

  • Create: $R/arm_statements/header.py
  • Test: $R/arm_statements/tests/test_header.py

Interfaces:

  • Consumes: HeaderCells, StatementHeader, HeaderField, DateField, parse_date, fix_lookalikes, Rules.

  • Produces (arm_statements/header.py):

    • parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader — у каждого поля text = cell.text, confidence = cell.confidence (нет ячейки — None, None, None); inn — только цифры текста без замены двойников, длина 10 или 12, иначе None; report_date — cell.value, если это date, иначе parse_date(fix_lookalikes(text)); units — первое совпадение units.spellings по normalize_caption(text), иначе по okei разобранному; okei, okpo, okopf, okfs — только цифры (пусто → None); okud — первое совпадение headers.okud_code; organization_name, address — текст без пробелов по краям (пусто → None); needs_review_fields = ()
    • type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None — ОКУД по словарю headers.okud → иначе заголовок по headers.form_titles (текст нормализуется: нижний регистр, ё → е, дефисы и переносы → пробел) → иначе None
    • continuation_type(text: str | None, rules: Rules) -> DocumentType | None — маркер «Форма 0710001 с.2» (headers.continuation) → тип по ОКУД из маркера; не маркер — None (для адаптера Excel плана 3)
  • Step 1: Write the failing test

from datetime import date

from arm_statements.header import continuation_type, parse_header, type_hint
from arm_statements.loader import Rules
from arm_statements.model import DocumentType
from arm_statements.tests.builders import hdr


def test_inn_digits_only_without_lookalikes(rules: Rules) -> None:
    head = parse_header(hdr(inn="|7700000000|"), rules)
    assert head.inn.value == "7700000000"
    dirty = parse_header(hdr(inn=("3|1 21 2|", 80.0)), rules)
    assert dirty.inn.value is None
    assert (dirty.inn.text, dirty.inn.confidence) == ("3|1 21 2|", 80.0)


def test_units_by_text_then_okei(rules: Rules) -> None:
    assert parse_header(hdr(units="в тыс. рублей"), rules).units.value == "тыс. руб."
    by_okei = parse_header(hdr(okei="384"), rules)
    assert (by_okei.units.value, by_okei.okei.value, by_okei.units.text) == ("тыс. руб.", "384", None)
    assert parse_header(hdr(units="руб."), rules).units.value == "руб."


def test_report_date_and_codes(rules: Rules) -> None:
    head = parse_header(hdr(report_date="30.09.2О25", okud="Форма по ОКУД 0710001", okopf="12267"), rules)
    assert head.report_date.value == date(2025, 9, 30)
    assert (head.okud.value, head.okopf.value) == ("0710001", "12267")


def test_type_hint_okud_wins(rules: Rules) -> None:
    assert type_hint("0710002", "Бухгалтерский баланс", rules) is DocumentType.INCOME
    assert type_hint("Форма по ОКУД 0710001", None, rules) is DocumentType.BALANCE
    assert type_hint("0710005", None, rules) is DocumentType.DISCLOSURE
    assert type_hint(None, "Пояснения к бухгалтерскому балансу", rules) is DocumentType.DISCLOSURE
    assert type_hint(None, "Отчет о прибылях и убытках", rules) is DocumentType.INCOME
    assert type_hint(None, "Бухгалтерская отчетность", rules) is None


def test_continuation_marker(rules: Rules) -> None:
    assert continuation_type("Форма 0710002 с.2", rules) is DocumentType.INCOME
    assert continuation_type("Бухгалтерский баланс", rules) is None
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_header.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement header.py по Interfaces.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: разбор шапки формы (ИНН без двойников, единицы по ОКЕИ) и тип-подсказка по ОКУД и заголовку"

Task 8: Шаг 2 — столбец кодов, нумерация, строка заголовка, роли, наименование

Files:

  • Create: $R/arm_statements/layout.py
  • Test: $R/arm_statements/tests/test_layout.py

Interfaces:

  • Consumes: Cell, RowRole, parse_code, is_code_cell, section_of, majority_section, normalize_caption, fix_lookalikes, parse_period_label, Rules.

  • Produces (arm_statements/layout.py; Grid = Sequence[Sequence[Cell | None]], строки first..last включительно):

    • cell_at(rows: Grid, row: int, col: int) -> Cell | None — вне строки → None
    • find_code_column(rows: Grid, first: int, last: int, rules: Rules, *, relaxed_section: CodeSection | None = None) -> int | None — правило (а)/(б) спеки «Шаг 2 — столбец кодов» («под подписью» — ячейки столбца ниже строки подписи до last); relaxed_section задан — ослабленный поиск: столбец с наибольшим числом кодов этого отрезка, минимум один (ничья — левый столбец)
    • first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rules) -> int | None
    • is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool — ≥ 3 непустых ячеек, каждая после fix_lookalikes(anywhere=True) — целое, слева направо +1, начало 1 или 2, не больше одного пропуска (скачок +2)
    • find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rules: Rules) -> tuple[int, ...] — строка заголовка по спеке «Шаг 2 — строка заголовка…» (строки нумерации пропускаются при поиске «последней над первой строкой с кодом»); двухстрочный: (r - 1, r), если для какого-то столбца правее кода подпись строки r - 1 отдельно — не период, а «подпись r - 1 + пробел + подпись r» — период (P1-9: «На 30 сентября» + «2025 г.»); не найдена — ()
    • segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole] — выше заголовка TITLE, строки заголовка HEADER, нумерация NUMBERING, остальное DATA; без заголовка TITLE нет
    • find_name_column(rows: Grid, header_rows: tuple[int, ...], first: int, last: int, code_col: int, rules: Rules) -> tuple[int | None, int | None] — (столбец наименования, столбец «Пояснения») по спеке «Шаг 2 — столбец наименования»; «Пояснения» — подпись captions.explanations, а без подписи — столбец левее кода, где все непустые значения не длиннее 3 символов
    • name_cell(cells: Sequence[Cell | None], name_col: int | None, code_col: int, explanations_col: int | None) -> Cell | None — ячейка столбца наименования; пустая — ближайшая левее кода ячейка с буквами, кроме «Пояснений»
  • Step 1: Write the failing test

from decimal import Decimal as D

from arm_statements.layout import (
    find_code_column, find_header_rows, find_name_column, first_code_row, is_numbering_row, segment_roles,
)
from arm_statements.loader import CodeSection, Rules
from arm_statements.model import Cell, RowRole
from arm_statements.tests.builders import excel, ocr, place

BALANCE_ROWS = [
    ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"],
    ["1", "2", "3", "4"],
    ["Нематериальные активы", "1110", "10", "9"],
    ["Основные средства", "1150", "20", "19"],
    ["Итого по разделу I", "1100", "30", "28"],
]


def test_code_column_by_caption(rules: Rules) -> None:
    cand = ocr(BALANCE_ROWS)
    assert (find_code_column(cand.rows, 0, 4, rules), first_code_row(cand.rows, 0, 4, 1, rules)) == (1, 2)


def test_title_block_kody_is_not_code_column(rules: Rules) -> None:
    title = [place({5: v}) for v in ["Коды", "0710001", "30 09 2025", "12345678", "7700000000", "12267", "16", "384",
                                      "", ""]]
    table = [place({0: n, 2: c, 3: D(a)}) for n, c, a in
             [("Запасы", "1210", "5"), ("НДС", "1220", "1"), ("Дебиторы", "1230", "4"), ("Итого", "1200", "10")]]
    cand = excel([("Баланс", [*title, *table])])
    assert [find_code_column(cand.rows, 0, len(cand.rows) - 1, rules)] == [2]


def test_two_line_document(rules: Rules) -> None:
    cand = ocr([["Наименование", "Код", "2025"], ["Выручка", "2110", "5"], ["Прибыль", "2400", "1"]])
    assert find_code_column(cand.rows, 0, 2, rules) == 1


def test_relaxed_search_finds_single_code(rules: Rules) -> None:
    cand = ocr([["Чистая прибыль", "", "2400", "7", "6"]])
    assert find_code_column(cand.rows, 0, 0, rules) is None
    assert [find_code_column(cand.rows, 0, 0, rules, relaxed_section=CodeSection.INCOME)] == [2]


def test_numbering_rows(rules: Rules) -> None:
    def row(*texts: str) -> tuple[Cell | None, ...]:
        return ocr([list(texts)]).rows[0]

    assert is_numbering_row(row("1", "2", "3", "4", "5"), rules)
    assert is_numbering_row(row("1", "2", "З", "4"), rules)
    assert is_numbering_row(row("2", "3", "4", "5"), rules)
    assert is_numbering_row(row("1", "2", "4", "5"), rules)
    assert not is_numbering_row(row("1", "3", "5"), rules)
    assert not is_numbering_row(row("1", "2"), rules)


def test_header_row_roles_and_title(rules: Rules) -> None:
    rows = [["Бухгалтерский баланс", "", "", ""], *BALANCE_ROWS]
    cand = ocr(rows)
    header = find_header_rows(cand.rows, 0, 3, 1, rules)
    assert header == (1,)
    roles = segment_roles(cand.rows, 0, 5, header, rules)
    assert roles == [RowRole.TITLE, RowRole.HEADER, RowRole.NUMBERING, RowRole.DATA, RowRole.DATA, RowRole.DATA]
    assert RowRole.TITLE not in segment_roles(cand.rows, 3, 5, (), rules)


def test_two_row_header_is_glued(rules: Rules) -> None:
    cand = ocr([["", "", "На 30 сентября", "На 31 декабря"], ["Наименование", "Код", "2025 г.", "2024 г."],
                ["Запасы", "1210", "5", "4"]])
    assert find_header_rows(cand.rows, 0, 2, 1, rules) == (0, 1)
    single = ocr([["Раздел", "", "", ""], ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"],
                  ["Запасы", "1210", "5", "4"]])
    assert find_header_rows(single.rows, 0, 2, 1, rules) == (1,)


def test_name_column_without_caption(rules: Rules) -> None:
    cand = ocr([["5.1", "Запасы", "1210", "5"], ["5.2", "Дебиторская задолженность", "1230", "4"],
                ["", "Итого", "1200", "9"]])
    assert find_name_column(cand.rows, (), 0, 2, 2, rules) == (1, 0)
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_layout.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement layout.py по Interfaces (доноры: tabular-parser columns.py — find_code_column, _find_header_row, _upper_header_row, _name_column, _note_columns; scan-gateway rows.py::is_numbering_row, layout.py). Ячейка-код для подсчёта — is_code_cell; «непустая» — text.strip() != "".

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — столбец кодов с защитой от блока «Коды», нумерация, строка заголовка, роли строк"

Task 9: Шаг 2 — колонки значений, подписи и даты

Files:

  • Create: $R/arm_statements/columns.py
  • Test: $R/arm_statements/tests/test_columns.py

Interfaces:

  • Consumes: layout.py (cell_at, find_header_rows), parse_period_label, resolve_relative, expected_dates, parse_amount, make_warning, StatementHeader, StatementColumn, Rules.

  • Produces (arm_statements/columns.py):

    • ColumnDraft(grid_col: int, label: str, label_confidence: float | None, kind: PeriodKind, date: date | None) — колонка сегмента до сопоставления; date абсолютной подписи (у относительной None)
    • has_numbers(rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules) -> bool — в строках с кодом есть ячейка с value-числом или с parse_amount(...).amount is not None
    • segment_columns(rows: Grid, header_rows: tuple[int, ...], data_rows: Sequence[int], code_col: int, source: Source, rules: Rules) -> tuple[list[ColumnDraft], list[StatementWarning]] — сегмент с заголовком: столбцы правее кода; подпись (склейка строк заголовка через пробел; уверенность — минимум) → период — колонка; исключение — пропуск и COLUMN_SKIPPED с column_index = grid_col; иначе при числах — колонка с подписью как есть, NONE, COLUMN_WITHOUT_DATE (P1-17: пустая подпись при числах — так же); без чисел — молча пропуск. Сегмент без заголовка (header_rows == ()): столбцы правее кода с числами, label="", label_confidence=None, NONE, без предупреждений
    • DocumentDates(effective: date | None, confident_effective: date | None, latest_absolute: date | None, confident_latest_absolute: date | None)
    • document_dates(drafts: Sequence[ColumnDraft], header: StatementHeader) -> DocumentDates — effective: дата отчёта, иначе самая поздняя абсолютная; confident_effective: дата отчёта с уверенностью 100, иначе самая поздняя абсолютная с label_confidence == 100; latest_absolute и confident_latest_absolute — самые поздние абсолютные (все / с уверенной подписью)
    • finalize_columns(drafts: Sequence[ColumnDraft], dates: DocumentDates, rules: Rules) -> tuple[tuple[StatementColumn, ...], list[StatementWarning]] — column_index = grid_col; относительные → resolve_relative(kind, dates.effective); абсолютная подпись с label_confidence < 100 не в expected_dates(dates.confident_effective) (или confident_effective is None) → COLUMN_DATE_UNCERTAIN с column_index (только абсолютные, P1-11)
  • Step 1: Write the failing test

from datetime import date
from decimal import Decimal as D

from arm_statements.columns import document_dates, finalize_columns, segment_columns
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import PeriodKind, Severity, Source
from arm_statements.tests.builders import excel, hdr, ocr

OCR, EXCEL = Source.OCR, Source.EXCEL


def run(rows: list[list[object]], source: Source, rules: Rules, header=None, header_rows=(0,), data_rows=(1, 2)):
    cand = ocr(rows) if source is OCR else excel([("Лист1", rows)])
    drafts, warnings = segment_columns(cand.rows, header_rows, data_rows, 1 if source is OCR else 2, source, rules)
    head = parse_header(header or hdr(), rules)
    columns, more = finalize_columns(drafts, document_dates(drafts, head), rules)
    return columns, [*warnings, *more]


def test_blank_66n_relative_labels(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На отчетную дату", "На 31 декабря предыдущего года",
             "На 31 декабря года, предшествующего предыдущему"],
            ["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]]
    columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
    assert [c.period_date for c in columns] == [date(2025, 9, 30), date(2024, 12, 31), date(2023, 12, 31)]
    assert [c.period_kind for c in columns] == [PeriodKind.CURRENT, PeriodKind.PREV_YEAR_END,
                                                PeriodKind.PREV_PREV_YEAR_END]
    assert warnings == []


def test_excel_skipped_change_column(rules: Rules) -> None:
    rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение, %"],
            ["Запасы", "", "1210", D("5"), D("4"), D("25")], ["Итого", "", "1200", D("5"), D("4"), D("25")]]
    columns, warnings = run(rows, EXCEL, rules)
    assert [c.column_index for c in columns] == [3, 4]
    assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_SKIPPED", Severity.INFO, 5)]
    assert {c.label_confidence for c in columns} == {100.0}


def test_junk_label_over_numbers_is_kept(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025", ("| |  г", 17.0)],
            ["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"]]
    columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
    junk = columns[1]
    assert (junk.label, junk.period_date, junk.period_kind) == ("| |  г", None, PeriodKind.NONE)
    assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)]


def test_sparse_ocr_columns(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025", None, "На 31.12.2024", "На 31.12.2023"],
            ["Запасы", "1210", "5", None, "4", "3"], ["Итого", "1200", "5", None, "4", "3"]]
    columns, _ = run(rows, OCR, rules)
    assert [c.column_index for c in columns] == [2, 4, 5]


def test_uncertain_label_does_not_confirm_itself(rules: Rules) -> None:
    rows = [["Наименование", "Код", ("На 30.09.2О25", 60.0), ""], ["Запасы", "1210", "5", "4"],
            ["Итого", "1200", "5", "4"]]
    cand = ocr(rows)
    drafts, _ = segment_columns(cand.rows, (0,), (1, 2), 1, OCR, rules)
    dates = document_dates(drafts, parse_header(hdr(), rules))
    assert (dates.effective, dates.confident_effective) == (date(2025, 9, 30), None)
    _, warnings = finalize_columns(drafts, dates, rules)
    assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 2)]


def test_uncertain_label_confirmed_by_report_date(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025", ("На 31.12.2024", 60.0), ("На 31.03.2025", 60.0)],
            ["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]]
    _, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
    assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 4)]


def test_segment_without_header(rules: Rules) -> None:
    cand = ocr([["Запасы", "1210", "5", "", "4"], ["Итого", "1200", "5", "", "4"]])
    drafts, warnings = segment_columns(cand.rows, (), (0, 1), 1, OCR, rules)
    assert [(d.grid_col, d.label, d.kind) for d in drafts] == [(2, "", PeriodKind.NONE), (4, "", PeriodKind.NONE)]
    assert warnings == []
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_columns.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement columns.py по Interfaces (донор — tabular-parser columns.py::_period_columns, _value_columns, _joined_label; scan-gateway document.py::_columns).

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — колонки периодов, относительные даты бланка 66н, неуверенные и мусорные подписи"

Task 10: Шаг 2 — сегменты, сопоставление колонок, find_layout

Files:

  • Create: $R/arm_statements/segments.py
  • Test: $R/arm_statements/tests/test_segments.py

Interfaces:

  • Consumes: layout.py, columns.py, majority_section, make_warning, FormCandidate, StatementHeader.
  • Produces (arm_statements/segments.py):
    • SegmentLayout(first_row: int, last_row: int, header_rows: tuple[int, ...], code_col: int | None, name_col: int | None, explanations_col: int | None, value_cols: tuple[tuple[int, int], ...], extra_cols: tuple[int, ...]) — value_cols: (столбец сетки сегмента, column_index колонки документа)
    • Layout(code_found: bool, column_section: CodeSection | None, columns: tuple[StatementColumn, ...], segments: tuple[SegmentLayout, ...], roles: tuple[RowRole, ...], dates: DocumentDates, warnings: tuple[StatementWarning, ...]) — roles — по всем строкам сетки; column_section — отрезок большинства кодов столбца кодов первого сегмента с найденным столбцом
    • find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout

Правила find_layout (спека «Шаг 2 — сегменты, повторная шапка и границы листов», «строка заголовка…», «номер колонки»): сетка делится по segment_breaks; внутри куска новый сегмент открывает строка-шапка, стоящая после первой строки с кодом текущего сегмента (ячейка подписи кода или ≥ 2 непустых ячеек, равных после normalize_caption ячейкам строк заголовка первого сегмента). Раскладка сегмента: с заголовком — столбец кодов (а)/(б) и колонки по заголовку; без заголовка — (б), а не первому сегменту при кодах < 3 — ослабленный поиск по column_section. Колонки документа — колонки первого сегмента; сегмент со своей шапкой сопоставляется по period_date (у каждой колонки дата есть и совпадает ровно с одной колонкой первого), иначе по порядку; без шапки — по порядку. По порядку и разное число — SEGMENT_COLUMNS_MISMATCH (значения в пределах меньшего числа, extra_cols — лишние, их номера в message). Сегмент с содержимым без столбца кодов — SEGMENT_WITHOUT_CODES, его строки — DATA. Предупреждения о подписях (COLUMN_SKIPPED, COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN) выдаются только по колонкам документа (сегмента, задающего колонки); у остальных сегментов столбцы-исключения пропускаются молча, неразобранные подписи не предупреждают (сценарий «Повторная шапка с другими подписями» — предупреждений нет). Заголовка нет ни у одного сегмента — колонки первого сегмента по правилу «без заголовка» и одно HEADER_ROW_NOT_FOUND; заголовок есть хоть у одного, а у первого нет — колонки документа берутся из первого сегмента с заголовком (P1-18). code_found — столбец кодов найден хотя бы у одного сегмента.

  • Step 1: Write the failing test
from decimal import Decimal as D

from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import RowRole
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr, place

DATES3 = ["На 30 сентября 2025 г.", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."]



def test_npz_sheet_with_own_header_maps_by_date(rules: Rules) -> None:
    first = [place({1: "Наименование показателя", 12: "Код", 16: DATES3[0], 21: DATES3[1], 24: DATES3[2]}),
             place({1: "Нематериальные активы", 12: "1110", 16: D("10"), 21: D("9"), 24: D("8")}),
             place({1: "Итого по разделу I", 12: "1100", 16: D("10"), 21: D("9"), 24: D("8")})]
    sheet4 = [place({0: "Наименование показателя", 6: "Код", 8: DATES3[0], 9: DATES3[1], 10: DATES3[2]}),
              place({0: "Заемные средства", 6: "1510", 8: D("3"), 9: D("2"), 10: D("1")})]
    cand = excel([("Бухгалтерский баланс", first), ("Бухгалтерский баланс. Лист 4", sheet4)])
    layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules)
    assert [c.column_index for c in layout.columns] == [16, 21, 24]
    assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24))
    assert not [w for w in layout.warnings if w.code.startswith("SEGMENT_")]


def test_repeat_header_mid_table(rules: Rules) -> None:
    rows = [["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"],
            ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Итого", "1200", "5", "4"]]
    layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
    assert [s.first_row for s in layout.segments] == [0, 2]
    assert layout.roles[2] is RowRole.HEADER


def test_repeat_header_without_dates_maps_by_order(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"],
            ["Наименование", "Код", "отчетн", "прошл"], ["Итого", "1200", "5", "4"]]
    layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
    assert layout.segments[1].value_cols == ((2, 2), (3, 3))
    assert layout.warnings == ()


def test_wider_segment_is_mismatch(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", "На 31.12.2023"],
            ["Запасы", "1210", "5", "4", "3"],
            ["Наименование", "Код", "графа 3", "графа 4", "графа 5", "графа 6"],
            ["Итого", "1200", "5", "4", "3", "2"]]
    layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
    (warning,) = [w for w in layout.warnings if w.code == "SEGMENT_COLUMNS_MISMATCH"]
    assert warning.severity.value == "error" and "5" in warning.message
    assert layout.segments[1].value_cols == ((2, 2), (3, 3), (4, 4)) and layout.segments[1].extra_cols == (5,)


def test_second_sheet_single_code_in_other_column(rules: Rules) -> None:
    first = [place({0: "Наименование", 3: "Код", 4: "За 9 месяцев 2025 г.", 5: "За 9 месяцев 2024 г."}),
             place({0: "Выручка", 3: "2110", 4: D("100"), 5: D("90")}),
             place({0: "Себестоимость", 3: "2120", 4: D("-60"), 5: D("-50")}),
             place({0: "Валовая прибыль", 3: "2100", 4: D("40"), 5: D("40")})]
    second = [place({0: "Чистая прибыль", 1: "2400", 2: D("7"), 3: D("6")})]
    cand = excel([("ОФР", first), ("ОФР_2", second)])
    layout = find_layout(cand, parse_header(hdr(), rules), rules)
    assert layout.segments[1].code_col == 1
    assert layout.segments[1].value_cols == ((2, 4), (3, 5))
    assert not [w for w in layout.warnings if w.code.startswith(("SEGMENT_", "HEADER_"))]


def test_header_not_found_anywhere(rules: Rules) -> None:
    rows = [["Запасы", "1210", "5", "4"], ["Дебиторы", "1230", "3", "2"], ["Итого", "1200", "8", "6"]]
    layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
    assert [w.code for w in layout.warnings] == ["HEADER_ROW_NOT_FOUND"]
    assert [(c.column_index, c.label) for c in layout.columns] == [(2, ""), (3, "")]


def test_segment_without_codes(rules: Rules) -> None:
    first = [["Наименование", "Код", "На 30.09.2025"], ["Запасы", "1210", "5"], ["Итого", "1200", "5"]]
    tail = [["Примечание к форме", "", "7"]]
    layout = find_layout(excel([("Баланс", first), ("Хвост", tail)]), parse_header(hdr(), rules), rules)
    assert [w.code for w in layout.warnings] == ["SEGMENT_WITHOUT_CODES"]
    assert layout.roles[3] is RowRole.DATA and layout.code_found
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_segments.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement segments.py по Interfaces и правилам выше (донор — tabular-parser merge.py, логика relaxed в columns.py::find_layout).

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — сегменты, повторная шапка, листы-продолжения и сопоставление колонок с первым сегментом"

Task 11: Шаг 3 — строки данных, статусы и разлепление

Files:

  • Create: $R/arm_statements/rows.py
  • Test: $R/arm_statements/tests/test_rows.py

Interfaces:

  • Consumes: Layout, SegmentLayout, cell_at, name_cell, parse_code, code_parts, section_of, has_digit, is_disguised_digit, parse_amount, make_warning, FormCandidate, LineStatus.

  • Produces (arm_statements/rows.py):

    • RawLine(row_index: int, ref: str, grid_row: int, code_cell: Cell | None, code: str | None, code_fixed: bool, line_status: LineStatus, name: Cell | None, parent_ref: str | None, cells: tuple[tuple[int, Cell | None], ...], split: bool) — cells — по всем колонкам документа по порядку layout.columns (column_index, ячейка сегмента или None); у разлепленных строк code_cell, name и ячейки значений — производные Cell с k-й частью текста, исходной уверенностью и ref <ref ячейки>#<k>
    • collect_raw_lines(candidate: FormCandidate, layout: Layout, rules: Rules) -> tuple[list[RawLine], list[StatementWarning]] — по спеке «Шаг 3 — строки данных и их статусы» и «Шаг 3 — разлепление»: строки роли DATA с содержимым; ref строки — row:<i> / row:<i>#<k> у OCR (<i> — из Cell.ref ячейки строки), у Excel — ref ячейки кода, без неё — первой непустой ячейки строки (у разлепленной — #<k>); row_index — порядковый с 0 после разлепления; parent_ref у DETAIL — ближайшая OK выше во всём документе; ROW_SPLIT (info) на каждую разлепленную строку сетки с line_refs её частей. Сегмент без столбца кодов: ячейка кода — None, значения — по столбцам сегмента value_cols (если есть), иначе строки идут без значений (статус — detail/missing по наличию содержимого в любой ячейке правее наименования)
  • Step 1: Write the failing test

from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import LineStatus
from arm_statements.rows import collect_raw_lines
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr

HEAD = ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"]
OK, UNREADABLE, DETAIL, MISSING = LineStatus.OK, LineStatus.UNREADABLE, LineStatus.DETAIL, LineStatus.MISSING


def lines_of(rows, rules: Rules, source: str = "ocr"):
    cand = ocr(rows) if source == "ocr" else excel([("Баланс", rows)])
    layout = find_layout(cand, parse_header(hdr(), rules), rules)
    return collect_raw_lines(cand, layout, rules)


def test_junk_code_lines_survive(rules: Rules) -> None:
    lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["Мусор", "ИЧ,250", "7", "6"],
                         ["в том числе: прочее", "", "1", ""], ["", "44 30\nI |", "", ""], ["Итого", "1100", "10", "8"]],
                        rules)
    assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "1110"), (OK, "1150"), (UNREADABLE, None),
                                                           (DETAIL, None), (UNREADABLE, None), (OK, "1100")]
    assert lines[3].parent_ref == lines[1].ref == "row:2"
    assert [ln.row_index for ln in lines] == [0, 1, 2, 3, 4, 5]


def test_lookalike_only_code_cells_are_empty(rules: Rules) -> None:
    lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["", "изо :", ("3", 0.0), ""],
                         ["", "НИ", "I", ""], ["", "| 140", "2", "1"], ["Итого", "1100", "12", "9"]], rules)
    assert [(ln.line_status, ln.code, ln.code_fixed) for ln in lines] == [
        (OK, "1110", False), (OK, "1150", False), (DETAIL, None, False), (DETAIL, None, False), (OK, "1140", True),
        (OK, "1100", False),
    ]


def test_missing_before_first_code_and_name_only_row(rules: Rules) -> None:
    lines, _ = lines_of([HEAD, ["Выручка", "", "100", "90"], ["Раздел I. Активы", "", "", ""],
                         ["Запасы", "1150", "5", "4"]], rules)
    assert [ln.line_status for ln in lines] == [MISSING, OK]


def test_duplicate_code_both_ok(rules: Rules) -> None:
    lines, _ = lines_of([HEAD, ["Налог", "2410", "100", "1"], ["Налог УСН", "2410", "649", "2"]], rules)
    assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "2410"), (OK, "2410")]


def test_split_glued_rows(rules: Rules) -> None:
    lines, warnings = lines_of([HEAD, ["Запасы", "1150", "5", "4"],
                                ["Займы\nКредиторы", "1500\n1700", "917 775\n2 371 333", "1\n2"]], rules)
    split = lines[1:]
    assert [(ln.ref, ln.code, ln.split) for ln in split] == [("row:2#1", "1500", True), ("row:2#2", "1700", True)]
    assert [ln.cells[0][1].text for ln in split if ln.cells[0][1] is not None] == ["917 775", "2 371 333"]
    assert [ln.name.text for ln in split if ln.name is not None] == ["Займы", "Кредиторы"]
    assert [(w.code, w.line_refs) for w in warnings] == [("ROW_SPLIT", ("row:2#1", "row:2#2"))]


def test_not_split_when_no_part_in_column_range(rules: Rules) -> None:
    lines, warnings = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"],
                                ["", "44 30\nI |", "1\n2", ""], ["Итого", "1100", "10", "8"]], rules)
    assert [ln.line_status for ln in lines] == [OK, OK, UNREADABLE, OK] and warnings == []


def test_excel_signature_text_is_not_content(rules: Rules) -> None:
    rows = [["Наименование", "", "Код", "На 30.09.2025"], ["Запасы", "", "1150", "5"],
            ["Руководитель", "", "", "Косарев"], ["", "", "", "(расшифровка подписи)"]]
    lines, _ = lines_of(rows, rules, source="excel")
    assert [ln.ref for ln in lines] == ["Баланс!C2"]
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_rows.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement rows.py. Содержимое ячейки значения: parse_amount(text, source).amount is not None, или непуста с уверенностью < 100, или is_disguised_digit; у ячейки с value-числом — содержимое всегда. Разлепление — до разбора кода; «часть — код диапазона столбца» — parse_code(part).code и section_of == layout.column_section.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 3 — строки данных со статусами ok/unreadable/detail/missing, ref, разлепление склеек"

Task 12: Шаги 4–5 — строки формы и предупреждения единиц

Files:

  • Create: $R/arm_statements/lines.py
  • Test: $R/arm_statements/tests/test_lines.py

Interfaces:

  • Consumes: RawLine, build_value, make_warning, StatementLine, StatementHeader, Source, Rules.

  • Produces (arm_statements/lines.py):

    • CODE_CONFIDENCE_CAP = 99.0
    • build_lines(raw: Sequence[RawLine], source: Source, rules: Rules) -> tuple[list[StatementLine], list[StatementWarning]] — line_code только у OK; line_code_text — code_cell.text; line_code_confidence — уверенность ячейки кода, при code_fixed — min(уверенность, 99.0); line_name / line_name_confidence — текст ячейки наименования как есть / её уверенность; needs_review = False (ставит шаг 8); values — build_value(cell, column_index, source, split_part=raw.split) по всем колонкам; VALUE_OUT_OF_RANGE (info) на каждое значение вне диапазона с line_refs=(ref,) и column_index
    • units_warnings(lines: Sequence[StatementLine], header: StatementHeader, adapter_warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning] — единицы units.thousands и доля значений с дробной частью среди значений с amount is not None ≥ fractional_share_percent → XLSB_SCALE_SUSPECT, если среди adapter_warnings есть XLSB_NO_FORMATS (P1-5), иначе FRACTIONAL_IN_THOUSANDS
  • Step 1: Write the failing test

from decimal import Decimal as D

from arm_statements.header import parse_header
from arm_statements.issues import make_warning
from arm_statements.lines import build_lines, units_warnings
from arm_statements.loader import Rules
from arm_statements.model import Severity, Source
from arm_statements.rows import collect_raw_lines
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr

HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]


def statement_lines(cand, rules: Rules):
    layout = find_layout(cand, parse_header(hdr(), rules), rules)
    raw, _ = collect_raw_lines(cand, layout, rules)
    return build_lines(raw, cand.source, rules)


def test_ocr_name_and_code_confidence(rules: Rules) -> None:
    cand = ocr([HEAD, [("Дебиторская задолжен-ность", 71.0), "1230", "5"], ["Итого", "I1О0", "5"]])
    lines, _ = statement_lines(cand, rules)
    assert (lines[0].line_name, lines[0].line_name_confidence) == ("Дебиторская задолжен-ность", 71.0)
    assert (lines[1].line_code, lines[1].line_code_text, lines[1].line_code_confidence) == ("1100", "I1О0", 99.0)
    assert [v.column_index for v in lines[0].values] == [2]


def test_value_out_of_range_warning(rules: Rules) -> None:
    cand = excel([("Баланс", [["Наименование", "Код", "На 30.09.2025"],
                              ["Запасы", "1210", D("123456789012345678")]])])
    lines, warnings = statement_lines(cand, rules)
    assert lines[0].values[0].amount is None
    assert [(w.code, w.severity, w.line_refs, w.column_index) for w in warnings] == [
        ("VALUE_OUT_OF_RANGE", Severity.INFO, ("Баланс!B2",), 2)
    ]


def test_fractional_in_thousands(rules: Rules) -> None:
    rows = [["Наименование", "Код", "На 30.09.2025"], *[[f"Строка {i}", c, D(v)] for i, (c, v) in
            enumerate([("1210", "1.5"), ("1220", "2.25"), ("1230", "3"), ("1240", "4"), ("1200", "10.75")])]]
    cand = excel([("Баланс", rows)])
    lines, _ = statement_lines(cand, rules)
    head = parse_header(hdr(units="тыс. руб."), rules)
    assert [w.code for w in units_warnings(lines, head, (), rules)] == ["FRACTIONAL_IN_THOUSANDS"]
    xlsb = make_warning("XLSB_NO_FORMATS", "формат чисел xlsb не читается", rules)
    (scale,) = units_warnings(lines, head, (xlsb,), rules)
    assert (scale.code, scale.severity) == ("XLSB_SCALE_SUSPECT", Severity.ERROR)
    assert units_warnings(lines, parse_header(hdr(units="руб."), rules), (), rules) == []
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_lines.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement lines.py по Interfaces.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаги 4–5 — строки формы с сырым текстом и уверенностью, предупреждения единиц и масштаба xlsb"

Task 13: Шаг 7 — самопроверки §7 и вариант формы

Files:

  • Create: $R/arm_statements/evaluation.py, $R/arm_statements/checks.py
  • Test: $R/arm_statements/tests/test_checks.py

Interfaces:

  • Consumes: CheckRules, RelationRule, DocumentType, FormVariant, LineStatus, FailedRelation, StatementChecks, section_of.

  • Produces (arm_statements/evaluation.py) — общий вид строк шагов 6–8 у сервиса и ядра:

    • EvalValue(column: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, fixed: bool = False) — column — ключ колонки (str(column_index) у сервиса, ключ хоста у ядра); fixed — правлено или подтверждено аналитиком
    • EvalLine(key: str, order: int | None, line_status: LineStatus, line_code: str | None, code_confidence: float | None, code_fixed: bool, values: tuple[EvalValue, ...]) — key: ref у сервиса, ключ хоста у ядра; order: row_index (у ядра None — строка правки)
    • is_check_line(line: EvalLine) -> bool — OK с кодом; у строки order is None — ещё и хотя бы одно amount не None (P1-8)
  • Produces (arm_statements/checks.py):

    • form_variant(document_type: DocumentType, ok_codes: Iterable[str]) -> FormVariant | None — спека «Шаг 6»: ББ — есть 1600 и нет ни 1100, ни 1200 → SIMPLIFIED; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → SIMPLIFIED; иначе FULL; DISCLOSURE → None
    • subline_parents(codes: Sequence[str]) -> list[str | None] — для каждой позиции: у пятизначного кода — ближайший четырёхзначный выше с теми же первыми тремя цифрами, иначе первые четыре цифры; у прочих — None
    • RelationOutcome(column: str, rule: RelationRule, codes: tuple[str, ...], computed: bool, expected: Decimal, actual: Decimal, within_tolerance: bool, within_rounding: bool) со свойством hard_failed: bool (rule.strict and computed and not within_tolerance and not within_rounding)
    • evaluate_relations(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, variant: FormVariant | None, rules: Rules) -> list[RelationOutcome] — строки — в порядке документа (вызывающий сортирует; от порядка зависят родители подстрок); набор relations_for(document_type, variant or FULL) + сгенерированные «родитель = Σ подстрок» (мягкие, только родители в отрезке формы, только в колонке, где хоть одна подстрока непуста); значение кода — сумма amount по строкам is_check_line; посчитано — непуста цель или член; правая часть как записано и с abs_terms по модулю, берётся сошедшийся вариант, иначе ближайший (при равенстве — «как записано»); within_tolerance: |expected − actual| ≤ tolerance; within_rounding: |expected − actual| ≤ max(|expected|, |значения членов|) × rounding_tolerance_percent / 100
    • checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks — hard_failed → relations_failed; посчитанное и не сошедшееся в пределах допуска прочее → soft_relations_failed; codes — цель и члены; relation — текст правила
  • Step 1: Write the failing test

from decimal import Decimal as D

from arm_statements.checks import checks_from, evaluate_relations, form_variant, subline_parents
from arm_statements.evaluation import EvalLine, EvalValue
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, FormVariant, LineStatus

BAL, INC = DocumentType.BALANCE, DocumentType.INCOME


def line(code: str | None, *amounts: str | None, status: LineStatus = LineStatus.OK, order: int = 0) -> EvalLine:
    values = tuple(EvalValue(str(i), None if a is None else D(a), a, 100.0, True) for i, a in enumerate(amounts))
    return EvalLine(f"k{order}{code}", order, status, code, 100.0, False, values)


def failed(lines: list[EvalLine], doc: DocumentType, variant: FormVariant | None, rules: Rules):
    outcomes = evaluate_relations(lines, ["0"], doc, variant, rules)
    return checks_from(outcomes, {"0": 0})


def test_form_variant() -> None:
    assert form_variant(BAL, ["1150", "1210", "1600", "1310", "1700"]) is FormVariant.SIMPLIFIED
    assert form_variant(BAL, ["1100", "1600"]) is FormVariant.FULL
    assert form_variant(INC, ["2110", "2400"]) is FormVariant.SIMPLIFIED
    assert form_variant(INC, ["2110", "2100", "2400"]) is FormVariant.FULL
    assert form_variant(DocumentType.DISCLOSURE, ["5101"]) is None


def test_empty_target_is_violation(rules: Rules) -> None:
    lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"),
             line(None, "100", status=LineStatus.DETAIL)]
    checks = failed(lines, BAL, FormVariant.FULL, rules)
    assert [f.relation for f in checks.relations_failed] == ["1600 = 1100 + 1200"]


def test_target_without_members_is_violation(rules: Rules) -> None:
    lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"), line("1600", "100")]
    checks = failed(lines, BAL, FormVariant.FULL, rules)
    assert [f.relation for f in checks.relations_failed] == ["1600 = 1700"]
    assert checks.relations_failed[0].codes == ("1600", "1700")


def test_duplicate_code_is_summed(rules: Rules) -> None:
    lines = [line("2300", "849"), line("2410", "100"), line("2410", "649"), line("2400", "100")]
    outcomes = evaluate_relations(lines, ["0"], INC, FormVariant.FULL, rules)
    target = next(o for o in outcomes if o.rule.relation == "2400 = 2300 - 2410 + 2420")
    assert target.within_tolerance


def test_simplified_income_soft(rules: Rules) -> None:
    lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")]
    checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
    assert checks.relations_failed == ()
    assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"]


def test_rounding_goes_soft(rules: Rules) -> None:
    lines = [line("1600", "17000000009.02"), line("1700", "17000000000"), line("1100", "17000000009.02"),
             line("1300", "17000000000")]
    checks = failed(lines, BAL, FormVariant.FULL, rules)
    assert "1600 = 1700" in [f.relation for f in checks.soft_relations_failed]
    assert "1600 = 1700" not in [f.relation for f in checks.relations_failed]


def test_abs_terms_hold_with_minus(rules: Rules) -> None:
    lines = [line("2110", "100"), line("2120", "-60"), line("2100", "40")]
    hard = failed(lines, INC, FormVariant.FULL, rules).relations_failed
    assert "2100 = 2110 - 2120" not in [f.relation for f in hard]


def test_sublines(rules: Rules) -> None:
    assert subline_parents(["1230", "12301", "12302", "1240", "11101"]) == [None, "1230", "1230", None, "1110"]
    lines = [line("1230", "10"), line("12301", "4"), line("12302", "5")]
    soft = failed(lines, BAL, FormVariant.FULL, rules).soft_relations_failed
    assert [f.relation for f in soft] == ["1230 = 12301 + 12302"]
    empty = [line("1230", "10"), line("12301", None), line("12302", None)]
    assert failed(empty, BAL, FormVariant.FULL, rules).soft_relations_failed == ()
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_checks.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement evaluation.py и checks.py по Interfaces (доноры: tabular-parser checks.py, arm-core statement_checks.py::subline_parents). Текст сгенерированного правила — "<родитель> = <подстрока> + …" в порядке строк.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 7 — посчитанные соотношения §7, abs_terms, допуск и округление, упрощённый набор, подстроки"

Task 14: Шаг 8 — подтверждение неуверенного кода структурой и редакция

Files:

  • Create: $R/arm_statements/structure.py
  • Test: $R/arm_statements/tests/test_structure.py

Interfaces:

  • Consumes: EvalLine, is_check_line, CodeOrders, Edition, FormVariant, subline_parents.
  • Produces (arm_statements/structure.py):
    • FSBU4_FROM = date(2025, 1, 1) — эффективная дата после 2024-12-31 → fsbu4
    • edition_by_date(effective: date | None) -> Edition | None
    • StructureResult(edition: Edition | None, unconfirmed: frozenset[str]) — unconfirmed — ключи строк с неуверенным кодом, не подтверждённым структурой (их needs_review = true)
    • confirm_codes(lines: Sequence[EvalLine], document_type: DocumentType, variant: FormVariant | None, effective: date | None, rules: Rules) -> StructureResult

Алгоритм (спека «Шаг 8 — подтверждение неуверенного кода структурой»; сигнатура его не определяет):

  1. Последовательность S — строки is_check_line с order is not None и четырёхзначным кодом, по order. Неуверенный код — code_confidence is None or code_confidence < 100, и code_fixed ложно.
  2. Для редакции e и перечня L = sequence(e, document_type, variant or FULL): в S остаются только коды из L (позиции сохраняются). Лучшая подпоследовательность — взвешенная LCS S × L по равенству кода с весом позиции w = (len(S) + 1) * уверенность + 1 (уверенный — 1, иначе 0): максимум суммы весов = лексикографически «больше уверенных, затем длиннее»; код с повтором в L может совпасть столько раз, сколько стоит в L.
  3. Редакция — с большим оптимумом; равенство — edition_by_date(effective); равенство и effective is None → edition = None, неуверенный код подтверждён, только если подтверждён в обеих редакциях (P1-4).
  4. Неуверенная позиция i подтверждена ⇔ оптимум с запретом позиции i строго меньше общего оптимума (то есть i входит во все лучшие подпоследовательности).
  5. Пятизначный неуверенный код подтверждён, если строка-родитель (subline_parents) стоит раньше по order и уверенна или подтверждена; родителя нет — не подтверждён.
  6. document_type DISCLOSURE или нет неуверенных кодов → unconfirmed = ∅; edition — по оптимуму, при пустой S — edition_by_date(effective).
  • Step 1: Write the failing test
from datetime import date

from arm_statements.evaluation import EvalLine
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, Edition, FormVariant, LineStatus
from arm_statements.structure import confirm_codes

BAL = DocumentType.BALANCE
Q3_2025 = date(2025, 9, 30)


def seq(*items: tuple[str, float]) -> list[EvalLine]:
    return [EvalLine(f"r{i}", i, LineStatus.OK, code, conf, False, ()) for i, (code, conf) in enumerate(items)]


def unconfirmed(lines: list[EvalLine], rules: Rules, effective: date | None = Q3_2025) -> set[str]:
    return set(confirm_codes(lines, BAL, FormVariant.FULL, effective, rules).unconfirmed)


def test_uncertain_code_in_place(rules: Rules) -> None:
    assert unconfirmed(seq(("1220", 100.0), ("1230", 50.0), ("1240", 100.0)), rules) == set()


def test_swapped_uncertain_neighbours(rules: Rules) -> None:
    lines = seq(("1210", 100.0), ("1230", 50.0), ("1220", 50.0), ("1240", 100.0))
    assert unconfirmed(lines, rules) == {"r1", "r2"}


def test_conflicting_anchors(rules: Rules) -> None:
    lines = seq(("1210", 100.0), ("1240", 100.0), ("1230", 100.0), ("1250", 50.0))
    assert unconfirmed(lines, rules) == set()


def test_legal_repeat_1220_fsbu4(rules: Rules) -> None:
    lines = seq(("1215", 100.0), ("1220", 100.0), ("1220", 50.0), ("1230", 100.0))
    assert unconfirmed(lines, rules) == set()


def test_repeat_outside_orders(rules: Rules) -> None:
    lines = seq(("1220", 100.0), ("1230", 100.0), ("1230", 50.0), ("1240", 100.0))
    assert unconfirmed(lines, rules) == {"r2"}


def test_66n_blank_in_2025(rules: Rules) -> None:
    lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0))
    result = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules)
    assert (result.edition, result.unconfirmed) == (Edition.RAS_66N, frozenset())


def test_tie_resolved_by_date(rules: Rules) -> None:
    lines = seq(("1210", 100.0), ("1230", 100.0))
    assert confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition is Edition.FSBU4
    assert confirm_codes(lines, BAL, FormVariant.FULL, date(2024, 12, 31), rules).edition is Edition.RAS_66N


def test_confident_nonstandard_code_not_flagged(rules: Rules) -> None:
    assert unconfirmed(seq(("1230", 100.0), ("1231", 100.0), ("11101", 100.0)), rules) == set()


def test_subline_follows_confirmed_parent(rules: Rules) -> None:
    lines = seq(("1220", 100.0), ("1230", 50.0), ("12301", 60.0), ("1240", 100.0))
    assert unconfirmed(lines, rules) == set()
    orphan = seq(("1220", 100.0), ("12301", 60.0), ("1240", 100.0))
    assert unconfirmed(orphan, rules) == {"r1"}
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_structure.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement structure.py по алгоритму выше (DP O(|S|·|L|), повтор с запретом позиции — на каждую неуверенную позицию; размеры форм ≤ 60 строк).

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 8 — подтверждение неуверенного кода перечнем бланка, якоря, законные повторы, выбор редакции"

Task 15: Шаг 8 — needs_review значений и строк, поля шапки, evaluate

Files:

  • Create: $R/arm_statements/review.py
  • Test: $R/arm_statements/tests/test_review.py

Interfaces:

  • Consumes: EvalLine, evaluate_relations, RelationOutcome, confirm_codes, sign_form_of, make_warning, StatementHeader, DocumentDates, CheckRules.abs_codes.

  • Produces (arm_statements/review.py):

    • value_needs_review(lines: Sequence[EvalLine], outcomes: Sequence[RelationOutcome], abs_codes: frozenset[str], rules: Rules, *, accepted: bool = False) -> dict[tuple[str, str], bool] — ключ (line.key, value.column); правила спеки «Шаг 8 — needs_review значения»: DETAIL → False; fixed → False; нуждается в покрытии ⇔ confidence is not None and confidence < 100 или not is_recognized; покрыто ⇔ amount is not None и в колонке есть посчитанное соотношение с кодом строки, у которого within_tolerance (жёсткое — ещё и within_rounding); знак члена abs_codes, нуждающегося в покрытии, подтверждён ⇔ его sign_form_of(text) равна форме строгого большинства прочих значений abs_codes той же колонки с amount is not None, is_recognized, не fixed; True — нуждается и (не покрыто или знак не подтверждён), а также у всех значений строк is_check_line с кодами hard_failed-соотношения в его колонке — кроме accepted=True (жёсткие нарушения акцептованы и статус не держат, P1-20)
    • line_needs_review(lines: Sequence[EvalLine], structure: StructureResult) -> dict[str, bool] — True только у ключей structure.unconfirmed; code_fixed и не-OK → False
    • Evaluation(variant: FormVariant | None, edition: Edition | None, outcomes: tuple[RelationOutcome, ...], line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], hard_failures: int)
    • evaluate(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, effective: date | None, rules: Rules, *, accepted: bool = False) -> Evaluation — variant = form_variant(тип, коды is_check_line), затем evaluate_relations, confirm_codes, value_needs_review, line_needs_review; hard_failures — число hard_failed
    • review_header(header: StatementHeader, dates: DocumentDates, rules: Rules) -> tuple[StatementHeader, list[StatementWarning]] — спека «Шаг 8 — поля шапки» и таблица уровней: дата отчёта с уверенностью < 100, не равная dates.confident_latest_absolute → needs_review_fields = ("report_date",) и REPORT_DATE_UNCERTAIN (field="report_date"); NO_ORGANIZATION_NO_INN — нет ни организации, ни ИНН; INN_NOT_FOUND — нет ИНН при организации; ORGANIZATION_NOT_FOUND — нет организации при ИНН; INN_UNCERTAIN / ORGANIZATION_UNCERTAIN — поле есть с уверенностью < 100 (field); REPORT_DATE_UNPARSED — текст даты есть, дата не разобрана, а абсолютные даты колонок есть; PERIOD_UNKNOWN — нет ни даты отчёта, ни latest_absolute; UNITS_NOT_FOUND — нет единиц
  • Step 1: Write the failing test

from datetime import date
from decimal import Decimal as D

from arm_statements.columns import DocumentDates
from arm_statements.evaluation import EvalLine, EvalValue
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, LineStatus
from arm_statements.review import evaluate, review_header
from arm_statements.tests.builders import hdr

OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE
NO_DATES = DocumentDates(None, None, None, None)


def ln(code: str | None, text: str, amount: str | None, conf: float = 100.0, recognized: bool = True,
       status: LineStatus = OK, order: int = 0) -> EvalLine:
    value = EvalValue("0", None if amount is None else D(amount), text, conf, recognized)
    return EvalLine(f"{code}-{order}", order, status, code, 100.0, False, (value,))


def flags(lines: list[EvalLine], doc: DocumentType, rules: Rules) -> dict[str, bool]:
    result = evaluate(lines, ["0"], doc, date(2025, 9, 30), rules)
    return {key: flag for (key, _), flag in result.value_needs_review.items()}


def test_guess_confirmed_by_relation(rules: Rules) -> None:
    lines = [ln("1210", "100", "100", order=0), ln("1230", "42831 1", "428311", recognized=False, order=1),
             ln("1200", "428411", "428411", order=2)]
    assert flags(lines, DocumentType.BALANCE, rules)["1230-1"] is False


def test_sign_in_curly_bracket_confirmed(rules: Rules) -> None:
    lines = [ln("2110", "10000000", "10000000", order=0),
             ln("2120", "(7744536}", "-7744536", recognized=False, order=1),
             ln("2100", "2255464", "2255464", order=2), ln("2210", "(100)", "-100", order=3),
             ln("2220", "(200)", "-200", order=4), ln("2200", "2255164", "2255164", order=5)]
    assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is False


def test_sign_outside_convention(rules: Rules) -> None:
    lines = [ln("2110", "100000", "100000", order=0), ln("2120", "38378", "38378", conf=60.0, order=1),
             ln("2100", "61622", "61622", order=2), ln("2210", "(100)", "-100", order=3),
             ln("2220", "(200)", "-200", order=4)]
    assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is True


def test_null_amount_and_unchecked_code(rules: Rules) -> None:
    lines = [ln("2120", "{38378)", None, recognized=False, order=0), ln("2900", "15", "15", conf=80.0, order=1)]
    result = flags(lines, DocumentType.INCOME, rules)
    assert (result["2120-0"], result["2900-1"]) == (True, True)


def test_detail_values_never_flagged(rules: Rules) -> None:
    lines = [ln("2110", "100", "100", order=0), ln(None, "1 425 130", "1425130", conf=78.0, status=DETAIL, order=1)]
    assert flags(lines, DocumentType.INCOME, rules)["None-1"] is False


def test_soft_relation_covers_only_exactly(rules: Rules) -> None:
    exact = [ln("2300", "1000", "1000", order=0), ln("2410", "200", "200", order=1),
             ln("2400", "800", "800", conf=66.0, order=2)]
    assert flags(exact, DocumentType.INCOME, rules)["2400-2"] is False
    rounding = [ln("2300", "17000000000", "17000000000", order=0), ln("2410", "0", "0", order=1),
                ln("2400", "17000000009", "17000000009", conf=66.0, order=2)]
    assert flags(rounding, DocumentType.INCOME, rules)["2400-2"] is True


def test_hard_failure_flags_clean_cells(rules: Rules) -> None:
    lines = [ln("1210", "100", "100", order=0), ln("1230", "50", "50", order=1), ln("1200", "999", "999", order=2)]
    result = evaluate(lines, ["0"], DocumentType.BALANCE, None, rules)
    assert result.hard_failures >= 1
    assert all(result.value_needs_review[(f"{c}-{i}", "0")] for i, c in enumerate(["1210", "1230", "1200"]))


def test_unreadable_values(rules: Rules) -> None:
    lines = [ln(None, "7", "7", conf=60.0, status=UNREADABLE), ln(None, "5", "5", status=UNREADABLE, order=1)]
    result = flags(lines, DocumentType.BALANCE, rules)
    assert (result["None-0"], result["None-1"]) == (True, False)


def test_report_date_confirmed_or_not(rules: Rules) -> None:
    head = parse_header(hdr(report_date=("30.09.2025", 60.0), organization_name="ООО «Пример»",
                            inn="7700000000", units="тыс. руб."), rules)
    ok_dates = DocumentDates(date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30))
    reviewed, warnings = review_header(head, ok_dates, rules)
    assert (reviewed.needs_review_fields, warnings) == ((), [])
    relative_only = DocumentDates(date(2025, 9, 30), None, None, None)
    reviewed, warnings = review_header(head, relative_only, rules)
    assert reviewed.needs_review_fields == ("report_date",)
    assert [(w.code, w.severity.value, w.field) for w in warnings] == [("REPORT_DATE_UNCERTAIN", "error", "report_date")]


def test_header_presence_warnings(rules: Rules) -> None:
    _, no_inn = review_header(parse_header(hdr(organization_name="ООО «Пример»", report_date="30.09.2025",
                                               units="тыс. руб."), rules), NO_DATES, rules)
    assert [w.code for w in no_inn] == ["INN_NOT_FOUND"]
    _, nothing = review_header(parse_header(hdr(), rules), NO_DATES, rules)
    assert {w.code for w in nothing} == {"NO_ORGANIZATION_NO_INN", "PERIOD_UNKNOWN", "UNITS_NOT_FOUND"}
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_review.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement review.py по Interfaces. abs_codes — rules.checks.abs_codes(document_type, variant or FULL).

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 8 — needs_review по покрытию §7 и конвенции знака, проверки полей шапки"

Task 16: Функция статуса документа и конверта

Files:

  • Create: $R/arm_statements/status.py
  • Test: $R/arm_statements/tests/test_status.py

Interfaces:

  • Consumes: DocumentType, LineStatus, Severity, DocStatus, EnvelopeStatus, StatementWarning.

  • Produces (arm_statements/status.py):

    • StatusLine(line_status: LineStatus, needs_review: bool); StatusValue(line_status: LineStatus, needs_review: bool)
    • StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...], hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])
    • document_status(data: StatusInput) -> DocStatus — спека «Шаг 8 — статус документа и конверта»: DISCLOSURE → DONE; иначе DONE ⇔ layout_found ∧ has_organization_or_inn ∧ period_known ∧ все строки OK/DETAIL ∧ нет строк с needs_review ∧ нет значений с needs_review, кроме значений строк DETAIL ∧ needs_review_fields пуст ∧ (hard_failures == 0 или accepted) ∧ нет Severity.ERROR в warning_severities
    • envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus — документов нет → ERROR; все DONE и нет предупреждений файла уровня error → DONE; иначе NEEDS_CHECK
  • Step 1: Write the failing test

from dataclasses import replace

from arm_statements.issues import make_warning
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, EnvelopeStatus, LineStatus, Severity
from arm_statements.status import StatusInput, StatusLine, StatusValue, document_status, envelope_status

OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE
DONE, CHECK = DocStatus.DONE, DocStatus.NEEDS_CHECK
BASE = StatusInput(
    document_type=DocumentType.BALANCE, layout_found=True, has_organization_or_inn=True, period_known=True,
    needs_review_fields=(), lines=(StatusLine(OK, False),), values=(StatusValue(OK, False),), hard_failures=0,
    accepted=False, warning_severities=(Severity.INFO,),
)


def test_clean_form_done() -> None:
    assert document_status(BASE) is DONE


def test_unreadable_line_blocks() -> None:
    assert document_status(replace(BASE, lines=(StatusLine(OK, False), StatusLine(UNREADABLE, False)))) is CHECK


def test_detail_value_does_not_block() -> None:
    lines = (StatusLine(OK, False), StatusLine(DETAIL, False))
    values = (StatusValue(OK, False), StatusValue(DETAIL, True))
    assert document_status(replace(BASE, lines=lines, values=values)) is DONE


def test_blocking_conditions() -> None:
    assert document_status(replace(BASE, values=(StatusValue(OK, True),))) is CHECK
    assert document_status(replace(BASE, lines=(StatusLine(OK, True),))) is CHECK
    assert document_status(replace(BASE, needs_review_fields=("report_date",))) is CHECK
    assert document_status(replace(BASE, hard_failures=2)) is CHECK
    assert document_status(replace(BASE, hard_failures=2, accepted=True)) is DONE
    assert document_status(replace(BASE, warning_severities=(Severity.ERROR,))) is CHECK
    assert document_status(replace(BASE, layout_found=False)) is CHECK
    assert document_status(replace(BASE, has_organization_or_inn=False)) is CHECK
    assert document_status(replace(BASE, period_known=False)) is CHECK


def test_disclosure_always_done() -> None:
    disclosure = replace(BASE, document_type=DocumentType.DISCLOSURE, layout_found=False, lines=(), values=())
    assert document_status(disclosure) is DONE


def test_envelope(rules: Rules) -> None:
    assert envelope_status([DONE, CHECK], []) is EnvelopeStatus.NEEDS_CHECK
    assert envelope_status([DONE], []) is EnvelopeStatus.DONE
    lost = make_warning("TABLE_LOST", "таблица без колонки кодов", rules, locator="file:a.json;table:1")
    assert envelope_status([DONE], [lost]) is EnvelopeStatus.NEEDS_CHECK
    assert envelope_status([], []) is EnvelopeStatus.ERROR
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_status.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: Implement status.py по Interfaces.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: общая функция статуса документа (detail не держит) и статуса конверта"

Task 17: Шаг 6 и конвейер кандидата

Files:

  • Create: $R/arm_statements/pipeline.py
  • Modify: $R/arm_statements/__init__.py (экспорт)
  • Test: $R/arm_statements/tests/test_pipeline.py

Interfaces:

  • Consumes: всё из задач 3–16.
  • Produces (arm_statements/pipeline.py):
    • normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult
    • normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult — по порядку; исключения не перехватывает (хост ловит их на кандидате и пишет TABLE_PARSE_ERROR)

Порядок normalize_candidate (спека «Шаг 6 — тип формы, вариант и документ», «Шаг 8»):

  1. header = parse_header(candidate.header); layout = find_layout(candidate, header).
  2. Нет столбца кодов (not layout.code_found): type_hint есть → документ без строк (columns=(), lines=(), checks=None, form_variant=None у DISCLOSURE, иначе FULL… — см. P1-19), DISCLOSURE → DONE без предупреждений шапки, иначе CODE_COLUMN_NOT_FOUND и статус по функции статуса (layout_found=False); type_hint нет → документа нет: у OCR при содержимом (ячейка с цифрой или is_disguised_digit) — предупреждение файла TABLE_LOST, без содержимого — TABLE_SKIPPED; у EXCEL — SHEET_OTHER с locator = "sheet:<имя первого листа>" (из candidate.locator); у файловых предупреждений locator = candidate.locator (OCR).
  3. Строки шага 3; тип — document_type_of(majority_section(коды строк OK)); кодов OK нет — type_hint; нет и его — как п. 2 без type_hint. Тип по кодам противоречит не-None type_hint → TYPE_CONFLICT (error).
  4. Тип DISCLOSURE → документ без строк, DONE.
  5. Иначе: build_lines, units_warnings; EvalLine из StatementLine (key = ref, order = row_index, колонки str(column_index), fixed=False); evaluate(..., effective=layout.dates.effective); checks_from; review_header; значения и строки получают needs_review (evaluate(..., accepted=False)); документ: form_variant из Evaluation, checks (у документа со строками — всегда, даже пустые списки), предупреждения в порядке: адаптера, раскладки, строк (ROW_SPLIT, VALUE_OUT_OF_RANGE), единиц, TYPE_CONFLICT, шапки; status = document_status(...) с layout_found=True, period_known = report_date ∨ latest_absolute, accepted=False.
  • Step 1: Write the failing test
from decimal import Decimal as D

from arm_statements import normalize, normalize_candidate
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, FormVariant, Severity
from arm_statements.tests.builders import excel, hdr, ocr

HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
FULL_HEADER = hdr(organization_name="ООО «Пример»", inn="7700000000", report_date="30.09.2025", units="тыс. руб.")


def bal_rows() -> list[list[str]]:
    return [HEAD, ["Запасы", "1210", "60"], ["Дебиторы", "1230", "40"], ["Итого II", "1200", "100"],
            ["НМА", "1110", "50"], ["Итого I", "1100", "50"], ["БАЛАНС", "1600", "150"],
            ["Капитал", "1310", "150"], ["Итого III", "1300", "150"], ["БАЛАНС", "1700", "150"]]


def test_clean_ocr_balance_done(rules: Rules) -> None:
    result = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules)
    doc = result.document
    assert doc is not None and result.file_warnings == ()
    assert (doc.document_type, doc.form_variant, doc.status) == (DocumentType.BALANCE, FormVariant.FULL, DocStatus.DONE)
    assert doc.checks is not None and doc.checks.relations_failed == ()
    assert doc.header.inn.value == "7700000000"


def test_disclosure_hint_with_balance_codes(rules: Rules) -> None:
    doc = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.DISCLOSURE), rules).document
    assert doc is not None and doc.document_type is DocumentType.BALANCE
    assert [(w.code, w.severity) for w in doc.warnings if w.code == "TYPE_CONFLICT"] == [("TYPE_CONFLICT",
                                                                                          Severity.ERROR)]


def test_balance_hint_with_income_codes(rules: Rules) -> None:
    rows = [HEAD, ["Выручка", "2110", "100"], ["Себестоимость", "2120", "(60)"], ["Валовая", "2100", "40"],
            ["Коммерческие", "2210", "(10)"], ["Прибыль от продаж", "2200", "30"]]
    doc = normalize_candidate(ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules).document
    assert doc is not None and doc.document_type is DocumentType.INCOME
    assert "TYPE_CONFLICT" in [w.code for w in doc.warnings]


def test_excel_other_sheet(rules: Rules) -> None:
    result = normalize_candidate(excel([("Примечания", [["Пояснительный текст без цифр"]])]), rules)
    assert result.document is None
    assert [(w.code, w.severity, w.locator) for w in result.file_warnings] == [("SHEET_OTHER", Severity.INFO,
                                                                                "sheet:Примечания")]


def test_ocr_lost_and_skipped_tables(rules: Rules) -> None:
    lost = normalize_candidate(ocr([["Итого", "", "15 000"]], locator="file:a.json;table:1"), rules)
    assert [(w.code, w.locator) for w in lost.file_warnings] == [("TABLE_LOST", "file:a.json;table:1")]
    skipped = normalize_candidate(ocr([["Руководитель", "Иванов"]]), rules)
    assert [w.code for w in skipped.file_warnings] == ["TABLE_SKIPPED"] and skipped.document is None


def test_hint_without_code_column(rules: Rules) -> None:
    doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER),
                              rules).document
    assert doc is not None and (doc.lines, doc.columns, doc.checks) == ((), (), None)
    assert doc.status is DocStatus.NEEDS_CHECK and "CODE_COLUMN_NOT_FOUND" in [w.code for w in doc.warnings]
    disc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document
    assert disc is not None and (disc.status, disc.form_variant, disc.lines) == (DocStatus.DONE, None, ())


def test_disclosure_by_codes(rules: Rules) -> None:
    rows = [HEAD, ["Пояснение", "5101", "1"], ["Пояснение", "5102", "2"], ["Пояснение", "5100", "3"]]
    doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
    assert doc is not None and (doc.document_type, doc.lines, doc.status) == (DocumentType.DISCLOSURE, (),
                                                                             DocStatus.DONE)


def test_simplified_balance(rules: Rules) -> None:
    rows = [HEAD, ["Матактивы", "1150", "50"], ["Запасы", "1210", "50"], ["БАЛАНС", "1600", "100"],
            ["Капитал", "1300", "100"], ["БАЛАНС", "1700", "100"]]
    doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
    assert doc is not None and doc.form_variant is FormVariant.SIMPLIFIED
    assert doc.checks is not None and doc.checks.relations_failed == ()


def test_income_without_inn_still_done(rules: Rules) -> None:
    rows = [["Наименование", "Код", "За 9 месяцев 2025 г."], ["Выручка", "2110", D("100")],
            ["Себестоимость", "2120", D("-60")], ["Валовая прибыль", "2100", D("40")],
            ["Прибыль от продаж", "2200", D("40")], ["Прибыль до налогообложения", "2300", D("40")],
            ["Чистая прибыль", "2400", D("40")]]
    head = hdr(organization_name="ООО «Пример»", units="тыс. руб.")
    doc = normalize_candidate(excel([("ОФР", rows)], header=head), rules).document
    assert doc is not None and doc.status is DocStatus.DONE
    assert [(w.code, w.severity) for w in doc.warnings] == [("INN_NOT_FOUND", Severity.INFO)]


def test_normalize_collects(rules: Rules) -> None:
    result = normalize([ocr(bal_rows(), header=FULL_HEADER), ocr([["Руководитель", "Иванов"]])], rules)
    assert len(result.documents) == 1 and [w.code for w in result.file_warnings] == ["TABLE_SKIPPED"]
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_pipeline.py -v Expected: FAIL — ImportError: cannot import name 'normalize'

  • Step 3: Implement pipeline.py по порядку выше; __init__.py экспортирует имена раздела «Публичный API», уже существующие к этой задаче.

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 6 и конвейер кандидата — тип по кодам, вариант формы, пояснения, предупреждения файла"

Task 18: Точка входа пересчёта по сохранённым данным

Files:

  • Create: $R/arm_statements/recompute.py
  • Modify: $R/arm_statements/__init__.py
  • Test: $R/arm_statements/tests/test_recompute.py

Interfaces:

  • Consumes: EvalLine, EvalValue, evaluate, checks_from, document_status, StatusInput.
  • Produces (arm_statements/recompute.py):
    • StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)
    • StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, value_fixed: bool)
    • StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None, line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None, values: tuple[StoredValue, ...])
    • StoredWarning(code: str, severity: Severity)
    • StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool, warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])
    • RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks, line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], detail_parents: Mapping[str, str | None], status: DocStatus) — value_needs_review — ключ (line.key, column_key); line_needs_review и value_needs_review — по всем строкам и значениям формы
    • recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult

Правила (спека «Точка входа пакета для пересчёта…»): StoredLine → EvalLine(key, row_index, line_status, line_code, line_code_confidence, code_fixed, values=EvalValue(column_key, amount, text, confidence, is_recognized, fixed=value_fixed)); evaluate(lines, [c.key for c in columns], document_type, effective_date, accepted=accepted); checks = checks_from(outcomes, {c.key: c.column_index}); detail_parents — у каждой строки DETAIL: ближайшая выше по row_index строка OK с row_index is not None, у строки DETAIL без row_index — None; статус — document_status с layout_found = any(row_index is not None), hard_failures из Evaluation, остальное из StoredForm. line_status, суммы и коды не меняются. DISCLOSURE — checks пустые, form_variant = None, edition = edition_by_date(effective_date), DONE.

  • Step 1: Write the failing test
from dataclasses import replace
from datetime import date
from decimal import Decimal as D

from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, recompute
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, Edition, FormVariant, LineStatus, PeriodKind

OK, DETAIL = LineStatus.OK, LineStatus.DETAIL
COL = StoredColumn("c1", 2, date(2025, 9, 30), PeriodKind.ABSOLUTE)


def sl(key: str, row: int | None, code: str | None, amount: str | None, *, status: LineStatus = OK,
       conf: float = 100.0, code_fixed: bool = False, value_conf: float = 100.0, value_fixed: bool = False,
       recognized: bool = True) -> StoredLine:
    value = StoredValue("c1", None if amount is None else D(amount), amount, value_conf, recognized, value_fixed)
    return StoredLine(key, row, status, code, conf, None, code_fixed, None, (value,))


def form(*lines: StoredLine, document_type: DocumentType = DocumentType.BALANCE) -> StoredForm:
    return StoredForm(document_type, date(2025, 9, 30), True, True, (), False, (), (COL,), tuple(lines))


def test_recompute_after_edit(rules: Rules) -> None:
    result = recompute(form(
        sl("l1210", 0, "1210", "100"),
        sl("l1230", 1, "1230", "300", value_conf=60.0, value_fixed=True),
        sl("l1250", 2, "1250", "50", conf=40.0, code_fixed=True),
        sl("l1260", None, "1260", "50"),
        sl("ldet", 3, None, "7", status=DETAIL, value_conf=60.0),
        sl("l1200", 4, "1200", "500"),
    ), rules)
    # 1200 = 100 + 300 + 50 + 50 (с 1250 и строкой правки 1260) — сходится; 1600 нет — «пустая цель»
    assert [(f.relation, f.column_key, f.column_index) for f in result.checks.relations_failed] == [
        ("1600 = 1100 + 1200", "c1", 2)
    ]
    assert result.value_needs_review[("l1230", "c1")] is False
    assert result.value_needs_review[("ldet", "c1")] is False
    assert result.line_needs_review["l1250"] is False
    assert result.detail_parents == {"ldet": "l1250"}
    assert (result.edition, result.form_variant, result.status) == (Edition.FSBU4, FormVariant.FULL,
                                                                   DocStatus.NEEDS_CHECK)


def test_code_change_switches_variant(rules: Rules) -> None:
    simplified = form(sl("a", 0, "1150", "50"), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"),
                      sl("d", 3, "1700", "50"))
    assert recompute(simplified, rules).form_variant is FormVariant.SIMPLIFIED
    edited = form(sl("a", 0, "1100", "50", code_fixed=True), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"),
                  sl("d", 3, "1700", "50"))
    result = recompute(edited, rules)
    assert result.form_variant is FormVariant.FULL
    assert result.status is DocStatus.NEEDS_CHECK


def test_accepted_and_empty_edit_line(rules: Rules) -> None:
    broken = form(sl("a", 0, "1600", "100"), sl("b", 1, "1700", "90"), sl("e", None, "1220", None))
    assert recompute(broken, rules).status is DocStatus.NEEDS_CHECK
    assert recompute(replace(broken, accepted=True), rules).status is DocStatus.DONE
  • Step 2: Run test to verify it fails

Run: .venv/bin/pytest arm_statements/tests/test_recompute.py -v Expected: FAIL — ImportError: cannot import name 'StoredColumn'

  • Step 3: Implement recompute.py по правилам выше; экспорт в __init__.py. Строки сортируются по (row_index is None, row_index) до evaluate; evaluate(..., accepted=form.accepted).

  • Step 4: Run tests

Run: make check Expected: всё зелёное.

  • Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: точка входа пересчёта формы по сохранённым данным (правки, подтверждения, строки правки)"

Task 19: Golden на обезличенных входах (Review Focus), README, итоговая проверка

Files:

  • Create: $R/arm_statements/tests/golden/{aep3_balance,aep3_income,glued_balance,kody_title_balance,npz_income}.candidate.json и одноимённые *.expected.json
  • Create: $R/arm_statements/tests/test_golden.py
  • Modify: $R/README.md (раздел «Публичный API» — копия раздела этого плана; «Как менять словари»; «golden-update»)
  • Modify: $R/arm_statements/__init__.py (окончательный __all__)

Interfaces:

  • Consumes: normalize_candidate, to_jsonable, candidate_from_dict, builders.
  • Produces: ничего нового для других задач.

Кандидаты собираются один раз скриптом в теле теста-генератора (builders → to_jsonable → файл) и дальше лежат файлами; названия, ИНН и адреса — обезличенные, суммы — синтетические, но мусорный текст ячеек — дословно из fixtures (Review Focus). Эталон *.expected.json — to_jsonable(normalize_candidate(...)); при GOLDEN_UPDATE=1 тест переписывает эталоны и падает с напоминанием просмотреть diff; без переменной — сравнивает. Ключевые факты каждого входа проверяются явными assert (эталон ловит дрейф, assert — смысл).

  • Step 1: Write the failing test
import json
import os
from decimal import Decimal as D
from pathlib import Path

import pytest

from arm_statements import normalize_candidate
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, LineStatus, StatementDocument
from arm_statements.serialize import candidate_from_dict, to_jsonable

GOLDEN = Path(__file__).parent / "golden"
FULL_CONFIDENCE = 100.0
NAMES = ["aep3_balance", "aep3_income", "glued_balance", "kody_title_balance", "npz_income"]


def run(name: str, rules: Rules) -> StatementDocument:
    data = json.loads((GOLDEN / f"{name}.candidate.json").read_text(encoding="utf-8"))
    result = normalize_candidate(candidate_from_dict(data), rules)
    assert result.document is not None
    return result.document


@pytest.mark.parametrize("name", NAMES)
def test_golden_snapshot(name: str, rules: Rules) -> None:
    actual = to_jsonable(run(name, rules))
    target = GOLDEN / f"{name}.expected.json"
    if os.environ.get("GOLDEN_UPDATE") == "1":
        target.write_text(json.dumps(actual, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        pytest.fail(f"эталон {target.name} пересобран — просмотрите diff и запустите без GOLDEN_UPDATE")
    assert actual == json.loads(target.read_text(encoding="utf-8"))


def test_aep3_junk_codes(rules: Rules) -> None:  # Review Focus 1
    doc = run("aep3_balance", rules)
    by_text = {ln.line_code_text: ln for ln in doc.lines}
    assert (by_text["| 140"].line_status, by_text["| 140"].line_code) == (LineStatus.OK, "1140")
    confidence = by_text["| 140"].line_code_confidence
    assert confidence is not None and confidence < FULL_CONFIDENCE
    for junk in ("НИ", "изо :"):
        assert by_text[junk].line_status is LineStatus.DETAIL and by_text[junk].parent_ref is not None
    refs = [ln.ref for ln in doc.lines]
    assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines)))


def test_aep3_dirty_income_amounts(rules: Rules) -> None:  # Review Focus 2
    doc = run("aep3_income", rules)
    values = {v.text: v for ln in doc.lines for v in ln.values if v.text}
    assert (values["(7744536}"].amount, values["(7744536}"].needs_review) == (D("-7744536"), False)
    assert (values["211(1 |"].amount, values["211(1 |"].needs_review) == (None, True)
    assert (values["42831 1"].amount, values["42831 1"].needs_review) == (D("428311"), False)
    assert doc.status is DocStatus.NEEDS_CHECK


def test_glued_rows(rules: Rules) -> None:  # Review Focus 3
    doc = run("glued_balance", rules)
    split = [ln for ln in doc.lines if "#" in ln.ref]
    assert [(ln.line_code, ln.values[0].amount) for ln in split] == [("1500", D("917775")), ("1700", D("2371333"))]
    assert [w.code for w in doc.warnings if w.code == "ROW_SPLIT"] == ["ROW_SPLIT"]
    assert not [v for ln in doc.lines for v in ln.values if v.needs_review]


def test_kody_title_block(rules: Rules) -> None:  # Review Focus 4
    doc = run("kody_title_balance", rules)
    assert "12267" not in [ln.line_code for ln in doc.lines]
    assert doc.header.okopf.value == "12267" and doc.status is DocStatus.DONE


def test_npz_continuation_single_code(rules: Rules) -> None:  # Review Focus 5
    doc = run("npz_income", rules)
    (line_2400,) = [ln for ln in doc.lines if ln.line_code == "2400"]
    assert [v.column_index for v in line_2400.values] == [c.column_index for c in doc.columns]
    assert all(v.amount is not None for v in line_2400.values)
    assert "," in doc.locator and not [w for w in doc.warnings if w.code.startswith("SEGMENT_")]
    assert doc.status is DocStatus.DONE
  • Step 2: Build golden inputs

Временный генератор (в arm_statements/tests/test_golden.py не остаётся; в коммит не идёт): собрать пять кандидатов builders и записать to_jsonable(...) в golden/<name>.candidate.json. Содержание:

  • aep3_balance — OCR ББ на бланке 66н (шапка с ИНН «3|1 21 2|» уверенностью 80 и организацией «НКО «Пример»»), строки 1110, 1150, «| 140» (уверенность 100), «НИ» с суммой, «изо :» с '3' уверенностью 0, 1100, 1210…1200, 1600, раздел III НКО (повтор 1310…1300), 1700; суммы сходятся.
  • aep3_income — OCR ОФР: 2110, 2120 "(7744536}", 2100 сходится по модулю; 2210 "(100)", 2220 "(200)", 2200; строка 2310 с "211(1 |"; 2320 "42831 1" и 2300 так, что 2300 = … сходится только при 428311 (ячейка 2310 null — член пуст, соотношение всё равно посчитано: подобрать 2300 = сумма прочих членов).
  • glued_balance — OCR ББ: …, строка с кодом "1500\n1700", суммами "917 775\n2 371 333", наименованием "Итого по разделу V\nБАЛАНС"; 1510 + 1520 = 917775, 1300 + 1400 + 1500 = 2371333, 1600 = 2371333.
  • kody_title_balance — Excel ББ одного листа «Баланс»: титульный блок справа (столбец F: «Коды», 0710001, 30 | 09 | 2025, ОКПО, ИНН 7700000000, ОКОПФ 12267, ОКФС 16, ОКЕИ 384), таблица без подписи «Код» в столбце C (≥ 6 кодов), шапка HeaderCells из адаптера (организация, ИНН, дата, ОКОПФ «12267», ОКЕИ «384» без текста единиц).
  • npz_income — Excel ОФР на листах «ОФР» (шапка, «Код» в столбце D, 2110…2300, 2410, 2460) и «ОФР_2» (без шапки, одна строка 2400 с кодом в столбце B и суммами в C, D); суммы сходятся, 2400 = 2300 + 2410 + 2460 точно.

Затем GOLDEN_UPDATE=1 .venv/bin/pytest arm_statements/tests/test_golden.py -k snapshot и просмотр каждого *.expected.json глазами против Review Focus.

  • Step 3: Run tests to verify state

Run: .venv/bin/pytest arm_statements/tests/test_golden.py -v Expected: PASS всех тестов. Если факт-тест падает — это находка в коде задач 8–17, а не повод править тест: чинить код, пересобрать эталон, повторить.

  • Step 4: README и __all__

__init__.py экспортирует ровно имена раздела «Публичный API пакета» ниже; тест test_public_api в arm_statements/tests/test_golden.py не нужен — добавить в arm_statements/tests/test_model.py:

import arm_statements

PUBLIC = {
    "Source", "DocumentType", "FormVariant", "Edition", "PeriodKind", "LineStatus", "Severity", "DocStatus",
    "EnvelopeStatus", "SignForm", "CellValue", "Cell", "Evidence", "HeaderCells", "HEADER_FIELDS", "StatementWarning",
    "FormCandidate", "HeaderField", "DateField", "StatementHeader", "StatementColumn", "StatementValue",
    "StatementLine", "FailedRelation", "StatementChecks", "StatementDocument", "CandidateResult",
    "NormalizationResult", "Rules", "RulesError", "load_rules", "default_rules", "normalize_candidate", "normalize",
    "parse_header", "type_hint", "continuation_type", "sheet_joins", "parse_code", "parse_amount", "parse_date",
    "fix_lookalikes", "make_warning", "StatusInput", "StatusLine", "StatusValue", "document_status",
    "envelope_status", "StoredForm", "StoredColumn", "StoredLine", "StoredValue", "StoredWarning", "RecomputeResult",
    "recompute", "to_jsonable", "candidate_from_dict", "statements_version",
}


def test_public_api() -> None:
    assert set(arm_statements.__all__) == PUBLIC
    assert all(hasattr(arm_statements, name) for name in PUBLIC)

README.md: «Публичный API» (копия раздела ниже), «Как менять словари» (PR сюда; перечень кодов — только из docs, байтовой копией; новый код предупреждения — statement_warnings.yaml + таблица §11.9 docs), «golden-update».

  • Step 5: Final verification

Run: make check Expected: ruff, format, mypy — без ошибок; все тесты зелёные; git -C "$R" status чист после коммита; файла arm_statements/VERSION нет.

  • Step 6: Commit
git -C "$R" add README.md arm_statements
git -C "$R" commit -m "test: golden на обезличенных входах aep_3, склейки, блока «Коды» 1С и листа-продолжения НПЗ; публичный API"

Публичный API пакета

Импорт — from arm_statements import …. Всё, что не перечислено, — внутреннее (может меняться без координации с хостами). Все dataclass-ы — frozen=True, slots=True; последовательности — tuple.

Перечисления (StrEnum, значение — строка контракта)

Имя Значения
Source OCR = "ocr", EXCEL = "excel"
DocumentType BALANCE, INCOME, DISCLOSURE
FormVariant FULL = "full", SIMPLIFIED = "simplified"
Edition RAS_66N = "66n", FSBU4 = "fsbu4"
PeriodKind ABSOLUTE = "absolute", CURRENT = "relative:current", PREV_YEAR_END = "relative:prev_year_end", PREV_PREV_YEAR_END = "relative:prev_prev_year_end", PREV_PERIOD = "relative:prev_period", NONE = "none"
LineStatus OK = "ok", UNREADABLE = "unreadable", MISSING = "missing", DETAIL = "detail"
Severity ERROR = "error", INFO = "info"
DocStatus DONE = "done", NEEDS_CHECK = "needs_check"
EnvelopeStatus DONE = "done", NEEDS_CHECK = "needs_check", ERROR = "error"
SignForm PARENTHESES = "parentheses", MINUS = "minus", UNSIGNED = "unsigned"

Вход шага 1 (строит адаптер хоста)

CellValue = Decimal | date | bool | None

Cell(text: str, value: CellValue, confidence: float, ref: str)
    # text — как показывает источник; value — у OCR всегда None, у Excel — по числовому формату (Decimal), дата или bool;
    # confidence 0–100 (Excel — 100); ref — OCR "row:<row_index ИС>", Excel "<лист>!<A1>" по правилу Excel
Evidence(okud: Cell | None = None, title: Cell | None = None)
HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None)
HEADER_FIELDS: tuple[str, ...]  # имена полей HeaderCells по порядку
StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (),
                 column_index: int | None = None, field: str | None = None, locator: str | None = None)
FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells,
              rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (),
              warnings: tuple[StatementWarning, ...] = ())
    # rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0);
    # locator: "file:<имя json>;table:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу
    # (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …)

Выход шагов 2–8

HeaderField(text: str | None, confidence: float | None, value: str | None)
DateField(text: str | None, confidence: float | None, value: date | None)
StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField,
                okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField,
                address: HeaderField, needs_review_fields: tuple[str, ...] = ())
    # контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence
StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None,
                period_kind: PeriodKind)
StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None,
               confidence: float, is_recognized: bool, needs_review: bool = False)
StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None,
              line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None,
              parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])
    # values — по всем columns документа, в их порядке
FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...],
               expected: Decimal, actual: Decimal)
    # сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key
StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])
StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str,
                  header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...],
                  checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])
    # checks is None ⇔ lines == (); form_variant is None ⇔ DISCLOSURE (у BALANCE/INCOME без строк — FULL, P1-19)
CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])
NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])

Функции

normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult
    # шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER)
normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult
    # по порядку; исключение кандидата не перехватывается — хост ловит его и пишет TABLE_PARSE_ERROR
document_status(data: StatusInput) -> DocStatus
envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus
    # ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост
recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult
parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader
type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None
continuation_type(text: str | None, rules: Rules) -> DocumentType | None      # маркер «Форма 0710001 с.2»
sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int,
            form_amount_columns: int, rules: Rules) -> bool                    # лист без маркера и шапки
parse_code(text: str | None, rules: Rules) -> CodeParse                       # CodeParse(code: str | None, fixed: bool)
parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse
    # AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None)
parse_date(text: str | None, rules: Rules) -> date | None
fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str
make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None,
             field: str | None = None, locator: str | None = None) -> StatementWarning
    # уровень — из словаря statement_warnings.yaml; хосты создают JSON_FAILED, TABLE_PARSE_ERROR, FILE_TYPE_MISMATCH,
    # XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED только через неё
to_jsonable(obj: object) -> Any                                    # dataclass пакета → json-совместимое значение
candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate      # обратная для кандидата
statements_version(path: Path | None = None) -> str | None         # содержимое api/arm_statements/VERSION копии
load_rules(directory: Path | None = None) -> Rules                 # RulesError при ошибке словаря
default_rules() -> Rules                                           # кэш словарей пакета

CodeParse и AmountParse в __all__ не входят (возвращаются функциями; при необходимости аннотаций — arm_statements.codes.CodeParse, arm_statements.amounts.AmountParse).

Статус

StatusLine(line_status: LineStatus, needs_review: bool)
StatusValue(line_status: LineStatus, needs_review: bool)       # line_status — статус строки значения
StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool,
            needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...],
            hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])
    # needs_review на входе уже учитывает правки и подтверждения; значения строк DETAIL функция игнорирует

Пересчёт по сохранённым данным (ядро)

StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)
StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None,
            is_recognized: bool, value_fixed: bool)
    # text — source_text (у значения правки None); value_fixed — is_modified или is_confirmed
StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None,
           line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None,
           values: tuple[StoredValue, ...])
    # row_index None — строка правки (в §7 и варианте — пока есть сумма; в структуре и родителях detail — нет);
    # code_fixed — line_code_modified или is_confirmed строки
StoredWarning(code: str, severity: Severity)
StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool,
           period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool,
           warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])
RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks,
                line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool],
                detail_parents: Mapping[str, str | None], status: DocStatus)
    # value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL

Словари (Rules) — что нужно хостам

Rules.headers.labels / auditor / legal_form / form_titles / okud / continuation — метки шапки и заголовки для адаптера Excel (план 3); Rules.code_orders.sequence(edition, form, variant) — перечень кодов редакции (ядру — порядок и состав строк формы, план 4); Rules.lookalikes — буквы-двойники; Rules.warnings — уровни предупреждений.


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

Контроллер переносит их в design.md docs, раздел ## Decisions made without discussion (дата 18.10.2026).

  • P1-1. Конфиг линтеров пакета — только в корневом pyproject.toml репозитория arm-statements (в дерево arm_statements/ не входит и в хост не копируется): ruff.toml внутри arm_statements/ по иерархии ruff перекрыл бы конфиг хоста, а спека требует «конфиг хоста применяется к копии». Код пакета написан под самый строгий конфиг, так что он проходит любой хост. Цена: «конфиг линтеров вместе с копией» (формулировка спеки) читается как «код, проходящий конфиг», а не как файл; см. O-1.

  • P1-2. Версии инструментов пакета — пины scan-gateway (ruff==0.16.10, mypy==2.4.0, pytest==9.1.1): это единственный хост с явными пинами; у tabular-parser и arm-core >=, в их окружениях встречаются ruff 0.16.10–0.17.1. Цена: новая версия ruff в хосте может найти новое замечание в копии; см. O-2.

  • P1-3. PLR2004 в тестах пакета не отключается: per-file-ignores хостов (tests/** у scan-gateway) на копию api/arm_statements/tests/ не распространяются. Цена: числа в тестах — кортежами и D("…").

  • P1-4. Ничья редакций при effective = None — edition = None, неуверенный код подтверждён только при подтверждении в обеих редакциях. Цена: лишняя подсветка кода у формы без даты (она и так needs_check — PERIOD_UNKNOWN).

  • P1-5. «Книга xlsb без числовых форматов» шаг 4 узнаёт по предупреждению адаптера XLSB_NO_FORMATS в FormCandidate.warnings — отдельного поля у кандидата спека не задаёт. Цена: адаптер Excel обязан класть это предупреждение в кандидат каждой формы книги xlsb.

  • P1-6. У DISCLOSURE предупреждения шапки не выдаются, статус всегда done (спека: «DISCLOSURE — done без условий»). Цена: пустая шапка пояснений не видна предупреждением.

  • P1-7. Код Excel из текстовой ячейки с заменой двойника — неуверенный (≤ 99) и проходит структурную проверку, как OCR; «у Excel needs_review по структуре не ставится» прочитано как «у чистых кодов Excel» (уверенность 100). Цена: редкий текстовый код «I1О0» в Excel может подсветиться.

  • P1-8. Строка правки (row_index = None) у ядра участвует в §7 и в выборе варианта, только если у неё есть сумма; в структурной последовательности и как родитель detail — нет (позиции в файле нет). Цена: код правки не служит якорем для подтверждения соседних неуверенных кодов.

  • P1-9. Двухстрочный заголовок склеивается, когда подпись верхней строки отдельно — не период, а «верхняя + нижняя» — период («На 30 сентября» + «2025 г.»; нижняя «2025 г.» сама по себе дала бы ошибочное 31.12.2025). Цена: заголовок в два ряда без даты не склеивается (колонка всё равно остаётся по числам); если над строкой заголовка стоит посторонний текст, который вместе с нижней подписью случайно читается как период, подпись склеится.

  • P1-10. Текстовая сумма без пробелов длиннее 16 целых цифр — amount = null, string_value = текст, is_recognized = false, без VALUE_OUT_OF_RANGE (предупреждение — только для числового value Excel, как в спеке).

  • P1-11. COLUMN_DATE_UNCERTAIN проверяется только у абсолютных подписей: относительные даты вычисляются от эффективной даты и в ожидаемые входят по построению.

  • P1-12. Поля шапки, правила которых спека не задаёт: okpo, okopf, okfs — только цифры; okud — первое совпадение 0710\d{3}; organization_name, address — текст без пробелов по краям.

  • P1-13. В словарь пакета statement_headers.yaml переезжают и метки шапки Excel (labels, auditor, legal_form) — proposal: «statement_headers.yaml переезжает в пакет»; у tabular-parser не остаётся своего словаря форм. Цена: в общем словаре есть данные, которыми пользуется один адаптер.

  • P1-14. «Под подписью» в правиле (а) столбца кодов — ячейки столбца ниже подписи до конца сегмента.

  • P1-15. FailedRelation несёт и column_index, и column_key — чтобы ядро получало нарушения в своих ключах колонок без обратного сопоставления.

  • P1-16. Перечень кодов копируется из ветки docs feat/116-unified-normalization до слияния docs PR #117.

  • P1-17. Столбец правее кода без подписи, но с числами, в сегменте с заголовком — колонка с label = "", period_kind = none и COLUMN_WITHOUT_DATE (error): «числовая колонка без разобранной подписи» по таблице уровней.

  • P1-18. Заголовок есть у одного из сегментов, но не у первого — колонки документа задаёт первый сегмент с заголовком (спека: «остальные сегменты берут его колонки»), column_index — номера столбцов этого сегмента.

  • P1-19. Документ BALANCE / INCOME без строк (CODE_COLUMN_NOT_FOUND) получает form_variant = full: в схеме v2.0 form_variant обязателен и null только у DISCLOSURE.

  • P1-20. При accepted (у ядра) правило «needs_review у всех ячеек несошедшегося жёсткого соотношения» не применяется: иначе акцепт снимал бы нарушение со статуса, а подсвеченные им ячейки держали бы needs_check через условие «нет значений с needs_review» (спека: «accepted ⇒ жёсткие нарушения статус не держат»). У сервиса accepted всегда ложно. Цена: после акцепта ячейки несошедшегося соотношения не подсвечиваются (нарушение видно в checks).

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

  • O-1. Спека: копия лежит «вместе с … конфигом линтеров»; design Р-1: «конфиг пакета подключается к pytest и ruff хоста как include». План (P1-1) не кладёт конфиг в дерево пакета. Нужен ли хостам файл конфига пакета (и как он подключается без перекрытия конфига хоста) — решается в плане 2 (sync-statements, quality-checks).
  • O-2. Версии ruff/mypy в хостах разные (tabular-parser и arm-core — >=, scan-gateway — ==0.16.10); ruff format --check копии на другой версии ruff может разойтись со стилем пакета. Предложение для плана 2: хосты закрепляют те же версии, что пакет.
  • O-3. pytest хостов: norecursedirs/testpaths должны включать arm_statements/tests (спека требует), а pytest.ini хоста задаёт env (APP_ENV=test) — тестам пакета он не нужен и не мешает; подтверждение — в плане 2.
  • O-4. Dockerfile хостов должен копировать api/arm_statements/rules/*.yaml в образ (словари читаются с диска) — в план 2 или планы сервисов.