From 14dcb0fbee016d2f10634d53c96a535040219b1d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 12:11:11 +0400 Subject: [PATCH] =?UTF-8?q?openspec(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=201?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B0=20arm=5Fsta?= =?UTF-8?q?tements=20(=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B9=20arm-statements),=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 задач: каркас и make check, типы и сериализация, словари и загрузчик, разборы текста/кода/дат/сумм, шапка, шаги 2–8, функция статуса, конвейер кандидата, точка входа пересчёта, golden на обезличенных входах aep_3, 1С и НПЗ. Решения автора плана P1-1…P1-20, открытые вопросы O-1…O-4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-plan-1-arm-statements.md | 2876 +++++++++++++++++ 1 file changed, 2876 insertions(+) create mode 100644 openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md new file mode 100644 index 0000000..da5b75e --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md @@ -0,0 +1,2876 @@ +# План 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/-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 ` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения несут маркеры (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `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/`): 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** + +```python +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** + +```bash +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:[,…]`, `ref` — `<лист>!` по правилу 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** + +```python +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** + +```bash +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: {: {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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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` `#` + - `collect_raw_lines(candidate: FormCandidate, layout: Layout, rules: Rules) -> + tuple[list[RawLine], list[StatementWarning]]` — по спеке «Шаг 3 — строки данных и их статусы» и «Шаг 3 — + разлепление»: строки роли `DATA` с содержимым; `ref` строки — `row:` / `row:#` у OCR (`` — из `Cell.ref` + ячейки строки), у Excel — `ref` ячейки кода, без неё — первой непустой ячейки строки (у разлепленной — `#`); + `row_index` — порядковый с 0 после разлепления; `parent_ref` у `DETAIL` — ближайшая `OK` выше во всём документе; + `ROW_SPLIT` (`info`) на каждую разлепленную строку сетки с `line_refs` её частей. Сегмент без столбца кодов: ячейка + кода — `None`, значения — по столбцам сегмента `value_cols` (если есть), иначе строки идут без значений (статус — + `detail`/`missing` по наличию содержимого в любой ячейке правее наименования) + +- [ ] **Step 1: Write the failing test** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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/.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`: + +```python +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** + +```bash +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 (строит адаптер хоста) + +```python +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:", Excel "<лист>!" по правилу 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:" | "sheet:[,…]"; warnings — предупреждения адаптера документу + # (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …) +``` + +### Выход шагов 2–8 + +```python +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, ...]) +``` + +### Функции + +```python +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`). + +### Статус + +```python +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 функция игнорирует +``` + +### Пересчёт по сохранённым данным (ядро) + +```python +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 или планы сервисов.