# План 1 — пакет общего кода `arm_statements` (репозиторий `arm-statements`). План реализации > **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 20 задач: по > `~/.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С), которые сценарии спеки описывают по одному, а встретятся вместе. Каждый пункт закреплён тестом задачи 20 на обезличенной копии внутри пакета (`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 # WarningCode, make_warning() ├── text.py # буквы-двойники, normalize_caption / normalize_label, «цифра под видом буквы» ├── 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: перечни кодов, choose_edition(), подтверждение кода ├── review.py # шаг 8: needs_review значений и строк, поля шапки, evaluate() ├── status.py # функция статуса документа и конверта, status_input(документ) ├── pipeline.py # шаг 6 и normalize_candidate() / normalize() ├── recompute.py # StoredForm → RecomputeResult ├── contract.py # to_contract(), warning_to_contract(), status_input_from_contract() — форма контракта v2.0 ├── 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` (тест задачи 20). `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» дописывается в задаче 20. - [ ] **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, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ())` — `form_name` — заголовок формы как в источнике (`header.form_name` контракта; ставит конвейер задачи 17 из `evidence.title`) - `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 | None, actual: Decimal | None)` — `column_key`: у сервиса `str(column_index)`, у ядра — ключ колонки хоста; `expected is None` ⇔ цель пуста (нет строки `ok` цели с `amount`), `actual is None` ⇔ пусты все члены (контракт: `expected`/`actual` — `money`, `null` — «цели нет»; P1-21) - `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, min_codes_continuation: int)` — `min_codes_continuation`: минимум кодов ослабленного поиска столбца (сегмент-продолжение, спека «Шаг 2 — сегменты…»: «минимум один») и листа-продолжения без маркера (`sheet_joins`) - `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], ...])` — ключи `labels` ровно `LABEL_KEYS = ("organization", "inn", "okpo", "okopf", "okfs", "okopf_okfs", "okei", "units", "address", "date")`; какая нормализация текста к какому ключу применяется — раздел «Публичный API пакета», «Словари» - `UnitRules(spellings: tuple[tuple[re.Pattern[str], str], ...], okei: Mapping[str, str], thousands: str, fractional_share_percent: int)` - `CodeOrders(editions: tuple[Edition, ...], sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]])` с методом `sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]`; `editions` — порядок редакций в файле (`(RAS_66N, FSBU4)`); `sequence(…, DocumentType.DISCLOSURE, …)` → `()` (у пояснений перечня нет, структурная проверка к ним не применяется) - `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`): - `class WarningCode(StrEnum)` — по члену на код словаря `statement_warnings.yaml` (имя = значение = код: `WarningCode.JSON_FAILED == "JSON_FAILED"`); хосты передают в `make_warning` член перечисления, а не строку-литерал - `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]`; `code` — `WarningCode` или строка (`StrEnum` — подкласс `str`); неизвестный код — `KeyError` с текстом «код предупреждения не задан в statement_warnings.yaml» (предупреждение с выдуманным кодом не создаётся) - Produces (`conftest.py`): фикстура `rules() -> Rules` (`default_rules()`). - Produces (`loader.py`): `LABEL_KEYS: tuple[str, ...]` — ключи `headers.labels` в порядке словаря. Содержимое словарей (ключи фиксированы; значения — из доноров и спеки): - `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`, `min_codes_continuation: 1` (из `line_codes.min_codes_continuation` tabular-parser). - `statement_code_orders.yaml`: `cp` из П-5. - `statement_warnings.yaml`: `warnings: {: {severity, scope}}` — ровно таблица спеки «Шаг 8 — предупреждения» (тест ниже), в том числе `XLS_NO_CODEPAGE: {severity: info, scope: file}` (книга xls без кодовой страницы, P1-23). - `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}`; множество кодов `statement_warnings.yaml` равно множеству `WarningCode`; ключи `headers.labels` — ровно `LABEL_KEYS`; `1 ≤ min_codes_continuation ≤ min_codes`; у `units.okei` значения есть среди `canonical`. - [ ] **Step 1: Write the failing test** ```python import re from decimal import Decimal from pathlib import Path import pytest import yaml from arm_statements.issues import WarningCode, make_warning from arm_statements.loader import LABEL_KEYS, 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"), "XLS_NO_CODEPAGE": ("info", "file"), } 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") assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4) assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == () def test_public_rule_types(rules: Rules) -> None: assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1") assert all(isinstance(e, Edition) for e in rules.code_orders.editions) fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED) assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu) assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3) assert tuple(rules.headers.labels) == LABEL_KEYS def test_warning_code_enum_matches_dictionary(rules: Rules) -> None: assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS) assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE" 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: warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5) assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5) assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR def test_make_warning_unknown_code_raises(rules: Rules) -> None: with pytest.raises(KeyError, match="NOT_A_CODE"): make_warning("NOT_A_CODE", "x", rules) def test_label_keys_are_checked(tmp_path: Path) -> None: for path in RULES_DIR.glob("*.yaml"): (tmp_path / path.name).write_bytes(path.read_bytes()) target = tmp_path / "statement_headers.yaml" target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8") with pytest.raises(RulesError, match="statement_headers"): load_rules(tmp_path) ``` - [ ] **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` — нижний регистр, `ё → е`, всё, кроме букв, цифр и `%`, → пробел, пробелы схлопнуты, по краям обрезано; `None` → `""`. Под неё записаны подписи колонок (`headers.code_caption`, `name_caption`, `explanations_caption`, `column_exclusions`), `units.spellings` и `periods.relative` - `normalize_label(text: str | None) -> str` — нормализация меток шапки и заголовков, дословно `normalize_text` tabular-parser: удалить невидимые `U+00AD`, `U+200B`, `U+FEFF`; нижний регистр; `ё → е`; любые пробельные символы (включая NBSP и переносы строк), `-`, `U+2010…U+2015`, `U+2212` подряд → один пробел; по краям обрезано; `None` → `""`. Пунктуация (точки, скобки, кавычки, `/`) **сохраняется**. Под неё записаны `headers.form_titles`, `okud`, `okud_code`, `continuation`, `labels`, `auditor`, `legal_form` (P1-22) - 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`; кодов меньше `code_ranges.min_codes_continuation` — `False` (для адаптера Excel плана 4) - [ ] **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, normalize_label @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_label_normalization_keeps_punctuation(rules: Rules) -> None: assert normalize_label("Местонахождение (адрес)") == "местонахождение (адрес)" assert normalize_label("по ОКОПФ / ОКФС") == "по окопф / окфс" assert normalize_label("Форма\u00a00710001 с.2") == "форма 0710001 с.2" assert normalize_label("Отчёт о финансовых\nрезультатах") == "отчет о финансовых результатах" assert normalize_label("за\u00adдол\u00adжен\u2014ность") == "задолжен ность" assert normalize_label(None) == "" for key, patterns in rules.headers.labels.items(): assert patterns, key assert any(p.search(normalize_label("Местонахождение (адрес)")) for p in rules.headers.labels["address"]) assert any(p.search(normalize_label("по ОКОПФ / ОКФС")) for p in rules.headers.labels["okopf_okfs"]) 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` по `normalize_caption` очищенного текста, по порядку) → её `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`; `ABSOLUTE` и `NONE` → `None` (не исключение: у таких колонок дата не вычисляется — хост берёт её из подписи или от аналитика, P1-24) - `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 resolve_relative(PeriodKind.ABSOLUTE, eff) is None and resolve_relative(PeriodKind.NONE, eff) 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` (оба — `search` по `normalize_label(text)`) → иначе `None` - `is_continuation(text: str | None, rules: Rules) -> bool` — текст — маркер продолжения «Форма 0710001 с.2»: хотя бы один шаблон `headers.continuation` находится в `normalize_label(text)` (для адаптера Excel плана 4: маркер без ОКУД из словаря — всё равно маркер) - `continuation_type(text: str | None, rules: Rules) -> DocumentType | None` — `is_continuation` → тип по ОКУД из маркера (`headers.okud`); не маркер **или** маркер без известного ОКУД — `None` (для адаптера Excel плана 4) - [ ] **Step 1: Write the failing test** ```python from datetime import date from arm_statements.header import continuation_type, is_continuation, 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 assert is_continuation("Форма\u00a00710000 с. 3", rules) and continuation_type("Форма 0710000 с. 3", rules) is None assert not is_continuation("Бухгалтерский баланс", rules) and not is_continuation(None, rules) def test_type_hint_uses_label_normalization(rules: Rules) -> None: assert type_hint(None, "ОТЧЁТ О ФИНАНСОВЫХ\nРЕЗУЛЬТАТАХ", rules) is DocumentType.INCOME ``` - [ ] **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` задан — ослабленный поиск: столбец с наибольшим числом кодов этого отрезка, минимум `rules.code_ranges.min_codes_continuation` (ничья — левый столбец) - `code_column(rows: Grid, rules: Rules, *, relaxed: DocumentType | None = None) -> int | None` — **публичная** обёртка для адаптеров: `find_code_column(rows, 0, len(rows) - 1, rules, relaxed_section=…)` над всей сеткой (пустая сетка — `None`); `relaxed` — `BALANCE` / `INCOME` → отрезок `CodeSection` этого типа, `DISCLOSURE` → `ValueError` (у пояснений нескольких отрезков). Одно правило с шагом 2: адаптер Excel берёт им вид листа, границу шапки и столбец для `sheet_joins` - `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 import pytest from arm_statements.layout import ( code_column, 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, DocumentType, 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_public_code_column(rules: Rules) -> None: assert code_column(ocr(BALANCE_ROWS).rows, rules) == 1 single = ocr([["Чистая прибыль", "", "2400", "7", "6"]]).rows assert (code_column(single, rules), code_column(single, rules, relaxed=DocumentType.INCOME)) == (None, 2) assert code_column(single, rules, relaxed=DocumentType.BALANCE) is None and code_column((), rules) is None with pytest.raises(ValueError, match="DISCLOSURE"): code_column(single, rules, relaxed=DocumentType.DISCLOSURE) 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` - `amount_columns(rows: Grid, code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...]` — **публичная**: столбцы правее `code_col` по возрастанию, где `has_numbers` по строкам сетки с ячейкой-кодом (`is_code_cell`) в `code_col`; то же правило, что колонки сегмента без заголовка (`segment_columns` берёт его же) и «число колонок сумм» для `sheet_joins` (`len(...)`) - `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 amount_columns, 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 == [] def test_public_amount_columns(rules: Rules) -> None: rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение"], ["Запасы", "", "1210", D("5"), D("4"), "рост"], ["Итого", "", "1200", D("5"), "", "рост"]] assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) ``` - [ ] **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 | None, actual: Decimal | None, within_tolerance: bool, within_rounding: bool)` со свойством `hard_failed: bool` (`rule.strict and computed and not within_tolerance and not within_rounding`); `expected is None` — цель пуста, `actual is None` — пусты все члены (P1-21) - `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`; посчитанное соотношение с `expected is None` или `actual is None` — `within_tolerance = within_rounding = False` (спека: «пустая цель при непустых членах и непустая цель при пустых членах — нарушение»; пусто ≠ 0) - `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"] assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (None, D("100")) 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") assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (D("100"), None) def test_zero_target_with_empty_members_is_violation(rules: Rules) -> None: lines = [line("1600", "0"), line("1110", "0"), line("1100", "0")] hard = failed(lines, BAL, FormVariant.FULL, rules).relations_failed assert [(f.relation, f.expected, f.actual) for f in hard] == [("1600 = 1700", D("0"), None)] 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` - `choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, rules: Rules, *, variant: FormVariant | None = None) -> Edition | None` — **публичная**: шаги 2–3 и 6 алгоритма ниже без подтверждения кодов. `codes` — коды строк `ok` по порядку строк как пары `(код, уверенный)`; уверенный ⇔ `confidence == 100` или код зафиксирован аналитиком; пятизначные коды и коды вне перечня в последовательность не входят. `variant is None` → `form_variant(document_type, коды)` (у `BALANCE`/`INCOME` — всегда не `None`). `DISCLOSURE` → `edition_by_date(effective)`. `confirm_codes` и `recompute` берут редакцию **этой** функцией (с `variant` документа), поэтому ядро, зовущее её при приёме результата, получает ту же редакцию, что пересчёт после правки (P1-25) Алгоритм (спека «Шаг 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 choose_edition, 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_choose_edition_public(rules: Rules) -> None: blank_66n = [("1110", True), ("1120", False), ("1130", True)] assert choose_edition(BAL, blank_66n, Q3_2025, rules) is Edition.RAS_66N tie = [("1210", True), ("1230", True)] assert choose_edition(BAL, tie, Q3_2025, rules) is Edition.FSBU4 assert choose_edition(BAL, tie, date(2024, 12, 31), rules) is Edition.RAS_66N assert choose_edition(BAL, tie, None, rules) is None assert choose_edition(BAL, [], Q3_2025, rules) is Edition.FSBU4 assert choose_edition(DocumentType.DISCLOSURE, [("5101", True)], date(2024, 12, 31), rules) is Edition.RAS_66N def test_choose_edition_equals_confirm_codes(rules: Rules) -> None: lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0)) pairs = [(ln.line_code or "", (ln.code_confidence or 0.0) >= 100.0) for ln in lines] expected = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition assert choose_edition(BAL, pairs, Q3_2025, rules, variant=FormVariant.FULL) is expected 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`, `StatementDocument`, `PeriodKind`. - 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` - `status_input(document: StatementDocument) -> StatusInput` — **публичная**: вход функции статуса из документа шагов 2–8 (статус документа — функция самого документа): `layout_found = document.lines != ()` (строк нет ⇔ столбец кодов не найден ни у одного сегмента, задача 17 п. 2); `has_organization_or_inn` — `header.organization_name.value` или `header.inn.value` не `None`; `period_known` — `header.report_date.value` не `None` или есть колонка с `period_kind is ABSOLUTE` и `period_date` не `None`; `needs_review_fields = header.needs_review_fields`; `lines` и `values` — `(line_status, needs_review)` строк и их значений; `hard_failures = len(checks.relations_failed)` (`checks is None` → 0); `accepted = False`; `warning_severities` — уровни `document.warnings`. Конвейер (задача 17) считает `status` как `document_status(status_input(<документ>))`; ядро — тем же правилом по документу контракта (`status_input_from_contract`, задача 19) - [ ] **Step 1: Write the failing test** ```python from dataclasses import replace from datetime import date from decimal import Decimal as D from arm_statements.header import parse_header from arm_statements.issues import make_warning from arm_statements.loader import Rules from arm_statements.model import ( DocStatus, DocumentType, EnvelopeStatus, FormVariant, LineStatus, PeriodKind, Severity, StatementChecks, StatementColumn, StatementDocument, StatementLine, StatementValue, ) from arm_statements.tests.builders import hdr from arm_statements.status import ( StatusInput, StatusLine, StatusValue, document_status, envelope_status, status_input, ) 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 def test_status_input_from_document(rules: Rules) -> None: head = parse_header(hdr(organization_name="ООО «Пример»", units="тыс. руб."), rules) column = StatementColumn(2, "На 30.09.2025", 100.0, date(2025, 9, 30), PeriodKind.ABSOLUTE) value = StatementValue(2, D("5"), None, "5", 60.0, True, needs_review=True) detail = StatementLine(1, "row:1", DETAIL, None, None, None, "в т. ч.", 100.0, "row:0", False, (value,)) line = StatementLine(0, "row:0", OK, "1210", "1210", 100.0, "Запасы", 100.0, None, False, (replace(value, needs_review=False),)) doc = StatementDocument(DocumentType.BALANCE, FormVariant.FULL, "file:t.json;table:0", head, (column,), (line, detail), StatementChecks((), ()), DocStatus.DONE, ()) data = status_input(doc) assert (data.layout_found, data.has_organization_or_inn, data.period_known, data.hard_failures) == ( True, True, True, 0) assert data.values == (StatusValue(OK, False), StatusValue(DETAIL, True)) assert document_status(data) is DONE empty = replace(doc, columns=(), lines=(), checks=None) assert (status_input(empty).layout_found, status_input(empty).period_known) == (False, False) ``` - [ ] **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 = replace(parse_header(candidate.header), form_name=)`, где `<title>` — `candidate.evidence.title.text` как есть, если `title` есть и текст непуст после `strip()`, иначе `None` (заголовок формы не теряется при `normalize` — документов меньше, чем кандидатов, и хосту не нужно сопоставлять документ с кандидатом); `ref` ячеек шапки и `evidence` в выход не переносятся (у `HeaderField` нет `ref`); `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(status_input(<документ>))` (задача 16) — у документов без строк (п. 2, п. 4) так же. - [ ] **Step 1: Write the failing test** ```python from decimal import Decimal as D from arm_statements import document_status, normalize, normalize_candidate, status_input 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: cand = ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") result = normalize_candidate(cand, rules) doc = result.document assert doc is not None and result.file_warnings == () assert doc.header.form_name == "Бухгалтерский баланс" assert document_status(status_input(doc)) is doc.status 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, ()) assert disc.header.form_name is 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`, суммы и коды не меняются. `edition` — из `Evaluation` (то есть `choose_edition` по строкам `ok` с `row_index` с `variant` документа; уверенный код — `line_code_confidence == 100` или `code_fixed`). `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, choose_edition, 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_edition_equals_choose_edition(rules: Rules) -> None: stored = form(sl("a", 0, "1110", "10"), sl("b", 1, "1120", "5", conf=60.0), sl("c", 2, "1130", "5")) pairs = [("1110", True), ("1120", False), ("1130", True)] expected = choose_edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), rules) assert recompute(stored, rules).edition is expected is Edition.RAS_66N 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: Перевод выхода в форму контракта v2.0 и вход статуса из документа контракта **Files:** - Create: `$R/arm_statements/contract.py` - Modify: `$R/arm_statements/__init__.py` (экспорт) - Test: `$R/arm_statements/tests/test_contract.py` **Interfaces:** - Consumes: `StatementDocument`, `StatementWarning`, `HEADER_FIELDS`, `StatusInput`, `StatusLine`, `StatusValue`, `status_input` (задача 16), `DocumentType`, `LineStatus`, `Severity`, `PeriodKind`. - Produces (`arm_statements/contract.py`; один перевод на оба сервиса — `contract_map` / `statements/contract.py` хостов не пишутся, P1-26): - `warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any]` — `{code, severity, message}` (значения — строки) и только непустые необязательные ключи: `line_refs` (список, если не пуст), `column_index` (если не `None`), `field` (если не `None`); `locator` — только при `file_level=True` и не `None` (`fileWarning`; у предупреждения документа место — `locator` документа, ключа нет). Схема v2.0 не допускает `null` у `column_index` и `field` — поэтому ключи опускаются, а не пишутся `null` - `to_contract(document: StatementDocument) -> dict[str, Any]` — `statementDocument` схемы v2.0 как json-совместимый `dict` (хост валидирует его своей моделью или схемой): `document_type`, `form_variant` (`null` у `DISCLOSURE`), `locator`, `status`, `warnings` (`warning_to_contract`, без `locator`); `header` — плоский: `form_name`, по каждому полю `HEADER_FIELDS` три ключа `<поле>` = `value` (дата — ISO), `<поле>_text` = `text`, `<поле>_confidence` = `confidence` (все 30 ключей, `null` где нет), `needs_review_fields` (список); `columns[] {column_index, label, label_confidence, period_date, period_kind}`; `lines[] {row_index, ref, line_status, line_code, line_code_text, line_code_confidence, line_name, line_name_confidence, parent_ref, needs_review, values[] {column_index, amount, string_value, text, confidence, is_recognized, needs_review}}`; `checks` — только если не `None`: `{relations_failed[], soft_relations_failed[]}` из `{column_index, relation, codes, expected, actual}` (`column_key` отбрасывается; `column_index is None` — `ValueError`: у сервиса номер колонки есть всегда). Деньги — строка `format(x, "f")` или `null`; перечисления — значения; уверенности — `float`/`null`. `ref` ячеек шапки и `evidence` в выходе нет (их нет в `StatementDocument`) - `status_input_from_contract(document: Mapping[str, Any]) -> StatusInput` — **для ядра**: то же правило, что `status_input` (задача 16), по `statementDocument` контракта v2.0 (`dict` после разбора json или `model_dump(mode="json")` модели хоста; отсутствующий необязательный ключ и `null` — одно и то же): `layout_found = len(lines) > 0`; `has_organization_or_inn` — `header.organization_name` или `header.inn` непусты; `period_known` — `header.report_date` не `null` или есть колонка с `period_kind == "absolute"` и непустым `period_date`; `needs_review_fields`; строки и значения — `(LineStatus(line_status), needs_review)`; `hard_failures = len(checks.relations_failed)` (нет `checks` — 0); `accepted = False`; `warning_severities` — `Severity(w.severity)`. Схему не проверяет: битый документ — `KeyError` / `ValueError` (ядро валидирует результат до вызова) Свойство, которое закрепляют тесты: для любого документа пакета `status_input_from_contract(to_contract(doc)) == status_input(doc)` и `document_status(status_input(doc)) is doc.status` — ядро, проверяя `document.status` результата, получает ровно то, что посчитал сервис. - [ ] **Step 1: Write the failing test** ```python import json from dataclasses import replace from decimal import Decimal as D import pytest from arm_statements import ( DocStatus, DocumentType, document_status, make_warning, normalize_candidate, status_input, status_input_from_contract, to_contract, warning_to_contract, ) from arm_statements.loader import Rules from arm_statements.model import FailedRelation, StatementChecks from arm_statements.tests.builders import hdr, ocr HEAD = ["Наименование показателя", "Код", "На 30.09.2025"] FULL_HEADER = hdr(organization_name="ООО «Пример»", inn=("7700000000", 80.0), report_date="30.09.2025", units="тыс. руб.") HEADER_KEYS = {"form_name", "needs_review_fields"} | { f"{name}{suffix}" for name in ("organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address") for suffix in ("", "_text", "_confidence") } def balance(rules: Rules, *rows: list[str]): cand = ocr([HEAD, *rows], header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") doc = normalize_candidate(cand, rules).document assert doc is not None return doc def test_document_in_contract_form(rules: Rules) -> None: doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) data = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) assert set(data["header"]) == HEADER_KEYS assert (data["header"]["form_name"], data["header"]["inn"], data["header"]["inn_confidence"]) == ( "Бухгалтерский баланс", "7700000000", 80.0) assert data["header"]["report_date"] == "2025-09-30" and data["header"]["okei"] is None assert data["lines"][0]["values"][0]["amount"] == "60.00" assert {ln["line_status"] for ln in data["lines"]} >= {"ok"} assert "column_key" not in json.dumps(data) and "row:" not in json.dumps(data["header"]) assert "locator" not in json.dumps(data["warnings"]) assert set(data["checks"]) == {"relations_failed", "soft_relations_failed"} def test_empty_target_is_null_in_contract(rules: Rules) -> None: doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["Итого I", "1100", "40"]) failed = to_contract(doc)["checks"]["relations_failed"] target = next(f for f in failed if f["relation"] == "1600 = 1100 + 1200") assert (target["expected"], target["actual"], target["column_index"]) == (None, "100.00", 2) def test_no_lines_no_checks_key(rules: Rules) -> None: doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), rules).document assert doc is not None and "checks" not in to_contract(doc) and to_contract(doc)["lines"] == [] def test_relation_without_column_index_is_error(rules: Rules) -> None: doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"]) bad = FailedRelation(None, "c1", "1600 = 1700", ("1600", "1700"), D("1"), None) with pytest.raises(ValueError, match="column_index"): to_contract(replace(doc, checks=StatementChecks((bad,), ()))) def test_warning_keys(rules: Rules) -> None: w = make_warning("ROW_SPLIT", "разлеплено", rules, line_refs=("row:3#1", "row:3#2"), locator="file:a.json") assert warning_to_contract(w) == {"code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", "line_refs": ["row:3#1", "row:3#2"]} lost = make_warning("TABLE_LOST", "таблица", rules, locator="file:a.json;table:1") assert warning_to_contract(lost, file_level=True) == {"code": "TABLE_LOST", "severity": "error", "message": "таблица", "locator": "file:a.json;table:1"} def test_status_input_roundtrip(rules: Rules) -> None: doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) data = status_input_from_contract(json.loads(json.dumps(to_contract(doc), ensure_ascii=False))) assert data == status_input(doc) assert document_status(data) is doc.status is DocStatus.NEEDS_CHECK ``` - [ ] **Step 2: Run test to verify it fails** Run: `.venv/bin/pytest arm_statements/tests/test_contract.py -v` Expected: FAIL — `ImportError: cannot import name 'to_contract'` - [ ] **Step 3: Implement `contract.py`** по Interfaces; экспорт `to_contract`, `warning_to_contract`, `status_input_from_contract`, `status_input` в `__init__.py`. Ключи `header` строятся циклом по `HEADER_FIELDS` (новое поле шапки — одна правка в `model.py`). - [ ] **Step 4: Run tests** Run: `make check` Expected: всё зелёное. - [ ] **Step 5: Commit** ```bash git -C "$R" add arm_statements git -C "$R" commit -m "feat: перевод документа и предупреждений в форму контракта v2.0, вход статуса из документа контракта" ``` --- ### Task 20: 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`, `to_contract`, `status_input`, `status_input_from_contract`, `document_status`. - 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 ( document_status, normalize_candidate, status_input, status_input_from_contract, to_contract, ) 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")) @pytest.mark.parametrize("name", NAMES) def test_status_is_function_of_contract_document(name: str, rules: Rules) -> None: doc = run(name, rules) contract = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) assert status_input_from_contract(contract) == status_input(doc) assert document_status(status_input(doc)) is doc.status def test_aep3_junk_codes(rules: Rules) -> None: # Review Focus 1 doc = run("aep3_balance", rules) by_text = {ln.line_code_text: ln for ln in doc.lines} assert (by_text["| 140"].line_status, by_text["| 140"].line_code) == (LineStatus.OK, "1140") confidence = by_text["| 140"].line_code_confidence assert confidence is not None and confidence < FULL_CONFIDENCE for junk in ("НИ", "изо :"): assert by_text[junk].line_status is LineStatus.DETAIL and by_text[junk].parent_ref is not None refs = [ln.ref for ln in doc.lines] assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines))) def test_aep3_dirty_income_amounts(rules: Rules) -> None: # Review Focus 2 doc = run("aep3_income", rules) values = {v.text: v for ln in doc.lines for v in ln.values if v.text} assert (values["(7744536}"].amount, values["(7744536}"].needs_review) == (D("-7744536"), False) assert (values["211(1 |"].amount, values["211(1 |"].needs_review) == (None, True) assert (values["42831 1"].amount, values["42831 1"].needs_review) == (D("428311"), False) assert doc.status is DocStatus.NEEDS_CHECK def test_glued_rows(rules: Rules) -> None: # Review Focus 3 doc = run("glued_balance", rules) split = [ln for ln in doc.lines if "#" in ln.ref] assert [(ln.line_code, ln.values[0].amount) for ln in split] == [("1500", D("917775")), ("1700", D("2371333"))] assert [w.code for w in doc.warnings if w.code == "ROW_SPLIT"] == ["ROW_SPLIT"] assert not [v for ln in doc.lines for v in ln.values if v.needs_review] def test_kody_title_block(rules: Rules) -> None: # Review Focus 4 doc = run("kody_title_balance", rules) assert "12267" not in [ln.line_code for ln in doc.lines] assert doc.header.okopf.value == "12267" and doc.status is DocStatus.DONE def test_npz_continuation_single_code(rules: Rules) -> None: # Review Focus 5 doc = run("npz_income", rules) (line_2400,) = [ln for ln in doc.lines if ln.line_code == "2400"] assert [v.column_index for v in line_2400.values] == [c.column_index for c in doc.columns] assert all(v.amount is not None for v in line_2400.values) assert "," in doc.locator and not [w for w in doc.warnings if w.code.startswith("SEGMENT_")] assert doc.status is DocStatus.DONE ``` - [ ] **Step 2: Build golden inputs** Временный генератор (в `arm_statements/tests/test_golden.py` не остаётся; в коммит не идёт): собрать пять кандидатов `builders` и записать `to_jsonable(...)` в `golden/<name>.candidate.json`. Содержание: - `aep3_balance` — OCR ББ на бланке 66н (шапка с ИНН «3|1 21 2|» уверенностью 80 и организацией «НКО «Пример»»), строки 1110, 1150, «| 140» (уверенность 100), «НИ» с суммой, «изо :» с `'3'` уверенностью 0, 1100, 1210…1200, 1600, раздел III НКО (повтор 1310…1300), 1700; суммы сходятся. - `aep3_income` — OCR ОФР: 2110, 2120 `"(7744536}"`, 2100 сходится по модулю; 2210 `"(100)"`, 2220 `"(200)"`, 2200; строка 2310 с `"211(1 |"`; 2320 `"42831 1"` и 2300 так, что `2300 = …` сходится только при 428311 (ячейка 2310 `null` — член пуст, соотношение всё равно посчитано: подобрать 2300 = сумма прочих членов). - `glued_balance` — OCR ББ: …, строка с кодом `"1500\n1700"`, суммами `"917 775\n2 371 333"`, наименованием `"Итого по разделу V\nБАЛАНС"`; 1510 + 1520 = 917775, 1300 + 1400 + 1500 = 2371333, 1600 = 2371333. - `kody_title_balance` — Excel ББ одного листа «Баланс»: титульный блок справа (столбец F: «Коды», 0710001, 30 | 09 | 2025, ОКПО, ИНН 7700000000, ОКОПФ 12267, ОКФС 16, ОКЕИ 384), таблица без подписи «Код» в столбце C (≥ 6 кодов), шапка `HeaderCells` из адаптера (организация, ИНН, дата, ОКОПФ «12267», ОКЕИ «384» без текста единиц). - `npz_income` — Excel ОФР на листах «ОФР» (шапка, «Код» в столбце D, 2110…2300, 2410, 2460) и «ОФР_2» (без шапки, одна строка 2400 с кодом в столбце B и суммами в C, D); суммы сходятся, `2400 = 2300 + 2410 + 2460` точно. Затем `GOLDEN_UPDATE=1 .venv/bin/pytest arm_statements/tests/test_golden.py -k snapshot` и просмотр каждого `*.expected.json` глазами против Review Focus. - [ ] **Step 3: Run tests to verify state** Run: `.venv/bin/pytest arm_statements/tests/test_golden.py -v` Expected: PASS всех тестов. Если факт-тест падает — это находка в коде задач 8–17, а не повод править тест: чинить код, пересобрать эталон, повторить. - [ ] **Step 4: README и `__all__`** `__init__.py` экспортирует ровно имена раздела «Публичный API пакета» ниже; тест `test_public_api` в `arm_statements/tests/test_golden.py` не нужен — добавить в `arm_statements/tests/test_model.py`: ```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", "resolve_relative", "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", "WarningCode", "choose_edition", "status_input", "status_input_from_contract", "to_contract", "warning_to_contract", "code_column", "amount_columns", "normalize_label", "normalize_caption", "is_continuation", } 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` + член `WarningCode` + таблица §11.9 docs и таблица спеки «Шаг 8 — предупреждения»), «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`. Раздел сведён с запросами планов 3, 4, 5 (18.10.2026, P1-21…P1-28): хосты не повторяют правил пакета у себя — ни столбца кодов, ни нормализации меток, ни перевода в контракт. ### Перечисления (`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"` | | `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | ### Вход шага 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:<row_index ИС>", Excel "<лист>!<A1>" по правилу Excel. # ref ячеек шапки (HeaderCells) и evidence пакет только читает: в выход шагов 2–8 он не переносится (у HeaderField # поля ref нет), формат — на усмотрение адаптера (scan-gateway — "data_ocr.<поле ИС>", P5-5) Evidence(okud: Cell | None = None, title: Cell | None = None) # title.text без изменений становится StatementHeader.form_name (пустой после strip — None) HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None) HEADER_FIELDS: tuple[str, ...] # имена полей HeaderCells по порядку StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None) FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), warnings: tuple[StatementWarning, ...] = ()) # rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0); # locator: "file:<имя json>;table:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу # (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …) ``` ### Выход шагов 2–8 ```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, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ()) # контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence; form_name — header.form_name # (заголовок формы как в источнике, из evidence.title кандидата; не теряется при normalize) 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 | None, actual: Decimal | None) # сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key. # expected is None ⇔ цель пуста, actual is None ⇔ пусты все члены (контракт: money, null — «цели нет»); такое # посчитанное соотношение — всегда нарушение (спека «Шаг 7»), P1-21 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); # status == document_status(status_input(document)) всегда 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), зовёт normalize_candidate в цикле recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер) type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None is_continuation(text: str | None, rules: Rules) -> bool # маркер «Форма 0710001 с.2» (любой ОКУД) continuation_type(text: str | None, rules: Rules) -> DocumentType | None # тип маркера; не маркер или ОКУД не из словаря — None sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, form_amount_columns: int, rules: Rules) -> bool # лист без маркера и шапки (адаптер Excel плана 4) code_column(rows: Sequence[Sequence[Cell | None]], rules: Rules, *, relaxed: DocumentType | None = None) -> int | None # столбец кодов сетки — правило (а)/(б) шага 2 (с защитой от блока «Коды»); relaxed=BALANCE|INCOME — ослабленный # поиск в отрезке этой формы, минимум rules.code_ranges.min_codes_continuation; relaxed=DISCLOSURE — ValueError amount_columns(rows: Sequence[Sequence[Cell | None]], code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...] # столбцы правее code_col, где в строках с кодом есть число — колонки сегмента без заголовка; «число колонок сумм» # для sheet_joins — len(amount_columns(...)) 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 resolve_relative(kind: PeriodKind, effective: date | None) -> date | None # дата относительной колонки от эффективной даты (ядро — после правки даты отчёта); effective is None → None; # ABSOLUTE и NONE → None, не исключение (P1-24) choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, rules: Rules, *, variant: FormVariant | None = None) -> Edition | None # редакция формы без полного пересчёта (ядро — при приёме результата 2.0): codes — (код, уверенный) строк ok по # порядку строк, уверенный ⇔ confidence == 100 или код зафиксирован аналитиком; variant None → по кодам; # лучшая подпоследовательность перечня (больше уверенных, затем длиннее), ничья — по дате, ничья без даты — None; # DISCLOSURE и пустые codes — по дате. recompute отдаёт ту же редакцию (P1-25) fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str normalize_caption(text: str | None) -> str # подписи колонок: нижний регистр, ё→е, всё кроме букв, цифр и % → пробел normalize_label(text: str | None) -> str # метки шапки и заголовки: = normalize_text tabular-parser (P1-22) 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 # code — WarningCode (или строка того же значения); уровень — из словаря statement_warnings.yaml; неизвестный код — # KeyError (предупреждение с выдуманным кодом не создаётся). Хосты создают JSON_FAILED, TABLE_PARSE_ERROR, # FILE_TYPE_MISMATCH, XLS_NO_CODEPAGE, XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, # SHEET_NOT_JOINED, SHEET_OTHER только через неё to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение (golden) 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`). ### Форма контракта v2.0 (сервисы пишут, ядро читает) ```python to_contract(document: StatementDocument) -> dict[str, Any] # statementDocument схемы v2.0: шапка плоская (form_name, <поле>, <поле>_text, <поле>_confidence для всех # HEADER_FIELDS, needs_review_fields), деньги — строки format(x, "f") или null, у нарушений нет column_key, # checks нет ⇔ строк нет, предупреждения — warning_to_contract без locator. Хост валидирует dict своей моделью warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any] # {code, severity, message} + непустые line_refs / column_index / field; locator — только file_level=True # (metrics.warnings); null у необязательных ключей не пишется — схема его не допускает status_input_from_contract(document: Mapping[str, Any]) -> StatusInput # вход функции статуса по statementDocument контракта (ядро: страховка document.status результата 2.0); # status_input_from_contract(to_contract(d)) == status_input(d) ``` ### Статус ```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 функция игнорирует status_input(document: StatementDocument) -> StatusInput # layout_found = строки есть; period_known = дата отчёта или колонка absolute с датой; hard_failures = # len(checks.relations_failed); accepted = False — сервис и ядро собирают вход одним правилом document_status(data: StatusInput) -> DocStatus envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus # ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост ``` ### Пересчёт по сохранённым данным (ядро) ```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; # checks.*.expected / actual — Decimal | None, как у сервиса ``` ### Словари (`Rules`) — что нужно хостам Хосты читают словари только через эти атрибуты; прочие поля `Rules` — внутренние. ```python Rules.code_orders.sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...] # перечень кодов бланка с законными повторами (ядру — порядок и состав строк формы); DISCLOSURE → (); # variant — FormVariant, не None: у формы без варианта хост передаёт FormVariant.FULL (как набор §7) Rules.code_orders.editions -> tuple[Edition, ...] # порядок редакций перечня: (Edition.RAS_66N, Edition.FSBU4) Rules.checks.tolerance -> Decimal # Decimal("1") — допуск ±1 (ядру — межформенная проверка) Rules.code_ranges.min_codes -> int # 3 — «три и более кодов» (столбец кодов, sheet_joins) Rules.code_ranges.min_codes_continuation -> int # 1 — минимум кодов слабого продолжения (code_column relaxed, sheet_joins) Rules.headers.labels -> Mapping[str, tuple[re.Pattern[str], ...]] # ключи ровно: organization, inn, okpo, okopf, okfs, okopf_okfs, okei, units, address, date (LABEL_KEYS) Rules.headers.form_titles / okud -> Mapping[DocumentType, tuple[re.Pattern[str], ...]] Rules.headers.okud_code -> re.Pattern[str] Rules.headers.continuation / auditor / legal_form -> tuple[re.Pattern[str], ...] Rules.headers.code_caption / name_caption / explanations_caption -> re.Pattern[str] Rules.headers.column_exclusions -> tuple[re.Pattern[str], ...] Rules.periods.relative -> tuple[tuple[PeriodKind, re.Pattern[str]], ...] Rules.lookalikes -> Mapping[str, str] # буквы-двойники Rules.warnings -> Mapping[str, WarningRule] # WarningRule(code, severity, scope: "document" | "file") ``` Какой текст подаётся шаблонам (`re.search`), — правило словаря, хост его не выбирает (P1-22): | Нормализация | Ключи | |---|---| | `normalize_label(text)` — пунктуация сохраняется | `headers.form_titles`, `okud`, `okud_code`, `continuation`, `labels.*`, `auditor`, `legal_form` | | `normalize_caption(text)` — пунктуация → пробел | `headers.code_caption`, `name_caption`, `explanations_caption`, `column_exclusions`, `units.spellings`, `periods.relative` | --- ## Решения автора плана без обсуждения (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`). - **P1-21.** `FailedRelation.expected` / `actual` (и `RelationOutcome`) — `Decimal | None`: `None` — цель или все члены пусты; такое посчитанное соотношение — всегда нарушение (допуск не применяется). Контракт v2.0 так и задаёт (`expected` — `money`, «`null` — цели нет»; `actual` — `money`), агрегат ядра тоже не превращает пустое в 0 (план 3, Review Focus 2). Цена: хосты и ядро обрабатывают `None` в тексте нарушения («пусто»); при ошибке — `0` вместо пустоты прятал бы «пустую цель» в допуск ±1 (`1600 = 0` при пустом `1700` считалось бы сошедшимся). - **P1-22.** Две публичные нормализации текста: `normalize_label` (дословно `normalize_text` tabular-parser — пунктуация сохраняется; под неё записаны перенесённые из tabular-parser метки шапки, заголовки, ОКУД, маркер продолжения) и `normalize_caption` (подписи колонок, единицы, относительные периоды). Таблица «ключ словаря → нормализация» — в разделе API; `type_hint` и `is_continuation` пакета пользуются `normalize_label`. Цена: новый синоним пишется под нормализацию своего ключа; ошибка — синоним молча не срабатывает (ловят тесты пакета на синтетике и покрытие словарей в tabular-parser, O-8 плана 4). - **P1-23.** Новый код `XLS_NO_CODEPAGE` (`info`, файл): книга xls без кодовой страницы — заметка читателя, относящаяся ко всей книге (и к формам, и к ОСВ). Строки добавлены в таблицу спеки «Шаг 8 — предупреждения» и §11.9 `05-domain-rules.md` (правило спеки: «новый код вводится словарём пакета и таблицей §11»). Цена: tabular-parser выдаёт его в `metrics.warnings` вместо `OSV_XLS_NO_CODEPAGE` в каждом документе ОСВ (план 4, P4-13); экран показывает его в карточке файла, а не у листа ОСВ. - **P1-24.** `resolve_relative(ABSOLUTE | NONE, …)` → `None`, а не исключение: у таких колонок дата не вычисляется, а ядро перебирает колонки формы одним циклом. Цена: вызов с неверным видом не заметен (ядро зовёт функцию только для `relative:*`, план 3 B2). - **P1-25.** `choose_edition(document_type, codes: Sequence[tuple[str, bool]], effective, rules, *, variant=None)` вместо запрошенной `choose_edition(effective, codes, rules)`: перечень кодов зависит от формы и варианта, а «лучшая подпоследовательность» по спеке сравнивает сначала число **уверенных** кодов — без типа и признака уверенности результат расходился бы с `recompute`. `confirm_codes` и `recompute` берут редакцию этой же функцией. Цена: ядро передаёт пары `(код, уверенный)`, собранные из строк результата (`line_code_confidence == 100`). - **P1-26.** Перевод выхода в форму контракта v2.0 (`to_contract`, `warning_to_contract`) — в пакете, а не в каждом сервисе: одно уплощение шапки, одно правило «необязательные ключи без `null`», один отброс `column_key`. Пакет теперь знает форму `statementDocument` v2.0. Цена: новый MAJOR контракта меняет и пакет (синхронизация копий — план 2), а модель контракта хоста валидирует `dict` пакета — расхождение видно тестом схемы хоста, а не пакета. - **P1-27.** Статус документа — функция документа: `status_input(StatementDocument)` (конвейер считает им `status`) и `status_input_from_contract(dict)` для ядра (страховка `document.status` результата 2.0); свойство `status_input_from_contract(to_contract(d)) == status_input(d)` закреплено тестами задач 19 и 20. `layout_found` выводится из «строки есть» (строк нет ⇔ столбец кодов не найден, задача 17 п. 2). Цена: если в будущем появится документ со столбцом кодов, но без строк, функции нужен явный признак раскладки. - **P1-28.** `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source)` — публичные обёртки шага 2 над сеткой `Cell` (адаптер Excel строит её из листа); минимум ослабленного поиска и слабого продолжения — данные словаря (`min_codes_continuation: 1`, был литерал «минимум один» в коде шага 2 и `min_codes_continuation` tabular-parser). `relaxed` задаётся типом формы, а не внутренним `CodeSection`. Цена: адаптеру нужна сетка `Cell` листа, чтобы спросить столбец кодов (у tabular-parser она уже есть — `grid_rows` плана 4). - **P1-29.** `form_name` — поле `StatementHeader` (в контракте он в `header`), а не `StatementDocument`; ставит конвейер из `evidence.title.text` как есть, `parse_header` оставляет `None`. Цена: хост, собирающий шапку `parse_header` сам (без конвейера), заголовка не получит — таких вызовов в планах нет. - **P1-30.** Коды предупреждений — `WarningCode` (`StrEnum`) в `issues.py`; загрузчик требует равенства множества кодов словаря и перечисления; `make_warning` с неизвестным кодом — `KeyError`. Цена: новый код — две правки в пакете (словарь + член перечисления) и строка §11.9 docs; опечатку в коде хоста ловит mypy (член перечисления), а строковый литерал — только `KeyError` при вызове. - **P1-31.** `Rules.code_orders.sequence(…, DISCLOSURE, …)` → `()`, а не `KeyError`: у пояснений перечня нет, и структурная проверка, и проверки ядра по редакции к ним не применяются. Цена: вызов для пояснений молча даёт пустой перечень. ## Открытые вопросы (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 или планы сервисов. - **O-5.** Закрыт 18.10.2026: план 3 сверен с разделом «Публичный API пакета» (его «Что ядро вызывает у пакета» использует `recompute`, `RecomputeResult`, `choose_edition`, `status_input_from_contract`, `resolve_relative`, `Rules.code_orders.*`, `Rules.checks.tolerance`; уверенности — `float | None`, перечисления — `StrEnum` пакета, `FailedRelation.expected` / `actual` — `Decimal | None`, P1-21).