19 задач: каркас и make check, типы и сериализация, словари и загрузчик, разборы текста/кода/дат/сумм, шапка, шаги 2–8, функция статуса, конвейер кандидата, точка входа пересчёта, golden на обезличенных входах aep_3, 1С и НПЗ. Решения автора плана P1-1…P1-20, открытые вопросы O-1…O-4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
184 KiB
План 1 — пакет общего кода arm_statements (репозиторий arm-statements). План реализации
Для исполнителей-агентов: обязательный навык — superpowers:subagent-driven-development (в плане 19 задач: по
~/.claude/CLAUDE.md— SDD, контроллер — фоновый сабагент на модели sonnet; исполнители задач — sonnet: готового кода в плане нет, есть сигнатуры, тесты и правила спеки; финальное ревью ветки — opus). Шаги размечены чекбоксами (- [ ]).
Goal: новый репозиторий arm-statements с пакетом arm_statements/ — общие шаги 2–8 нормализации ББ/ОФР, типы
шага 1, общие разборы текста, словари и точка входа пересчёта, с тестами внутри пакета и зелёным make check; публичный
API пакета зафиксирован в конце плана для планов 2–5.
Architecture: пакет — чистые функции над frozen-dataclass-ами (stdlib, без pydantic): адаптер хоста строит
FormCandidate, normalize_candidate проводит его через шаги 2–8 и возвращает StatementDocument или предупреждения
файла; ядро пересчитывает сохранённую форму функцией recompute(StoredForm). Шаги 6–8 у обоих путей идут через один
внутренний вид строк (EvalLine), поэтому правила §7, покрытия, знака, структуры и статуса у сервиса и ядра — один код.
Словари — YAML в arm_statements/rules/, загрузчик load_rules проверяет их при загрузке. Тесты пакета —
arm_statements/tests/ (копируются в хосты вместе с пакетом), только синтетика и обезличенные копии.
Tech Stack: Python 3.11, PyYAML 6.0.3 (единственная зависимость), pytest 9.1.1, ruff 0.16.10, mypy 2.4.0, uv,
GNU make, Gitea (tea), git worktree.
Spec: openspec/changes/unified-statement-normalization/ (репозиторий arm-ka-docs, ветка
feat/116-unified-normalization): главное — specs/statement-normalization/spec.md (все требования); что пакет отдаёт
хостам — specs/recognition-contract/spec.md («Файл результата»); перечни кодов — docs/contracts/statement-code-orders.yaml;
форма результата — docs/contracts/recognition-result.schema.json v2.0; docs/context/05-domain-rules.md §7
(«Самопроверка в сервисах») и §11 (§11.3–§11.9); proposal.md; design.md — справка (Р-n, S-n), спека важнее.
Предусловия (для человека или контроллера, до задачи 1)
- П-1. Человек создаёт в Gitea пустой репозиторий
RSHB/arm-statements(ветка по умолчаниюmain). Агент репозитории не создаёт. - П-2. Клон —
/Users/andrey.epifancev/projects/awp-ca/arm-statements(рядом с остальными репозиториями workspace;sync-statementsплана 2 ищет источник именно там). Обвязка подключается~/.claude/harness/adopt-project.sh— единственная разрешённая запись вmain; после неё вmainесть хотя бы один коммит (иначе worktree не создать). - П-3. Контроллер: issue в
RSHB/arm-statements(tea issues create --login home --repo RSHB/arm-statements, заголовок «Пакет общего кода нормализации ББ/ОФР (#116 docs)», ссылка на RSHB/arm-ka-docs#116 и этот план); worktree/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statementsна веткеfeat/<issue>-arm-statements-package; в worktree —codegraph init; в зеркальном workspace.wt/normalization/симлинкиfixtures,reference-old-arm,CLAUDE.md,.claude(как в00-workspace-layout.md). - П-4. В тексте плана
$R=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statements; все команды — из$R, git —git -C "$R". - П-5. Перечень кодов копируется байтово из
/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml(ветка docsfeat/116-unified-normalization, до слияния docs PR #117 вmaindocs его нет). Если файл в ветке 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в суммах запрещён. Уверенность —float0–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; mypystrict = true(надмножество строгих флагов scan-gateway и arm-core).PLR2004в тестах не отключается: числа сравнивать кортежами/списками (assert cols == (2, 4, 5)), черезD("…")или именованные константы модуля;# noqa— только с причиной в той же строке;# type: ignoreзапрещён (warn_unused_ignores). - Код тестов в плане фиксирует входы, значения и утверждения; аннотации типов вспомогательных функций тестов, которых требует mypy strict, исполнитель дописывает сам (поведение теста при этом не меняется). Если утверждение теста противоречит спеке — источник истины спека: исполнитель отмечает расхождение в отчёте, а не подгоняет код.
- Тесты:
arm_statements/tests/с__init__.py(имена модулей не конфликтуют с тестами хоста) и своимconftest.pyбезautouse; без своих маркеров pytest (хосты запускают--strict-markers), без asyncio. - Импорты внутри пакета — абсолютные
from arm_statements.<модуль> import …; пакет не импортирует ничего из хоста (src.*). Словари читаются поPath(__file__).parent / "rules". - Файла
arm_statements/VERSIONв репозитории нет (его пишет толькоsync-statementsв копии хоста); путь — в.gitignore. - Код предупреждения и его уровень берутся только из словаря
rules/statement_warnings.yaml(таблица спеки «Шаг 8 — предупреждения»); текстыmessage— по-русски, свободные, кроме оговорённого в задачах. - Детерминизм: одинаковый вход — одинаковый выход (без времени, случайности и зависимости от порядка
set). - Публичный API — только имена из
arm_statements/__init__.py__all__(раздел «Публичный API пакета»); остальные модули доступны тестам пакета, но хосты их не импортируют. - Язык комментариев, docstring, сообщений предупреждений, коммитов — русский; идентификаторы — английские.
- Коммиты заканчиваются строками
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>иClaude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W; PR — строкой🤖 Generated with [Claude Code](https://claude.com/claude-code). Слияние — только по команде человека. - Находки и решения несут маркеры (
T<n>.<k>,C-n/I-n/M-n,R-n,O-n); решения этого плана —P1-n(раздел в конце), контроллер переносит их вdesign.mddocs (## Decisions made without discussion).
Review Focus
Входы с реальных файлов (fixtures aep_3, НПЗ, выгрузки 1С), которые сценарии спеки описывают по одному, а встретятся
вместе. Каждый пункт закреплён тестом задачи 19 на обезличенной копии внутри пакета (arm_statements/tests/golden/):
- Мусорные коды aep_3 в одной таблице ББ — «НИ», «изо :», «| 140» среди нормальных кодов: «| 140» → строка
ok1140 с неуверенным кодом, «НИ» и «изо :» → строкиdetailсparent_refближайшейok, ни одна строка не пропала,row_indexсквозной иrefуникальны. - Грязные суммы ОФР в одной колонке — «(7744536}», «211(1 |», «42831 1» вместе со скобочными 2210/2220:
−7744536 со знаком «скобки» подтверждён голосованием, «211(1 |» →
amount = null,needs_review = true, 428311 покрыт сошедшимся соотношением; документneeds_check(держитnull-ячейка). - Склейка «1500\n1700» посреди ББ — две строки
row:i#1,row:i#2, суммы 917775 и 2371333,ROW_SPLIT(info), соседниеrefне пересекаются; при сошедшихся1500 = …и1700 = …подсветки нет. - Титульный блок «Коды» выгрузки 1С — под «Коды» ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ, а у таблицы подписи
«Код» нет: столбец кодов — по правилу (б), строк с кодом 12267 нет (титульный блок — роль
title), ББdone. - Лист-продолжение НПЗ с одной строкой 2400 в другой колонке — ОФР на двух листах, у второго нет шапки, код 2400
стоит левее, чем на первом листе: ослабленный поиск столбца, значения 2400 под колонками первого листа без сдвига,
locatorиз двух листов, предупрежденийSEGMENT_*нет, ОФРdone.
Структура репозитория
arm-statements/
├── pyproject.toml # [project] (имя, зависимости), [project.optional-dependencies].dev, ruff, mypy, pytest
├── Makefile # venv, lint, test, check, golden-update
├── README.md # назначение, раскладка копией, публичный API, как менять словари
├── .gitignore # Python + arm_statements/VERSION + .codegraph/ .superpowers/ .bounded
└── arm_statements/
├── __init__.py # публичный API (__all__)
├── version.py # statements_version()
├── model.py # перечисления и dataclass-ы входа и выхода
├── serialize.py # to_jsonable(), candidate_from_dict()
├── loader.py # Rules, load_rules(), default_rules(), RulesError
├── issues.py # make_warning()
├── text.py # буквы-двойники, нормализация подписей, «цифра под видом буквы»
├── codes.py # разбор кода, отрезки кодов, присоединение листа
├── dates.py # parse_date, подпись колонки → период, ожидаемые даты
├── amounts.py # parse_amount, build_value (шаг 4, ячейка)
├── header.py # разбор шапки, type_hint, тип маркера продолжения
├── layout.py # шаг 2: столбец кодов, нумерация, строка заголовка, роли, наименование
├── columns.py # шаг 2: колонки значений, подписи, даты, номер колонки
├── segments.py # шаг 2: сегменты, сопоставление колонок, find_layout()
├── rows.py # шаг 3: строки данных, статусы, разлепление
├── lines.py # шаги 4–5: StatementLine, предупреждения единиц
├── evaluation.py # EvalLine / EvalValue — общий вид строк для шагов 6–8
├── checks.py # шаг 7: §7, вариант формы
├── structure.py # шаг 8: перечни кодов, редакция, подтверждение кода
├── review.py # шаг 8: needs_review значений и строк, поля шапки, evaluate()
├── status.py # функция статуса документа и конверта
├── pipeline.py # шаг 6 и normalize_candidate() / normalize()
├── recompute.py # StoredForm → RecomputeResult
├── rules/
│ ├── statement_checks.yaml # §7: полный и упрощённый наборы
│ ├── statement_periods.yaml # подписи периодов: относительные, текстовые, числовые
│ ├── statement_headers.yaml # заголовки форм, ОКУД, маркер продолжения, подписи колонок, метки шапки Excel
│ ├── statement_units.yaml # единицы: написания и ОКЕИ
│ ├── statement_code_ranges.yaml # отрезки кодов, пороги столбца кодов
│ ├── statement_code_orders.yaml # байтовая копия docs/contracts/statement-code-orders.yaml
│ ├── statement_warnings.yaml # код предупреждения → уровень и область
│ └── ocr_lookalikes.yaml # буквы-двойники
└── tests/
├── __init__.py
├── conftest.py # фикстура rules (default_rules())
├── builders.py # ocr(), excel(), hdr(), place() — сборка FormCandidate для тестов
├── golden/ # обезличенные кандидаты *.candidate.json и эталоны *.expected.json
└── test_*.py
Доноры логики (переносить правило, а не код целиком; смотреть через codegraph_explore с projectPath
/Users/andrey.epifancev/projects/awp-ca/<repo>): tabular-parser api/src/parser/statements/{columns,lines,checks, number_format,detect,merge}.py, api/src/parser/numbers.py, api/rules/statement_*.yaml; scan-gateway
api/src/normalizer/ocr/{text,dates,amounts,rows,layout}.py, api/rules/ocr_lookalikes.yaml,
api/rules/statement_labels.yaml, api/tests/unit/normalizer/test_dates.py; arm-core
api/src/domain/statement_checks.py (subline_parents, run_checks).
Task 1: Каркас репозитория и make check
Files:
- Create:
$R/pyproject.toml,$R/Makefile,$R/README.md,$R/.gitignore - Create:
$R/arm_statements/__init__.py,$R/arm_statements/version.py - Create:
$R/arm_statements/tests/__init__.py,$R/arm_statements/tests/conftest.py - Test:
$R/arm_statements/tests/test_version.py
Interfaces:
-
Consumes: —
-
Produces:
statements_version(path: Path | None = None) -> str | Noneвarm_statements/version.py(по умолчаниюPath(__file__).parent / "VERSION"; содержимое без пробелов по краям; нет файла или пусто —None); цели Makefilevenv,lint,test,check,golden-update. -
Step 1: Write the failing test
from pathlib import Path
from arm_statements.version import statements_version
FULL_HASH = "abc1234def5678abc1234def5678abc1234def56"
def test_version_absent_in_source() -> None:
assert statements_version() is None
def test_version_read_from_file(tmp_path: Path) -> None:
marker = tmp_path / "VERSION"
marker.write_text(f"{FULL_HASH}\n", encoding="utf-8")
assert statements_version(marker) == FULL_HASH
def test_version_empty_file_is_none(tmp_path: Path) -> None:
marker = tmp_path / "VERSION"
marker.write_text(" \n", encoding="utf-8")
assert statements_version(marker) is None
- Step 2: Создать конфиг и Makefile
pyproject.toml: [project] name = "arm-statements", version = "0", requires-python = ">=3.11,<3.12",
dependencies = ["PyYAML==6.0.3"]; [project.optional-dependencies] dev = ["ruff==0.16.10", "mypy==2.4.0", "pytest==9.1.1", "types-PyYAML==6.0.12.20260906"]; [tool.ruff] и [tool.ruff.lint] — как в Global Constraints,
[tool.ruff.lint.isort] known-first-party = ["arm_statements"]; [tool.mypy] python_version = "3.11", strict = true,
mypy_path = ".", explicit_package_bases = true; [tool.pytest.ini_options] testpaths = ["arm_statements/tests"],
pythonpath = ["."], addopts = "--tb=short --strict-markers". Без [build-system] (пакет не собирается в wheel).
Makefile (рецепты из корня, окружение .venv, BIN := $(CURDIR)/.venv/bin): venv — uv venv --python 3.11 --allow-existing .venv и uv pip install --python .venv/bin/python -r pyproject.toml --extra dev; lint — ruff check .,
ruff format --check ., mypy arm_statements; test — pytest; check: lint test; golden-update —
GOLDEN_UPDATE=1 $(BIN)/pytest arm_statements/tests/test_golden.py (тест задачи 19). help с описанием целей.
.gitignore: шаблон Python (как у scan-gateway) + arm_statements/VERSION, .codegraph/, .superpowers/, .bounded.
conftest.py: пока пустой модуль с docstring (фикстура rules появится в задаче 3). README.md: назначение пакета,
ссылка на спеку, «копия раскладывается make -C docs sync-statements, прямые правки копии запрещены», «как проверить:
make venv && make check»; раздел «Публичный API» дописывается в задаче 19.
- Step 3: Run test to verify it fails
Run: make venv && .venv/bin/pytest arm_statements/tests/test_version.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.version'
- Step 4: Implement
statements_version(path: Path | None = None) -> str | Noneinarm_statements/version.py
__init__.py пока экспортирует только statements_version.
- Step 5: Run checks
Run: make check
Expected: ruff, format, mypy — без ошибок; 3 passed.
- Step 6: Commit
git -C "$R" add pyproject.toml Makefile README.md .gitignore arm_statements
git -C "$R" commit -m "feat: каркас пакета arm_statements, make check, маркер версии копии"
Task 2: Типы входа и выхода, сериализация, сборщики тестовых кандидатов
Files:
- Create:
$R/arm_statements/model.py,$R/arm_statements/serialize.py,$R/arm_statements/tests/builders.py - Test:
$R/arm_statements/tests/test_model.py
Interfaces:
-
Consumes: —
-
Produces (
arm_statements/model.py; все dataclass-ыfrozen=True, slots=True, последовательности —tuple):class Source(StrEnum):OCR = "ocr",EXCEL = "excel"class DocumentType(StrEnum):BALANCE,INCOME,DISCLOSURE(значения = имена)class FormVariant(StrEnum):FULL = "full",SIMPLIFIED = "simplified"class Edition(StrEnum):RAS_66N = "66n",FSBU4 = "fsbu4"class PeriodKind(StrEnum):ABSOLUTE = "absolute",CURRENT = "relative:current",PREV_YEAR_END = "relative:prev_year_end",PREV_PREV_YEAR_END = "relative:prev_prev_year_end",PREV_PERIOD = "relative:prev_period",NONE = "none"class LineStatus(StrEnum):OK = "ok",UNREADABLE = "unreadable",MISSING = "missing",DETAIL = "detail"class Severity(StrEnum):ERROR = "error",INFO = "info"class DocStatus(StrEnum):DONE = "done",NEEDS_CHECK = "needs_check"class EnvelopeStatus(StrEnum):DONE = "done",NEEDS_CHECK = "needs_check",ERROR = "error"class SignForm(StrEnum):PARENTHESES = "parentheses",MINUS = "minus",UNSIGNED = "unsigned"class RowRole(StrEnum):TITLE = "title",HEADER = "header",NUMBERING = "numbering",DATA = "data"CellValue = Decimal | date | bool | NoneCell(text: str, value: CellValue, confidence: float, ref: str)Evidence(okud: Cell | None = None, title: Cell | None = None)HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None)— десять полей, порядок как здесь; кортеж имён — константаHEADER_FIELDS: tuple[str, ...]StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None)FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), warnings: tuple[StatementWarning, ...] = ())— позиция ячейки в строке = номер столбца сетки; строки могут быть разной длины (недостающие позиции =None)HeaderField(text: str | None, confidence: float | None, value: str | None)DateField(text: str | None, confidence: float | None, value: date | None)StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField, okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField, address: HeaderField, needs_review_fields: tuple[str, ...] = ())StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None, period_kind: PeriodKind)StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None, confidence: float, is_recognized: bool, needs_review: bool = False)StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None, line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None, parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], expected: Decimal, actual: Decimal)—column_key: у сервисаstr(column_index), у ядра — ключ колонки хостаStatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str, header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...], checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])
-
Produces (
arm_statements/serialize.py):to_jsonable(obj: object) -> Any— dataclass →dictпо полям,StrEnum→ значение,Decimal→str,date→ ISO,tuple/list→list, остальное как есть; уCell.value— тег:{"decimal": "106311"},{"date": "2025-09-30"},{"bool": true},nullcandidate_from_dict(data: Mapping[str, Any]) -> FormCandidate— обратная кto_jsonableдля кандидата
-
Produces (
arm_statements/tests/builders.py, только для тестов):ocr(rows: Sequence[Sequence[str | tuple[str, float] | None]], *, type_hint: DocumentType | None = None, header: HeaderCells | None = None, title: str | None = None, okud: str | None = None, locator: str = "file:t.json;table:0") -> FormCandidate— строкаr→ ячейкиCell(text, None, conf, f"row:{r}"),str— уверенность 100,(text, conf)— заданная,None— позиции нетexcel(sheets: Sequence[tuple[str, Sequence[Sequence[str | Decimal | None]]]], *, type_hint=None, header=None, title: str | None = None, okud: str | None = None, warnings: Sequence[StatementWarning] = ()) -> FormCandidate— листы подряд в одной сетке,segment_breaks— первая строка каждого листа после первого,locator—sheet:<A>[,<B>…],ref—<лист>!<A1>по правилу Excel (кавычки, если в имени есть что-то кроме букв, цифр и_или оно начинается с цифры; апостроф удваивается);Decimal→Cell(text=<с пробелами тысяч>, value=v, 100)hdr(**fields: str | tuple[str, float]) -> HeaderCellsplace(cells: Mapping[int, str | Decimal | tuple[str, float]], width: int | None = None) -> list[...]— строка с ячейками в заданных столбцах, остальныеNone
-
Step 1: Write the failing test
import json
from datetime import date
from decimal import Decimal as D
from arm_statements.model import Cell, DocumentType, Evidence, FormCandidate, HeaderCells, Source
from arm_statements.serialize import candidate_from_dict, to_jsonable
from arm_statements.tests.builders import excel, ocr
def test_candidate_roundtrip_through_json() -> None:
cand = FormCandidate(
source=Source.EXCEL,
locator="sheet:Баланс",
type_hint=DocumentType.BALANCE,
evidence=Evidence(okud=Cell("0710001", None, 100.0, "Баланс!F3"), title=None),
header=HeaderCells(report_date=Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!C5")),
rows=((Cell("106 311", D("106311"), 100.0, "Баланс!D27"), None, Cell("да", True, 100.0, "Баланс!F27")),),
)
data = json.loads(json.dumps(to_jsonable(cand), ensure_ascii=False))
assert data["rows"][0][0]["value"] == {"decimal": "106311"}
assert data["header"]["report_date"]["value"] == {"date": "2025-09-30"}
assert candidate_from_dict(data) == cand
def test_excel_builder_refs_follow_excel_quoting() -> None:
cand = excel([("Баланс", [["", "", "", "1110"]]), ("Бухгалтерский баланс. Лист 1", [["", "", "1150"]])])
assert cand.rows[0][3] is not None and cand.rows[0][3].ref == "Баланс!D1"
assert cand.rows[1][2] is not None and cand.rows[1][2].ref == "'Бухгалтерский баланс. Лист 1'!C1"
assert cand.segment_breaks == (1,)
assert cand.locator == "sheet:Баланс,Бухгалтерский баланс. Лист 1"
def test_ocr_builder_cells() -> None:
cand = ocr([["Выручка", ("[190 :", 25.0), None]])
cell = cand.rows[0][1]
assert cell == Cell("[190 :", None, 25.0, "row:0")
assert cand.rows[0][2] is None and cand.source is Source.OCR
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_model.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.model'
-
Step 3: Implement types,
to_jsonable,candidate_from_dict, builders — по Interfaces.to_jsonable—dataclasses.fieldsрекурсивно (неasdict: нужен тег уCell.value). -
Step 4: Run tests and lint
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2–8, сериализация кандидата в json"
Task 3: Словари и загрузчик
Files:
- Create:
$R/arm_statements/rules/*.yaml(восемь файлов из «Структуры репозитория»),$R/arm_statements/loader.py,$R/arm_statements/issues.py - Modify:
$R/arm_statements/tests/conftest.py(фикстураrules) - Test:
$R/arm_statements/tests/test_loader.py
Interfaces:
- Consumes: перечисления из
model.py. - Produces (
arm_statements/loader.py):class RulesError(ValueError)— словарь не прошёл проверку (сообщение называет файл и ключ)class CodeSection(StrEnum):BALANCE,INCOME,CAPITAL,CASH_FLOW,NOTES,TARGET_USECodeRange(section: CodeSection, low: int, high: int);CodeRanges(ranges: tuple[CodeRange, ...], min_share: Decimal, min_codes: int)RelationRule(form: DocumentType, variant: FormVariant, relation: str, target: str, terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str])—terms:(+1 | -1, код)CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, relations: tuple[RelationRule, ...])с методамиrelations_for(form: DocumentType, variant: FormVariant) -> tuple[RelationRule, ...]иabs_codes(form: DocumentType, variant: FormVariant) -> frozenset[str](объединениеabs_termsнабора)PeriodPattern(kind: str, pattern: re.Pattern[str]);PeriodRules(relative: tuple[tuple[PeriodKind, re.Pattern[str]], ...], absolute: tuple[PeriodPattern, ...], months: tuple[tuple[str, int], ...], min_year: int, max_year: int, max_text_length: int)HeaderRules(form_titles: Mapping[DocumentType, tuple[re.Pattern[str], ...]], okud: Mapping[DocumentType, tuple[re.Pattern[str], ...]], okud_code: re.Pattern[str], continuation: tuple[re.Pattern[str], ...], code_caption: re.Pattern[str], name_caption: re.Pattern[str], explanations_caption: re.Pattern[str], column_exclusions: tuple[re.Pattern[str], ...], labels: Mapping[str, tuple[re.Pattern[str], ...]], auditor: tuple[re.Pattern[str], ...], legal_form: tuple[re.Pattern[str], ...])UnitRules(spellings: tuple[tuple[re.Pattern[str], str], ...], okei: Mapping[str, str], thousands: str, fractional_share_percent: int)CodeOrders(sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]])с методомsequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]WarningRule(code: str, severity: Severity, scope: Literal["document", "file"])Rules(checks: CheckRules, periods: PeriodRules, headers: HeaderRules, units: UnitRules, code_ranges: CodeRanges, code_orders: CodeOrders, lookalikes: Mapping[str, str], warnings: Mapping[str, WarningRule])load_rules(directory: Path | None = None) -> Rules;default_rules() -> Rules(functools.cache, каталогPath(__file__).parent / "rules")
- Produces (
arm_statements/issues.py):make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None, field: str | None = None, locator: str | None = None) -> StatementWarning— уровень изrules.warnings[code]; неизвестный код —KeyError. - Produces (
conftest.py): фикстураrules() -> Rules(default_rules()).
Содержимое словарей (ключи фиксированы; значения — из доноров и спеки):
statement_checks.yaml:tolerance: 1,rounding_tolerance_percent: 0.001,subline_check: true,relations:— элементы{form, variant, relation, strict, abs_terms?}.variant: full— все 13 строк tabular-parserapi/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, как_MONTHSscan-gatewaydates.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(число месяцев); регулярки — из_PATTERNSscan-gatewaydates.py(они надмножество словаря tabular-parserstatement_periods.yaml;for_yeartabular-parser покрывает числовой разбор «2024»).statement_headers.yaml: из tabular-parserapi/rules/statement_headers.yaml—form_titles(+ заголовкиstatement_labels.yamlscan-gateway, «пояснени…», «^расшифровк» →DISCLOSURE),okud,okud_code,continuation,labels,auditor,legal_form; новые ключиcaptions: {code: '^код(ы|\s+строки|\s+показателя)?$', name: '^наименование', explanations: '^пояснени', exclusions: ['изменени', 'отклонени', '%', 'темп', 'доля', 'примечани']}. Ключиline_codes,units_thousands,fractional_share_percent,column_numbering,code_caption,explanations_captionв пакет не переносятся (заменены отдельными словарями и правилами).statement_units.yaml:units:— список{canonical, patterns}по порядку:тыс. руб.('тыс\w*\.?\s*руб','тысяч\w*\s+рубл'),млн руб.('млн\w*\.?\s*руб','миллион\w*\s+рубл'),руб.('руб');okei: {"384": "тыс. руб.", "383": "руб.", "385": "млн руб."};thousands: "тыс. руб.";fractional_share_percent: 10.statement_code_ranges.yaml:sections: {BALANCE: [1100, 1799], INCOME: [2100, 2999], CAPITAL: [3100, 3699], CASH_FLOW: [4100, 4499], NOTES: [5000, 5999], TARGET_USE: [6100, 6499]},min_share: 0.6,min_codes: 3.statement_code_orders.yaml:cpиз П-5.statement_warnings.yaml:warnings: {<CODE>: {severity, scope}}— ровно таблица спеки (тест ниже).ocr_lookalikes.yaml:lookalikes:—О о O o → 0,З з → 3,б → 6,S s → 5,I l | і → 1,Ч → 4.
Проверки загрузчика (RulesError): каждая регулярка компилируется; relation — <4 цифры> = <4 цифры> ([+-] <4 цифры>)*,
abs_terms ⊆ членов; variant ∈ {full, simplified}; code_orders.version == 1; в каждой редакции и форме в full
каждый код встречается 1 + (число его вхождений в repeats) раз, в simplified — не чаще; все коды перечня —
четыре цифры; severity ∈ {error, info}, scope ∈ {document, file}; у units.okei значения есть среди canonical.
- Step 1: Write the failing test
import re
from pathlib import Path
import pytest
import yaml
from arm_statements.loader import RulesError, Rules, load_rules
from arm_statements.model import DocumentType, Edition, FormVariant, Severity
RULES_DIR = Path(__file__).parent.parent / "rules"
SPEC_WARNINGS = {
"JSON_FAILED": ("error", "file"), "TABLE_PARSE_ERROR": ("error", "file"), "TABLE_LOST": ("error", "file"),
"TABLE_SKIPPED": ("info", "file"), "CODE_COLUMN_NOT_FOUND": ("error", "document"),
"SEGMENT_WITHOUT_CODES": ("error", "document"), "SHEET_NOT_JOINED": ("error", "document"),
"CONTINUATION_TYPE_CONFLICT": ("error", "document"), "SEGMENT_COLUMNS_MISMATCH": ("error", "document"),
"COLUMN_WITHOUT_DATE": ("error", "document"), "COLUMN_DATE_UNCERTAIN": ("error", "document"),
"HEADER_ROW_NOT_FOUND": ("error", "document"), "TYPE_CONFLICT": ("error", "document"),
"NO_ORGANIZATION_NO_INN": ("error", "document"), "PERIOD_UNKNOWN": ("error", "document"),
"REPORT_DATE_UNCERTAIN": ("error", "document"), "XLSB_SCALE_SUSPECT": ("error", "document"),
"INN_NOT_FOUND": ("info", "document"), "INN_UNCERTAIN": ("info", "document"),
"ORGANIZATION_NOT_FOUND": ("info", "document"), "ORGANIZATION_UNCERTAIN": ("info", "document"),
"REPORT_DATE_UNPARSED": ("info", "document"), "UNITS_NOT_FOUND": ("info", "document"),
"FILE_TYPE_MISMATCH": ("info", "file"), "COLUMN_SKIPPED": ("info", "document"),
"FRACTIONAL_IN_THOUSANDS": ("info", "document"), "XLSB_NO_FORMATS": ("info", "document"),
"NUMBER_FORMAT_UNPARSED": ("info", "document"), "VALUE_OUT_OF_RANGE": ("info", "document"),
"SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"),
}
def test_warning_table_matches_spec(rules: Rules) -> None:
assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
(rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
assert rule.target == "2400" and not rule.strict
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
{"2120", "2210", "2220", "2330", "2350", "2410"}
)
def test_code_orders_with_repeats(rules: Rules) -> None:
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL)
assert "1120" in ras and ras.index("1110") < ras.index("1100")
def test_no_code_to_name_mapping_anywhere() -> None:
code = re.compile(r"^\d{4,5}$")
def walk(node: object) -> None:
if isinstance(node, dict):
for key, value in node.items():
assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value))
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
for path in RULES_DIR.glob("*.yaml"):
walk(yaml.safe_load(path.read_text(encoding="utf-8")))
def test_broken_repeat_count_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
orders = tmp_path / "statement_code_orders.yaml"
orders.write_text(orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'),
encoding="utf-8")
with pytest.raises(RulesError, match="statement_code_orders"):
load_rules(tmp_path)
def test_unknown_severity_rejected(tmp_path: Path) -> None:
for path in RULES_DIR.glob("*.yaml"):
(tmp_path / path.name).write_bytes(path.read_bytes())
target = tmp_path / "statement_warnings.yaml"
target.write_text(target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1),
encoding="utf-8")
with pytest.raises(RulesError, match="statement_warnings"):
load_rules(tmp_path)
def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None:
from arm_statements.issues import make_warning
warning = make_warning("COLUMN_SKIPPED", "колонка «Изменение, %» пропущена", rules, column_index=5)
assert (warning.severity, warning.column_index) == (Severity.INFO, 5)
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_loader.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'arm_statements.loader'
-
Step 3: Write the eight YAML files and implement
loader.py,issues.py, фикстуруrules— по Interfaces и перечню выше. YAML читаетсяyaml.safe_load, результат приводится к типам через явные проверки (isinstance) —Anyиз YAML не утекает вRules. -
Step 4: Verify byte copy and run tests
Run: cmp arm_statements/rules/statement_code_orders.yaml /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml && make check
Expected: cmp молчит; всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: словари нормализации (§7, периоды, заголовки, единицы, коды, предупреждения) и загрузчик"
Task 4: Очистка OCR-текста и разбор кода
Files:
- Create:
$R/arm_statements/text.py,$R/arm_statements/codes.py - Test:
$R/arm_statements/tests/test_text_codes.py
Interfaces:
-
Consumes:
Rules(lookalikes,code_ranges,headers). -
Produces (
arm_statements/text.py):fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str— NBSP → пробел; если первая строка текста состоит только из цифр, пробелов,|, точек, запятых и двоеточий —|заменяется пробелом (разделитель клеток, «2|0|2|4|» → «2 0 2 4 »); затем двойники → цифры вплотную к цифре в несколько проходов (anywhere=True— без условия соседства: для нумерации и «цифры под видом буквы»)has_digit(text: str | None) -> boolis_disguised_digit(text: str, lookalikes: Mapping[str, str]) -> bool— послеfix_lookalikes(anywhere=True)есть цифра и нет других букв ('I','| О'→True;'Косарев','(расшифровка подписи)'→False)normalize_caption(text: str | None) -> str— нижний регистр,ё → е, всё, кроме букв, цифр и%, → пробел, пробелы схлопнуты
-
Produces (
arm_statements/codes.py):CodeParse(code: str | None, fixed: bool)—fixed: при разборе заменён двойникparse_code(text: str | None, rules: Rules) -> CodeParse— однострочный текст по спеке «Диапазоны кодов и разбор кода»: убрать пробелы → двойники (с|; если результат не код — повтор без|) → краевая пунктуация →^\d{4,5} (\.0)?$, первые четыре цифры в объединении отрезков; многострочный текст →CodeParse(None, False)is_code_cell(text: str | None, rules: Rules) -> bool— однострочный —parse_code(...).code is not None; многострочный — каждая непустая часть кодcode_parts(text: str | None) -> tuple[str, ...]— непустые строки ячейки, обрезанныеsection_of(code: str, rules: Rules) -> CodeSection | None— отрезок по первым четырём цифрамmajority_section(codes: Iterable[str], rules: Rules) -> CodeSection | None— отрезок большинства (ничья — первый по порядку отрезков словаря; пусто —None)document_type_of(section: CodeSection) -> DocumentType—BALANCE/INCOMEкак есть, прочие →DISCLOSUREsheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, form_amount_columns: int, rules: Rules) -> bool— правило листа без маркера и шапки (спека «Шаг 1 — многостраничная форма»): все коды в отрезке формы и при трёх и более кодах число колонок сумм совпадает; кодов нет —False(для адаптера Excel плана 3)
-
Step 1: Write the failing test
import pytest
from arm_statements.codes import is_code_cell, majority_section, parse_code, sheet_joins
from arm_statements.loader import CodeSection, Rules
from arm_statements.model import DocumentType
from arm_statements.text import fix_lookalikes, is_disguised_digit, normalize_caption
@pytest.mark.parametrize(
("raw", "code", "fixed"),
[
("1220 ,", "1220", False),
("| 140", "1140", True),
("I1О0", "1100", True),
("1110.0", "1110", False),
("12301", "12301", False),
("3450", "3450", False),
("2024", None, False),
("1050", None, False),
("7450", None, False),
("ИЧ,250", None, False),
("изо :", None, False),
("НИ", None, False),
("1500\n1700", None, False),
("2", None, False),
],
)
def test_parse_code(rules: Rules, raw: str, code: str | None, fixed: bool) -> None:
assert parse_code(raw, rules) == (code, fixed)
def test_multiline_code_cell(rules: Rules) -> None:
assert is_code_cell("1500\n1700", rules)
assert not is_code_cell("44 30\nI |", rules)
def test_lookalikes_only_near_digit(rules: Rules) -> None:
assert fix_lookalikes("2ОО5", rules.lookalikes) == "2005"
assert fix_lookalikes("2|0|2|4|", rules.lookalikes).split() == ["2", "0", "2", "4"]
assert fix_lookalikes("Основные средства", rules.lookalikes) == "Основные средства"
assert fix_lookalikes("З", rules.lookalikes) == "З"
assert fix_lookalikes("З", rules.lookalikes, anywhere=True) == "3"
def test_disguised_digit(rules: Rules) -> None:
assert is_disguised_digit("I", rules.lookalikes) and is_disguised_digit("| О", rules.lookalikes)
assert not is_disguised_digit("Косарев", rules.lookalikes)
assert not is_disguised_digit("(расшифровка подписи)", rules.lookalikes)
def test_caption_normalization() -> None:
assert normalize_caption("Код строки") == "код строки"
assert normalize_caption("Изменение, %") == "изменение %"
assert normalize_caption("Наименование\nпоказателя") == "наименование показателя"
def test_majority_section_and_join(rules: Rules) -> None:
assert majority_section(["1110", "1150", "2110"], rules) is CodeSection.BALANCE
assert sheet_joins(DocumentType.INCOME, ["2400"], 1, 3, rules)
assert not sheet_joins(DocumentType.BALANCE, ["1110", "1150", "1100"], 4, 3, rules)
assert not sheet_joins(DocumentType.BALANCE, ["2110"], 3, 3, rules)
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_text_codes.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
text.pyandcodes.pyпо Interfaces (донор — scan-gatewaytext.py::fix_lookalikes, tabular-parsercolumns.py::line_code). Краевая пунктуация — всё, что не буква и не цифра, по краям после удаления пробелов. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: буквы-двойники, нормализация подписей, разбор кода строки и отрезки кодов"
Task 5: Даты и подписи колонок
Files:
- Create:
$R/arm_statements/dates.py - Test:
$R/arm_statements/tests/test_dates.py
Interfaces:
-
Consumes:
Rules.periods,Rules.headers.column_exclusions,fix_lookalikes,normalize_caption. -
Produces (
arm_statements/dates.py):parse_date(text: str | None, rules: Rules) -> date | None— текст уже после очистки; длиннееmax_text_length(без пробелов по краям) →None; сначала текстовые формыabsoluteпо порядку словаря по всему тексту, затем числовой разбор первой строки, затем всего текста через пробел (алгоритм_numeric_date,_from_digits,_from_parts,_yearscan-gatewaydates.py: три группы; 8 цифр ДДММГГГГ / ГГГГММДД / ГГГГДДММ; 4 → 31.12 года в[min_year, max_year]; 6 → ДДММГГ; больше трёх групп — первые три; двузначный год → 20YY)PeriodLabel(kind: PeriodKind, date: date | None, excluded: bool, bare_year: bool)parse_period_label(cell: Cell | None, rules: Rules) -> PeriodLabel—None/пустой текст →(NONE, None, False, False);Cell.value—date→ABSOLUTEс этой датой; иначеfix_lookalikes(text); исключение (column_exclusionsпоnormalize_caption) →excluded=True,kind=NONE; относительная подпись (словарьrelative, по порядку) → еёkind,date=None; иначеparse_date→ABSOLUTE; иначеNONE.bare_year— текст после очистки — четыре цифры годаresolve_relative(kind: PeriodKind, effective: date | None) -> date | None—CURRENT→effective;PREV_YEAR_END→ 31.12 года − 1;PREV_PREV_YEAR_END→ 31.12 года − 2;PREV_PERIOD→effective− 1 год (29.02 → 28.02);effective is Noneили не относительный вид →Noneexpected_dates(effective: date) -> frozenset[date]—effective, она же − 1 и − 2 года (29.02 → 28.02), 31.12 года − 1 и года − 2
-
Step 1: Write the failing test
from datetime import date
import pytest
from arm_statements.dates import expected_dates, parse_date, parse_period_label, resolve_relative
from arm_statements.loader import Rules
from arm_statements.model import Cell, PeriodKind
from arm_statements.text import fix_lookalikes
# Обязательные строки scan-gateway test_dates.py::test_parses_real_forms (спека «Шаг 2 — подпись и дата колонки»)
REAL_FORMS = [
("на 30 сентября 2025 г.", date(2025, 9, 30)), ("На 30 июня\n2025 г", date(2025, 6, 30)),
("31 декабря 2024 года", date(2024, 12, 31)), ("30 сентября 2025 r.", date(2025, 9, 30)),
("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), ("I квартал 2026", date(2026, 3, 31)),
("1-й кв. 2026", date(2026, 3, 31)), ("первый квартал 2025 г.", date(2025, 3, 31)),
("1 полугодие 2026", date(2026, 6, 30)), ("1 п/г 2026", date(2026, 6, 30)),
("9 месяцев 2025", date(2025, 9, 30)), ("9 мес. 2025", date(2025, 9, 30)),
("За январь - июнь\n2025 г.", date(2025, 6, 30)), ("январь – сентябрь 2025", date(2025, 9, 30)),
("за 2025 г.", date(2025, 12, 31)), ("2024 год", date(2024, 12, 31)), ("30.09.2025", date(2025, 9, 30)),
("2025 03 31", date(2025, 3, 31)), ("30/09/25", date(2025, 9, 30)), ("2 0 2 4", date(2024, 12, 31)),
("30 09 2 0 2 5", date(2025, 9, 30)), ("30092025", date(2025, 9, 30)), ("20250930", date(2025, 9, 30)),
("30 09 25", date(2025, 9, 30)), ("30.09.2025 0:00:00", date(2025, 9, 30)), ("2|0|2|4|", date(2024, 12, 31)),
("3l.12.2О24", date(2024, 12, 31)),
]
@pytest.mark.parametrize(("raw", "expected"), REAL_FORMS)
def test_parses_real_forms(rules: Rules, raw: str, expected: date) -> None:
assert parse_date(fix_lookalikes(raw, rules.lookalikes), rules) == expected
label = parse_period_label(Cell(raw, None, 100.0, "row:0"), rules)
assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, expected)
@pytest.mark.parametrize("raw", ["Общество с ограниченной ответственностью", "", "+", "тыс. руб.", "1999999999999"])
def test_not_a_date(rules: Rules, raw: str) -> None:
assert parse_date(raw, rules) is None
def test_long_text_not_parsed(rules: Rules) -> None:
assert parse_date("на 30 сентября 2025 г." + "x" * 200, rules) is None
@pytest.mark.parametrize(
("raw", "kind"),
[
("На отчетную дату", PeriodKind.CURRENT),
("За отчётный период", PeriodKind.CURRENT),
("На 31 декабря предыдущего года", PeriodKind.PREV_YEAR_END),
("На 31 декабря года, предшествующего предыдущему", PeriodKind.PREV_PREV_YEAR_END),
("За аналогичный период предыдущего года", PeriodKind.PREV_PERIOD),
],
)
def test_relative_labels(rules: Rules, raw: str, kind: PeriodKind) -> None:
assert parse_period_label(Cell(raw, None, 100.0, "row:0"), rules).kind is kind
def test_exclusion_and_excel_date_value(rules: Rules) -> None:
assert parse_period_label(Cell("Изменение, %", None, 100.0, "A!F1"), rules).excluded
label = parse_period_label(Cell("30.09.2025", date(2025, 9, 30), 100.0, "A!D1"), rules)
assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, date(2025, 9, 30))
assert parse_period_label(Cell("2024", None, 100.0, "A!E1"), rules).bare_year
def test_resolve_relative_and_expected() -> None:
eff = date(2025, 9, 30)
assert resolve_relative(PeriodKind.PREV_YEAR_END, eff) == date(2024, 12, 31)
assert resolve_relative(PeriodKind.PREV_PREV_YEAR_END, eff) == date(2023, 12, 31)
assert resolve_relative(PeriodKind.PREV_PERIOD, date(2024, 2, 29)) == date(2023, 2, 28)
assert resolve_relative(PeriodKind.CURRENT, None) is None
assert expected_dates(eff) == frozenset(
{eff, date(2024, 9, 30), date(2023, 9, 30), date(2024, 12, 31), date(2023, 12, 31)}
)
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_dates.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
dates.py— обработчик на каждыйkindсловаряabsolute(код), регулярки — словарь. Длину проверять доfix_lookalikesи регулярок (backtracking, решение № 31 среза 3a). -
Step 4: Run tests
Run: make check
Expected: всё зелёное; тест длинного текста — доли секунды.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: словарь периодов — даты, относительные подписи бланка 66н, исключения колонок"
Task 6: Сумма и значение ячейки (шаг 4, ячейка)
Files:
- Create:
$R/arm_statements/amounts.py - Test:
$R/arm_statements/tests/test_amounts.py
Interfaces:
-
Consumes:
Source,Cell,StatementValue,SignForm,Rules.lookalikes,fix_lookalikes. -
Produces (
arm_statements/amounts.py):AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None)—sign_formесть, когда ядро числа допустимо (стадия 4 пройдена), иначеNoneparse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse— стадии 1–6 спеки «Шаг 4 — значение ячейки» дословно; единственное место, где разбор зависит отsourcesign_form_of(text: str | None, rules: Rules) -> SignForm | None— форма знака по тексту (parse_amount(text, Source.OCR, rules).sign_form; от источника не зависит)ValueBuild(value: StatementValue, out_of_range: bool)build_value(cell: Cell | None, column_index: int, source: Source, rules: Rules, *, split_part: bool = False) -> ValueBuild—cell is None→amount=None, string_value=None, text=None, confidence=100.0, is_recognized=True;cell.value—Decimal→ квантование half-up до 0,01, больше 16 целых цифр →amount=None,string_value=text,is_recognized=False,out_of_range=True;date/bool→amount=None,string_value=text,is_recognized=False; иначе —parse_amount(cell.text, ...); текст из нескольких непустых строк (послеstrip) приsplit_part=False→amount=None,string_value=text,is_recognized=False;split_part=True→is_recognized=Falseвсегда;text— всегдаcell.text,confidence—cell.confidence
-
Step 1: Write the failing test
from datetime import date
from decimal import Decimal as D
import pytest
from arm_statements.amounts import build_value, parse_amount, sign_form_of
from arm_statements.loader import Rules
from arm_statements.model import Cell, SignForm, Source
OCR, EXCEL = Source.OCR, Source.EXCEL
@pytest.mark.parametrize(
("raw", "amount", "string_value", "recognized"),
[
("1 2О5", D("1205"), None, False),
("42831 1", D("428311"), None, False),
("(7744536}", D("-7744536"), None, False),
("211(1 |", None, "211(1 |", False),
("(Ю4)", None, "(Ю4)", False),
(": : 2856983 = ■", D("2856983"), None, False),
("| : 122477", D("122477"), None, False),
("|122477", D("122477"), None, False),
("{38378)", None, "{38378)", False),
("4,200", D("4200"), None, True),
("1,234,567", D("1234567"), None, True),
("837.994", D("837994"), None, True),
("1 234.567", D("1234567"), None, True),
("4,2", D("4.2"), None, True),
("1 234,56", D("1234.56"), None, True),
("1.234,56", D("123456"), None, False),
("0,125", None, "0,125", False),
("1 234 567", D("1234567"), None, True),
("(1 234)", D("-1234"), None, True),
("−500", D("-500"), None, True),
("12:34", None, "12:34", False),
("1 234 руб.", D("1234"), None, False),
("—", None, None, True),
("-", None, None, True),
("", None, None, True),
("I", None, "I", False),
],
)
def test_parse_amount_ocr(rules: Rules, raw: str, amount: D | None, string_value: str | None, recognized: bool) -> None:
parsed = parse_amount(raw, OCR, rules)
assert (parsed.amount, parsed.string_value, parsed.is_recognized) == (amount, string_value, recognized)
def test_comma_depends_on_source(rules: Rules) -> None:
assert parse_amount("4,200", EXCEL, rules).amount == D("4.20")
assert parse_amount("1.500", EXCEL, rules).amount == D("1.5")
def test_sign_forms(rules: Rules) -> None:
assert sign_form_of("(7744536}", rules) is SignForm.PARENTHESES
assert sign_form_of("-1 234", rules) is SignForm.MINUS
assert sign_form_of("38378", rules) is SignForm.UNSIGNED
assert sign_form_of("{38378)", rules) is None
def test_build_value_excel_number_and_out_of_range(rules: Rules) -> None:
ok = build_value(Cell("106 311", D("106311.456"), 100.0, "Баланс!D27"), 3, EXCEL, rules)
assert (ok.value.amount, ok.value.is_recognized, ok.out_of_range) == (D("106311.46"), True, False)
big = build_value(Cell("1E+17", D("123456789012345678"), 100.0, "Баланс!D28"), 3, EXCEL, rules)
assert (big.value.amount, big.value.string_value, big.value.is_recognized, big.out_of_range) == (
None, "1E+17", False, True,
)
def test_build_value_date_missing_multiline_split(rules: Rules) -> None:
as_date = build_value(Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!D3"), 3, EXCEL, rules).value
assert (as_date.amount, as_date.string_value, as_date.is_recognized) == (None, "30.09.2025", False)
missing = build_value(None, 4, OCR, rules).value
assert (missing.amount, missing.string_value, missing.text, missing.is_recognized) == (None, None, None, True)
assert missing.confidence == pytest.approx(100.0)
multi = build_value(Cell("917 775\n2 371 333", None, 100.0, "row:9"), 2, OCR, rules).value
assert (multi.amount, multi.string_value, multi.is_recognized) == (None, "917 775\n2 371 333", False)
part = build_value(Cell("917 775", None, 100.0, "row:9#1"), 2, OCR, rules, split_part=True).value
assert (part.amount, part.is_recognized) == (D("917775"), False)
dash = build_value(Cell("—", None, 100.0, "row:3"), 2, OCR, rules).value
assert (dash.amount, dash.string_value, dash.is_recognized, dash.text) == (None, None, True, "—")
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_amounts.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
amounts.py. Стадии по спеке; уточнения, которые спека оставляет: «лучшая догадка» при пробеле не по группам и смешанные разделители — цифры подряд (не длиннее 16, иначеamount=None,string_value=text); текст без пробелов с более чем 16 целыми цифрами —amount=None,string_value=text,is_recognized=False(без предупреждения, P1-10). Пробелы —\s, NBSP, тонкий,. Удаление разделителей тысяч по шаблону исправлением не считается. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: parse_amount по стадиям (ядро числа, краевой мусор, скобки, разделители) и значение ячейки"
Task 7: Шапка кандидата и тип-подсказка
Files:
- Create:
$R/arm_statements/header.py - Test:
$R/arm_statements/tests/test_header.py
Interfaces:
-
Consumes:
HeaderCells,StatementHeader,HeaderField,DateField,parse_date,fix_lookalikes,Rules. -
Produces (
arm_statements/header.py):parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader— у каждого поляtext = cell.text,confidence = cell.confidence(нет ячейки —None,None,None);inn— только цифры текста без замены двойников, длина 10 или 12, иначеNone;report_date—cell.value, если этоdate, иначеparse_date(fix_lookalikes(text));units— первое совпадениеunits.spellingsпоnormalize_caption(text), иначе поokeiразобранному;okei,okpo,okopf,okfs— только цифры (пусто →None);okud— первое совпадениеheaders.okud_code;organization_name,address— текст без пробелов по краям (пусто →None);needs_review_fields = ()type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None— ОКУД по словарюheaders.okud→ иначе заголовок поheaders.form_titles(текст нормализуется: нижний регистр,ё → е, дефисы и переносы → пробел) → иначеNonecontinuation_type(text: str | None, rules: Rules) -> DocumentType | None— маркер «Форма 0710001 с.2» (headers.continuation) → тип по ОКУД из маркера; не маркер —None(для адаптера Excel плана 3)
-
Step 1: Write the failing test
from datetime import date
from arm_statements.header import continuation_type, parse_header, type_hint
from arm_statements.loader import Rules
from arm_statements.model import DocumentType
from arm_statements.tests.builders import hdr
def test_inn_digits_only_without_lookalikes(rules: Rules) -> None:
head = parse_header(hdr(inn="|7700000000|"), rules)
assert head.inn.value == "7700000000"
dirty = parse_header(hdr(inn=("3|1 21 2|", 80.0)), rules)
assert dirty.inn.value is None
assert (dirty.inn.text, dirty.inn.confidence) == ("3|1 21 2|", 80.0)
def test_units_by_text_then_okei(rules: Rules) -> None:
assert parse_header(hdr(units="в тыс. рублей"), rules).units.value == "тыс. руб."
by_okei = parse_header(hdr(okei="384"), rules)
assert (by_okei.units.value, by_okei.okei.value, by_okei.units.text) == ("тыс. руб.", "384", None)
assert parse_header(hdr(units="руб."), rules).units.value == "руб."
def test_report_date_and_codes(rules: Rules) -> None:
head = parse_header(hdr(report_date="30.09.2О25", okud="Форма по ОКУД 0710001", okopf="12267"), rules)
assert head.report_date.value == date(2025, 9, 30)
assert (head.okud.value, head.okopf.value) == ("0710001", "12267")
def test_type_hint_okud_wins(rules: Rules) -> None:
assert type_hint("0710002", "Бухгалтерский баланс", rules) is DocumentType.INCOME
assert type_hint("Форма по ОКУД 0710001", None, rules) is DocumentType.BALANCE
assert type_hint("0710005", None, rules) is DocumentType.DISCLOSURE
assert type_hint(None, "Пояснения к бухгалтерскому балансу", rules) is DocumentType.DISCLOSURE
assert type_hint(None, "Отчет о прибылях и убытках", rules) is DocumentType.INCOME
assert type_hint(None, "Бухгалтерская отчетность", rules) is None
def test_continuation_marker(rules: Rules) -> None:
assert continuation_type("Форма 0710002 с.2", rules) is DocumentType.INCOME
assert continuation_type("Бухгалтерский баланс", rules) is None
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_header.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
header.pyпо Interfaces. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: разбор шапки формы (ИНН без двойников, единицы по ОКЕИ) и тип-подсказка по ОКУД и заголовку"
Task 8: Шаг 2 — столбец кодов, нумерация, строка заголовка, роли, наименование
Files:
- Create:
$R/arm_statements/layout.py - Test:
$R/arm_statements/tests/test_layout.py
Interfaces:
-
Consumes:
Cell,RowRole,parse_code,is_code_cell,section_of,majority_section,normalize_caption,fix_lookalikes,parse_period_label,Rules. -
Produces (
arm_statements/layout.py;Grid = Sequence[Sequence[Cell | None]], строкиfirst..lastвключительно):cell_at(rows: Grid, row: int, col: int) -> Cell | None— вне строки →Nonefind_code_column(rows: Grid, first: int, last: int, rules: Rules, *, relaxed_section: CodeSection | None = None) -> int | None— правило (а)/(б) спеки «Шаг 2 — столбец кодов» («под подписью» — ячейки столбца ниже строки подписи доlast);relaxed_sectionзадан — ослабленный поиск: столбец с наибольшим числом кодов этого отрезка, минимум один (ничья — левый столбец)first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rules) -> int | Noneis_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool— ≥ 3 непустых ячеек, каждая послеfix_lookalikes(anywhere=True)— целое, слева направо +1, начало 1 или 2, не больше одного пропуска (скачок +2)find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rules: Rules) -> tuple[int, ...]— строка заголовка по спеке «Шаг 2 — строка заголовка…» (строки нумерации пропускаются при поиске «последней над первой строкой с кодом»); двухстрочный:(r - 1, r), если для какого-то столбца правее кода подпись строкиr - 1отдельно — не период, а «подписьr - 1+ пробел + подписьr» — период (P1-9: «На 30 сентября» + «2025 г.»); не найдена —()segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]— выше заголовкаTITLE, строки заголовкаHEADER, нумерацияNUMBERING, остальноеDATA; без заголовкаTITLEнетfind_name_column(rows: Grid, header_rows: tuple[int, ...], first: int, last: int, code_col: int, rules: Rules) -> tuple[int | None, int | None]—(столбец наименования, столбец «Пояснения»)по спеке «Шаг 2 — столбец наименования»; «Пояснения» — подписьcaptions.explanations, а без подписи — столбец левее кода, где все непустые значения не длиннее 3 символовname_cell(cells: Sequence[Cell | None], name_col: int | None, code_col: int, explanations_col: int | None) -> Cell | None— ячейка столбца наименования; пустая — ближайшая левее кода ячейка с буквами, кроме «Пояснений»
-
Step 1: Write the failing test
from decimal import Decimal as D
from arm_statements.layout import (
find_code_column, find_header_rows, find_name_column, first_code_row, is_numbering_row, segment_roles,
)
from arm_statements.loader import CodeSection, Rules
from arm_statements.model import Cell, RowRole
from arm_statements.tests.builders import excel, ocr, place
BALANCE_ROWS = [
["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"],
["1", "2", "3", "4"],
["Нематериальные активы", "1110", "10", "9"],
["Основные средства", "1150", "20", "19"],
["Итого по разделу I", "1100", "30", "28"],
]
def test_code_column_by_caption(rules: Rules) -> None:
cand = ocr(BALANCE_ROWS)
assert (find_code_column(cand.rows, 0, 4, rules), first_code_row(cand.rows, 0, 4, 1, rules)) == (1, 2)
def test_title_block_kody_is_not_code_column(rules: Rules) -> None:
title = [place({5: v}) for v in ["Коды", "0710001", "30 09 2025", "12345678", "7700000000", "12267", "16", "384",
"", ""]]
table = [place({0: n, 2: c, 3: D(a)}) for n, c, a in
[("Запасы", "1210", "5"), ("НДС", "1220", "1"), ("Дебиторы", "1230", "4"), ("Итого", "1200", "10")]]
cand = excel([("Баланс", [*title, *table])])
assert [find_code_column(cand.rows, 0, len(cand.rows) - 1, rules)] == [2]
def test_two_line_document(rules: Rules) -> None:
cand = ocr([["Наименование", "Код", "2025"], ["Выручка", "2110", "5"], ["Прибыль", "2400", "1"]])
assert find_code_column(cand.rows, 0, 2, rules) == 1
def test_relaxed_search_finds_single_code(rules: Rules) -> None:
cand = ocr([["Чистая прибыль", "", "2400", "7", "6"]])
assert find_code_column(cand.rows, 0, 0, rules) is None
assert [find_code_column(cand.rows, 0, 0, rules, relaxed_section=CodeSection.INCOME)] == [2]
def test_numbering_rows(rules: Rules) -> None:
def row(*texts: str) -> tuple[Cell | None, ...]:
return ocr([list(texts)]).rows[0]
assert is_numbering_row(row("1", "2", "3", "4", "5"), rules)
assert is_numbering_row(row("1", "2", "З", "4"), rules)
assert is_numbering_row(row("2", "3", "4", "5"), rules)
assert is_numbering_row(row("1", "2", "4", "5"), rules)
assert not is_numbering_row(row("1", "3", "5"), rules)
assert not is_numbering_row(row("1", "2"), rules)
def test_header_row_roles_and_title(rules: Rules) -> None:
rows = [["Бухгалтерский баланс", "", "", ""], *BALANCE_ROWS]
cand = ocr(rows)
header = find_header_rows(cand.rows, 0, 3, 1, rules)
assert header == (1,)
roles = segment_roles(cand.rows, 0, 5, header, rules)
assert roles == [RowRole.TITLE, RowRole.HEADER, RowRole.NUMBERING, RowRole.DATA, RowRole.DATA, RowRole.DATA]
assert RowRole.TITLE not in segment_roles(cand.rows, 3, 5, (), rules)
def test_two_row_header_is_glued(rules: Rules) -> None:
cand = ocr([["", "", "На 30 сентября", "На 31 декабря"], ["Наименование", "Код", "2025 г.", "2024 г."],
["Запасы", "1210", "5", "4"]])
assert find_header_rows(cand.rows, 0, 2, 1, rules) == (0, 1)
single = ocr([["Раздел", "", "", ""], ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"],
["Запасы", "1210", "5", "4"]])
assert find_header_rows(single.rows, 0, 2, 1, rules) == (1,)
def test_name_column_without_caption(rules: Rules) -> None:
cand = ocr([["5.1", "Запасы", "1210", "5"], ["5.2", "Дебиторская задолженность", "1230", "4"],
["", "Итого", "1200", "9"]])
assert find_name_column(cand.rows, (), 0, 2, 2, rules) == (1, 0)
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_layout.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
layout.pyпо Interfaces (доноры: tabular-parsercolumns.py—find_code_column,_find_header_row,_upper_header_row,_name_column,_note_columns; scan-gatewayrows.py::is_numbering_row,layout.py). Ячейка-код для подсчёта —is_code_cell; «непустая» —text.strip() != "". -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — столбец кодов с защитой от блока «Коды», нумерация, строка заголовка, роли строк"
Task 9: Шаг 2 — колонки значений, подписи и даты
Files:
- Create:
$R/arm_statements/columns.py - Test:
$R/arm_statements/tests/test_columns.py
Interfaces:
-
Consumes:
layout.py(cell_at,find_header_rows),parse_period_label,resolve_relative,expected_dates,parse_amount,make_warning,StatementHeader,StatementColumn,Rules. -
Produces (
arm_statements/columns.py):ColumnDraft(grid_col: int, label: str, label_confidence: float | None, kind: PeriodKind, date: date | None)— колонка сегмента до сопоставления;dateабсолютной подписи (у относительнойNone)has_numbers(rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules) -> bool— в строках с кодом есть ячейка сvalue-числом или сparse_amount(...).amount is not Nonesegment_columns(rows: Grid, header_rows: tuple[int, ...], data_rows: Sequence[int], code_col: int, source: Source, rules: Rules) -> tuple[list[ColumnDraft], list[StatementWarning]]— сегмент с заголовком: столбцы правее кода; подпись (склейка строк заголовка через пробел; уверенность — минимум) → период — колонка; исключение — пропуск иCOLUMN_SKIPPEDсcolumn_index = grid_col; иначе при числах — колонка с подписью как есть,NONE,COLUMN_WITHOUT_DATE(P1-17: пустая подпись при числах — так же); без чисел — молча пропуск. Сегмент без заголовка (header_rows == ()): столбцы правее кода с числами,label="",label_confidence=None,NONE, без предупрежденийDocumentDates(effective: date | None, confident_effective: date | None, latest_absolute: date | None, confident_latest_absolute: date | None)document_dates(drafts: Sequence[ColumnDraft], header: StatementHeader) -> DocumentDates—effective: дата отчёта, иначе самая поздняя абсолютная;confident_effective: дата отчёта с уверенностью 100, иначе самая поздняя абсолютная сlabel_confidence == 100;latest_absoluteиconfident_latest_absolute— самые поздние абсолютные (все / с уверенной подписью)finalize_columns(drafts: Sequence[ColumnDraft], dates: DocumentDates, rules: Rules) -> tuple[tuple[StatementColumn, ...], list[StatementWarning]]—column_index = grid_col; относительные →resolve_relative(kind, dates.effective); абсолютная подпись сlabel_confidence < 100не вexpected_dates(dates.confident_effective)(илиconfident_effective is None) →COLUMN_DATE_UNCERTAINсcolumn_index(только абсолютные, P1-11)
-
Step 1: Write the failing test
from datetime import date
from decimal import Decimal as D
from arm_statements.columns import document_dates, finalize_columns, segment_columns
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import PeriodKind, Severity, Source
from arm_statements.tests.builders import excel, hdr, ocr
OCR, EXCEL = Source.OCR, Source.EXCEL
def run(rows: list[list[object]], source: Source, rules: Rules, header=None, header_rows=(0,), data_rows=(1, 2)):
cand = ocr(rows) if source is OCR else excel([("Лист1", rows)])
drafts, warnings = segment_columns(cand.rows, header_rows, data_rows, 1 if source is OCR else 2, source, rules)
head = parse_header(header or hdr(), rules)
columns, more = finalize_columns(drafts, document_dates(drafts, head), rules)
return columns, [*warnings, *more]
def test_blank_66n_relative_labels(rules: Rules) -> None:
rows = [["Наименование", "Код", "На отчетную дату", "На 31 декабря предыдущего года",
"На 31 декабря года, предшествующего предыдущему"],
["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]]
columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
assert [c.period_date for c in columns] == [date(2025, 9, 30), date(2024, 12, 31), date(2023, 12, 31)]
assert [c.period_kind for c in columns] == [PeriodKind.CURRENT, PeriodKind.PREV_YEAR_END,
PeriodKind.PREV_PREV_YEAR_END]
assert warnings == []
def test_excel_skipped_change_column(rules: Rules) -> None:
rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение, %"],
["Запасы", "", "1210", D("5"), D("4"), D("25")], ["Итого", "", "1200", D("5"), D("4"), D("25")]]
columns, warnings = run(rows, EXCEL, rules)
assert [c.column_index for c in columns] == [3, 4]
assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_SKIPPED", Severity.INFO, 5)]
assert {c.label_confidence for c in columns} == {100.0}
def test_junk_label_over_numbers_is_kept(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025", ("| | г", 17.0)],
["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"]]
columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
junk = columns[1]
assert (junk.label, junk.period_date, junk.period_kind) == ("| | г", None, PeriodKind.NONE)
assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)]
def test_sparse_ocr_columns(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025", None, "На 31.12.2024", "На 31.12.2023"],
["Запасы", "1210", "5", None, "4", "3"], ["Итого", "1200", "5", None, "4", "3"]]
columns, _ = run(rows, OCR, rules)
assert [c.column_index for c in columns] == [2, 4, 5]
def test_uncertain_label_does_not_confirm_itself(rules: Rules) -> None:
rows = [["Наименование", "Код", ("На 30.09.2О25", 60.0), ""], ["Запасы", "1210", "5", "4"],
["Итого", "1200", "5", "4"]]
cand = ocr(rows)
drafts, _ = segment_columns(cand.rows, (0,), (1, 2), 1, OCR, rules)
dates = document_dates(drafts, parse_header(hdr(), rules))
assert (dates.effective, dates.confident_effective) == (date(2025, 9, 30), None)
_, warnings = finalize_columns(drafts, dates, rules)
assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 2)]
def test_uncertain_label_confirmed_by_report_date(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025", ("На 31.12.2024", 60.0), ("На 31.03.2025", 60.0)],
["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]]
_, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025"))
assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 4)]
def test_segment_without_header(rules: Rules) -> None:
cand = ocr([["Запасы", "1210", "5", "", "4"], ["Итого", "1200", "5", "", "4"]])
drafts, warnings = segment_columns(cand.rows, (), (0, 1), 1, OCR, rules)
assert [(d.grid_col, d.label, d.kind) for d in drafts] == [(2, "", PeriodKind.NONE), (4, "", PeriodKind.NONE)]
assert warnings == []
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_columns.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
columns.pyпо Interfaces (донор — tabular-parsercolumns.py::_period_columns,_value_columns,_joined_label; scan-gatewaydocument.py::_columns). -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — колонки периодов, относительные даты бланка 66н, неуверенные и мусорные подписи"
Task 10: Шаг 2 — сегменты, сопоставление колонок, find_layout
Files:
- Create:
$R/arm_statements/segments.py - Test:
$R/arm_statements/tests/test_segments.py
Interfaces:
- Consumes:
layout.py,columns.py,majority_section,make_warning,FormCandidate,StatementHeader. - Produces (
arm_statements/segments.py):SegmentLayout(first_row: int, last_row: int, header_rows: tuple[int, ...], code_col: int | None, name_col: int | None, explanations_col: int | None, value_cols: tuple[tuple[int, int], ...], extra_cols: tuple[int, ...])—value_cols:(столбец сетки сегмента, column_index колонки документа)Layout(code_found: bool, column_section: CodeSection | None, columns: tuple[StatementColumn, ...], segments: tuple[SegmentLayout, ...], roles: tuple[RowRole, ...], dates: DocumentDates, warnings: tuple[StatementWarning, ...])—roles— по всем строкам сетки;column_section— отрезок большинства кодов столбца кодов первого сегмента с найденным столбцомfind_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout
Правила find_layout (спека «Шаг 2 — сегменты, повторная шапка и границы листов», «строка заголовка…», «номер
колонки»): сетка делится по segment_breaks; внутри куска новый сегмент открывает строка-шапка, стоящая после
первой строки с кодом текущего сегмента (ячейка подписи кода или ≥ 2 непустых ячеек, равных после normalize_caption
ячейкам строк заголовка первого сегмента). Раскладка сегмента: с заголовком — столбец кодов (а)/(б) и колонки по
заголовку; без заголовка — (б), а не первому сегменту при кодах < 3 — ослабленный поиск по column_section. Колонки
документа — колонки первого сегмента; сегмент со своей шапкой сопоставляется по period_date (у каждой колонки дата
есть и совпадает ровно с одной колонкой первого), иначе по порядку; без шапки — по порядку. По порядку и разное число —
SEGMENT_COLUMNS_MISMATCH (значения в пределах меньшего числа, extra_cols — лишние, их номера в message). Сегмент
с содержимым без столбца кодов — SEGMENT_WITHOUT_CODES, его строки — DATA. Предупреждения о подписях
(COLUMN_SKIPPED, COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN) выдаются только по колонкам документа (сегмента,
задающего колонки); у остальных сегментов столбцы-исключения пропускаются молча, неразобранные подписи не
предупреждают (сценарий «Повторная шапка с другими подписями» — предупреждений нет). Заголовка нет ни у одного сегмента —
колонки первого сегмента по правилу «без заголовка» и одно HEADER_ROW_NOT_FOUND; заголовок есть хоть у одного, а у
первого нет — колонки документа берутся из первого сегмента с заголовком (P1-18). code_found — столбец кодов найден
хотя бы у одного сегмента.
- Step 1: Write the failing test
from decimal import Decimal as D
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import RowRole
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr, place
DATES3 = ["На 30 сентября 2025 г.", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."]
def test_npz_sheet_with_own_header_maps_by_date(rules: Rules) -> None:
first = [place({1: "Наименование показателя", 12: "Код", 16: DATES3[0], 21: DATES3[1], 24: DATES3[2]}),
place({1: "Нематериальные активы", 12: "1110", 16: D("10"), 21: D("9"), 24: D("8")}),
place({1: "Итого по разделу I", 12: "1100", 16: D("10"), 21: D("9"), 24: D("8")})]
sheet4 = [place({0: "Наименование показателя", 6: "Код", 8: DATES3[0], 9: DATES3[1], 10: DATES3[2]}),
place({0: "Заемные средства", 6: "1510", 8: D("3"), 9: D("2"), 10: D("1")})]
cand = excel([("Бухгалтерский баланс", first), ("Бухгалтерский баланс. Лист 4", sheet4)])
layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules)
assert [c.column_index for c in layout.columns] == [16, 21, 24]
assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24))
assert not [w for w in layout.warnings if w.code.startswith("SEGMENT_")]
def test_repeat_header_mid_table(rules: Rules) -> None:
rows = [["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"],
["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Итого", "1200", "5", "4"]]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert [s.first_row for s in layout.segments] == [0, 2]
assert layout.roles[2] is RowRole.HEADER
def test_repeat_header_without_dates_maps_by_order(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"],
["Наименование", "Код", "отчетн", "прошл"], ["Итого", "1200", "5", "4"]]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert layout.segments[1].value_cols == ((2, 2), (3, 3))
assert layout.warnings == ()
def test_wider_segment_is_mismatch(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", "На 31.12.2023"],
["Запасы", "1210", "5", "4", "3"],
["Наименование", "Код", "графа 3", "графа 4", "графа 5", "графа 6"],
["Итого", "1200", "5", "4", "3", "2"]]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
(warning,) = [w for w in layout.warnings if w.code == "SEGMENT_COLUMNS_MISMATCH"]
assert warning.severity.value == "error" and "5" in warning.message
assert layout.segments[1].value_cols == ((2, 2), (3, 3), (4, 4)) and layout.segments[1].extra_cols == (5,)
def test_second_sheet_single_code_in_other_column(rules: Rules) -> None:
first = [place({0: "Наименование", 3: "Код", 4: "За 9 месяцев 2025 г.", 5: "За 9 месяцев 2024 г."}),
place({0: "Выручка", 3: "2110", 4: D("100"), 5: D("90")}),
place({0: "Себестоимость", 3: "2120", 4: D("-60"), 5: D("-50")}),
place({0: "Валовая прибыль", 3: "2100", 4: D("40"), 5: D("40")})]
second = [place({0: "Чистая прибыль", 1: "2400", 2: D("7"), 3: D("6")})]
cand = excel([("ОФР", first), ("ОФР_2", second)])
layout = find_layout(cand, parse_header(hdr(), rules), rules)
assert layout.segments[1].code_col == 1
assert layout.segments[1].value_cols == ((2, 4), (3, 5))
assert not [w for w in layout.warnings if w.code.startswith(("SEGMENT_", "HEADER_"))]
def test_header_not_found_anywhere(rules: Rules) -> None:
rows = [["Запасы", "1210", "5", "4"], ["Дебиторы", "1230", "3", "2"], ["Итого", "1200", "8", "6"]]
layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules)
assert [w.code for w in layout.warnings] == ["HEADER_ROW_NOT_FOUND"]
assert [(c.column_index, c.label) for c in layout.columns] == [(2, ""), (3, "")]
def test_segment_without_codes(rules: Rules) -> None:
first = [["Наименование", "Код", "На 30.09.2025"], ["Запасы", "1210", "5"], ["Итого", "1200", "5"]]
tail = [["Примечание к форме", "", "7"]]
layout = find_layout(excel([("Баланс", first), ("Хвост", tail)]), parse_header(hdr(), rules), rules)
assert [w.code for w in layout.warnings] == ["SEGMENT_WITHOUT_CODES"]
assert layout.roles[3] is RowRole.DATA and layout.code_found
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_segments.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
segments.pyпо Interfaces и правилам выше (донор — tabular-parsermerge.py, логикаrelaxedвcolumns.py::find_layout). -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 2 — сегменты, повторная шапка, листы-продолжения и сопоставление колонок с первым сегментом"
Task 11: Шаг 3 — строки данных, статусы и разлепление
Files:
- Create:
$R/arm_statements/rows.py - Test:
$R/arm_statements/tests/test_rows.py
Interfaces:
-
Consumes:
Layout,SegmentLayout,cell_at,name_cell,parse_code,code_parts,section_of,has_digit,is_disguised_digit,parse_amount,make_warning,FormCandidate,LineStatus. -
Produces (
arm_statements/rows.py):RawLine(row_index: int, ref: str, grid_row: int, code_cell: Cell | None, code: str | None, code_fixed: bool, line_status: LineStatus, name: Cell | None, parent_ref: str | None, cells: tuple[tuple[int, Cell | None], ...], split: bool)—cells— по всем колонкам документа по порядкуlayout.columns(column_index, ячейка сегмента илиNone); у разлепленных строкcode_cell,nameи ячейки значений — производныеCellс k-й частью текста, исходной уверенностью иref<ref ячейки>#<k>collect_raw_lines(candidate: FormCandidate, layout: Layout, rules: Rules) -> tuple[list[RawLine], list[StatementWarning]]— по спеке «Шаг 3 — строки данных и их статусы» и «Шаг 3 — разлепление»: строки ролиDATAс содержимым;refстроки —row:<i>/row:<i>#<k>у OCR (<i>— изCell.refячейки строки), у Excel —refячейки кода, без неё — первой непустой ячейки строки (у разлепленной —#<k>);row_index— порядковый с 0 после разлепления;parent_refуDETAIL— ближайшаяOKвыше во всём документе;ROW_SPLIT(info) на каждую разлепленную строку сетки сline_refsеё частей. Сегмент без столбца кодов: ячейка кода —None, значения — по столбцам сегментаvalue_cols(если есть), иначе строки идут без значений (статус —detail/missingпо наличию содержимого в любой ячейке правее наименования)
-
Step 1: Write the failing test
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import LineStatus
from arm_statements.rows import collect_raw_lines
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"]
OK, UNREADABLE, DETAIL, MISSING = LineStatus.OK, LineStatus.UNREADABLE, LineStatus.DETAIL, LineStatus.MISSING
def lines_of(rows, rules: Rules, source: str = "ocr"):
cand = ocr(rows) if source == "ocr" else excel([("Баланс", rows)])
layout = find_layout(cand, parse_header(hdr(), rules), rules)
return collect_raw_lines(cand, layout, rules)
def test_junk_code_lines_survive(rules: Rules) -> None:
lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["Мусор", "ИЧ,250", "7", "6"],
["в том числе: прочее", "", "1", ""], ["", "44 30\nI |", "", ""], ["Итого", "1100", "10", "8"]],
rules)
assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "1110"), (OK, "1150"), (UNREADABLE, None),
(DETAIL, None), (UNREADABLE, None), (OK, "1100")]
assert lines[3].parent_ref == lines[1].ref == "row:2"
assert [ln.row_index for ln in lines] == [0, 1, 2, 3, 4, 5]
def test_lookalike_only_code_cells_are_empty(rules: Rules) -> None:
lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["", "изо :", ("3", 0.0), ""],
["", "НИ", "I", ""], ["", "| 140", "2", "1"], ["Итого", "1100", "12", "9"]], rules)
assert [(ln.line_status, ln.code, ln.code_fixed) for ln in lines] == [
(OK, "1110", False), (OK, "1150", False), (DETAIL, None, False), (DETAIL, None, False), (OK, "1140", True),
(OK, "1100", False),
]
def test_missing_before_first_code_and_name_only_row(rules: Rules) -> None:
lines, _ = lines_of([HEAD, ["Выручка", "", "100", "90"], ["Раздел I. Активы", "", "", ""],
["Запасы", "1150", "5", "4"]], rules)
assert [ln.line_status for ln in lines] == [MISSING, OK]
def test_duplicate_code_both_ok(rules: Rules) -> None:
lines, _ = lines_of([HEAD, ["Налог", "2410", "100", "1"], ["Налог УСН", "2410", "649", "2"]], rules)
assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "2410"), (OK, "2410")]
def test_split_glued_rows(rules: Rules) -> None:
lines, warnings = lines_of([HEAD, ["Запасы", "1150", "5", "4"],
["Займы\nКредиторы", "1500\n1700", "917 775\n2 371 333", "1\n2"]], rules)
split = lines[1:]
assert [(ln.ref, ln.code, ln.split) for ln in split] == [("row:2#1", "1500", True), ("row:2#2", "1700", True)]
assert [ln.cells[0][1].text for ln in split if ln.cells[0][1] is not None] == ["917 775", "2 371 333"]
assert [ln.name.text for ln in split if ln.name is not None] == ["Займы", "Кредиторы"]
assert [(w.code, w.line_refs) for w in warnings] == [("ROW_SPLIT", ("row:2#1", "row:2#2"))]
def test_not_split_when_no_part_in_column_range(rules: Rules) -> None:
lines, warnings = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"],
["", "44 30\nI |", "1\n2", ""], ["Итого", "1100", "10", "8"]], rules)
assert [ln.line_status for ln in lines] == [OK, OK, UNREADABLE, OK] and warnings == []
def test_excel_signature_text_is_not_content(rules: Rules) -> None:
rows = [["Наименование", "", "Код", "На 30.09.2025"], ["Запасы", "", "1150", "5"],
["Руководитель", "", "", "Косарев"], ["", "", "", "(расшифровка подписи)"]]
lines, _ = lines_of(rows, rules, source="excel")
assert [ln.ref for ln in lines] == ["Баланс!C2"]
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_rows.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
rows.py. Содержимое ячейки значения:parse_amount(text, source).amount is not None, или непуста с уверенностью < 100, илиis_disguised_digit; у ячейки сvalue-числом — содержимое всегда. Разлепление — до разбора кода; «часть — код диапазона столбца» —parse_code(part).codeиsection_of == layout.column_section. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 3 — строки данных со статусами ok/unreadable/detail/missing, ref, разлепление склеек"
Task 12: Шаги 4–5 — строки формы и предупреждения единиц
Files:
- Create:
$R/arm_statements/lines.py - Test:
$R/arm_statements/tests/test_lines.py
Interfaces:
-
Consumes:
RawLine,build_value,make_warning,StatementLine,StatementHeader,Source,Rules. -
Produces (
arm_statements/lines.py):CODE_CONFIDENCE_CAP = 99.0build_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_indexunits_warnings(lines: Sequence[StatementLine], header: StatementHeader, adapter_warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]— единицыunits.thousandsи доля значений с дробной частью среди значений сamount is not None≥fractional_share_percent→XLSB_SCALE_SUSPECT, если средиadapter_warningsестьXLSB_NO_FORMATS(P1-5), иначеFRACTIONAL_IN_THOUSANDS
-
Step 1: Write the failing test
from decimal import Decimal as D
from arm_statements.header import parse_header
from arm_statements.issues import make_warning
from arm_statements.lines import build_lines, units_warnings
from arm_statements.loader import Rules
from arm_statements.model import Severity, Source
from arm_statements.rows import collect_raw_lines
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
def statement_lines(cand, rules: Rules):
layout = find_layout(cand, parse_header(hdr(), rules), rules)
raw, _ = collect_raw_lines(cand, layout, rules)
return build_lines(raw, cand.source, rules)
def test_ocr_name_and_code_confidence(rules: Rules) -> None:
cand = ocr([HEAD, [("Дебиторская задолжен-ность", 71.0), "1230", "5"], ["Итого", "I1О0", "5"]])
lines, _ = statement_lines(cand, rules)
assert (lines[0].line_name, lines[0].line_name_confidence) == ("Дебиторская задолжен-ность", 71.0)
assert (lines[1].line_code, lines[1].line_code_text, lines[1].line_code_confidence) == ("1100", "I1О0", 99.0)
assert [v.column_index for v in lines[0].values] == [2]
def test_value_out_of_range_warning(rules: Rules) -> None:
cand = excel([("Баланс", [["Наименование", "Код", "На 30.09.2025"],
["Запасы", "1210", D("123456789012345678")]])])
lines, warnings = statement_lines(cand, rules)
assert lines[0].values[0].amount is None
assert [(w.code, w.severity, w.line_refs, w.column_index) for w in warnings] == [
("VALUE_OUT_OF_RANGE", Severity.INFO, ("Баланс!B2",), 2)
]
def test_fractional_in_thousands(rules: Rules) -> None:
rows = [["Наименование", "Код", "На 30.09.2025"], *[[f"Строка {i}", c, D(v)] for i, (c, v) in
enumerate([("1210", "1.5"), ("1220", "2.25"), ("1230", "3"), ("1240", "4"), ("1200", "10.75")])]]
cand = excel([("Баланс", rows)])
lines, _ = statement_lines(cand, rules)
head = parse_header(hdr(units="тыс. руб."), rules)
assert [w.code for w in units_warnings(lines, head, (), rules)] == ["FRACTIONAL_IN_THOUSANDS"]
xlsb = make_warning("XLSB_NO_FORMATS", "формат чисел xlsb не читается", rules)
(scale,) = units_warnings(lines, head, (xlsb,), rules)
assert (scale.code, scale.severity) == ("XLSB_SCALE_SUSPECT", Severity.ERROR)
assert units_warnings(lines, parse_header(hdr(units="руб."), rules), (), rules) == []
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_lines.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
lines.pyпо Interfaces. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаги 4–5 — строки формы с сырым текстом и уверенностью, предупреждения единиц и масштаба xlsb"
Task 13: Шаг 7 — самопроверки §7 и вариант формы
Files:
- Create:
$R/arm_statements/evaluation.py,$R/arm_statements/checks.py - Test:
$R/arm_statements/tests/test_checks.py
Interfaces:
-
Consumes:
CheckRules,RelationRule,DocumentType,FormVariant,LineStatus,FailedRelation,StatementChecks,section_of. -
Produces (
arm_statements/evaluation.py) — общий вид строк шагов 6–8 у сервиса и ядра:EvalValue(column: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, fixed: bool = False)—column— ключ колонки (str(column_index)у сервиса, ключ хоста у ядра);fixed— правлено или подтверждено аналитикомEvalLine(key: str, order: int | None, line_status: LineStatus, line_code: str | None, code_confidence: float | None, code_fixed: bool, values: tuple[EvalValue, ...])—key:refу сервиса, ключ хоста у ядра;order:row_index(у ядраNone— строка правки)is_check_line(line: EvalLine) -> bool—OKс кодом; у строкиorder is None— ещё и хотя бы одноamountнеNone(P1-8)
-
Produces (
arm_statements/checks.py):form_variant(document_type: DocumentType, ok_codes: Iterable[str]) -> FormVariant | None— спека «Шаг 6»: ББ — есть 1600 и нет ни 1100, ни 1200 →SIMPLIFIED; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 →SIMPLIFIED; иначеFULL;DISCLOSURE→Nonesubline_parents(codes: Sequence[str]) -> list[str | None]— для каждой позиции: у пятизначного кода — ближайший четырёхзначный выше с теми же первыми тремя цифрами, иначе первые четыре цифры; у прочих —NoneRelationOutcome(column: str, rule: RelationRule, codes: tuple[str, ...], computed: bool, expected: Decimal, actual: Decimal, within_tolerance: bool, within_rounding: bool)со свойствомhard_failed: bool(rule.strict and computed and not within_tolerance and not within_rounding)evaluate_relations(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, variant: FormVariant | None, rules: Rules) -> list[RelationOutcome]— строки — в порядке документа (вызывающий сортирует; от порядка зависят родители подстрок); наборrelations_for(document_type, variant or FULL)+ сгенерированные «родитель = Σ подстрок» (мягкие, только родители в отрезке формы, только в колонке, где хоть одна подстрока непуста); значение кода — суммаamountпо строкамis_check_line; посчитано — непуста цель или член; правая часть как записано и сabs_termsпо модулю, берётся сошедшийся вариант, иначе ближайший (при равенстве — «как записано»);within_tolerance:|expected − actual| ≤ tolerance;within_rounding:|expected − actual| ≤ max(|expected|, |значения членов|) × rounding_tolerance_percent / 100checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks—hard_failed→relations_failed; посчитанное и не сошедшееся в пределах допуска прочее →soft_relations_failed;codes— цель и члены;relation— текст правила
-
Step 1: Write the failing test
from decimal import Decimal as D
from arm_statements.checks import checks_from, evaluate_relations, form_variant, subline_parents
from arm_statements.evaluation import EvalLine, EvalValue
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, FormVariant, LineStatus
BAL, INC = DocumentType.BALANCE, DocumentType.INCOME
def line(code: str | None, *amounts: str | None, status: LineStatus = LineStatus.OK, order: int = 0) -> EvalLine:
values = tuple(EvalValue(str(i), None if a is None else D(a), a, 100.0, True) for i, a in enumerate(amounts))
return EvalLine(f"k{order}{code}", order, status, code, 100.0, False, values)
def failed(lines: list[EvalLine], doc: DocumentType, variant: FormVariant | None, rules: Rules):
outcomes = evaluate_relations(lines, ["0"], doc, variant, rules)
return checks_from(outcomes, {"0": 0})
def test_form_variant() -> None:
assert form_variant(BAL, ["1150", "1210", "1600", "1310", "1700"]) is FormVariant.SIMPLIFIED
assert form_variant(BAL, ["1100", "1600"]) is FormVariant.FULL
assert form_variant(INC, ["2110", "2400"]) is FormVariant.SIMPLIFIED
assert form_variant(INC, ["2110", "2100", "2400"]) is FormVariant.FULL
assert form_variant(DocumentType.DISCLOSURE, ["5101"]) is None
def test_empty_target_is_violation(rules: Rules) -> None:
lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"),
line(None, "100", status=LineStatus.DETAIL)]
checks = failed(lines, BAL, FormVariant.FULL, rules)
assert [f.relation for f in checks.relations_failed] == ["1600 = 1100 + 1200"]
def test_target_without_members_is_violation(rules: Rules) -> None:
lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"), line("1600", "100")]
checks = failed(lines, BAL, FormVariant.FULL, rules)
assert [f.relation for f in checks.relations_failed] == ["1600 = 1700"]
assert checks.relations_failed[0].codes == ("1600", "1700")
def test_duplicate_code_is_summed(rules: Rules) -> None:
lines = [line("2300", "849"), line("2410", "100"), line("2410", "649"), line("2400", "100")]
outcomes = evaluate_relations(lines, ["0"], INC, FormVariant.FULL, rules)
target = next(o for o in outcomes if o.rule.relation == "2400 = 2300 - 2410 + 2420")
assert target.within_tolerance
def test_simplified_income_soft(rules: Rules) -> None:
lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")]
checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules)
assert checks.relations_failed == ()
assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"]
def test_rounding_goes_soft(rules: Rules) -> None:
lines = [line("1600", "17000000009.02"), line("1700", "17000000000"), line("1100", "17000000009.02"),
line("1300", "17000000000")]
checks = failed(lines, BAL, FormVariant.FULL, rules)
assert "1600 = 1700" in [f.relation for f in checks.soft_relations_failed]
assert "1600 = 1700" not in [f.relation for f in checks.relations_failed]
def test_abs_terms_hold_with_minus(rules: Rules) -> None:
lines = [line("2110", "100"), line("2120", "-60"), line("2100", "40")]
hard = failed(lines, INC, FormVariant.FULL, rules).relations_failed
assert "2100 = 2110 - 2120" not in [f.relation for f in hard]
def test_sublines(rules: Rules) -> None:
assert subline_parents(["1230", "12301", "12302", "1240", "11101"]) == [None, "1230", "1230", None, "1110"]
lines = [line("1230", "10"), line("12301", "4"), line("12302", "5")]
soft = failed(lines, BAL, FormVariant.FULL, rules).soft_relations_failed
assert [f.relation for f in soft] == ["1230 = 12301 + 12302"]
empty = [line("1230", "10"), line("12301", None), line("12302", None)]
assert failed(empty, BAL, FormVariant.FULL, rules).soft_relations_failed == ()
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_checks.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
evaluation.pyиchecks.pyпо Interfaces (доноры: tabular-parserchecks.py, arm-corestatement_checks.py::subline_parents). Текст сгенерированного правила —"<родитель> = <подстрока> + …"в порядке строк. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 7 — посчитанные соотношения §7, abs_terms, допуск и округление, упрощённый набор, подстроки"
Task 14: Шаг 8 — подтверждение неуверенного кода структурой и редакция
Files:
- Create:
$R/arm_statements/structure.py - Test:
$R/arm_statements/tests/test_structure.py
Interfaces:
- Consumes:
EvalLine,is_check_line,CodeOrders,Edition,FormVariant,subline_parents. - Produces (
arm_statements/structure.py):FSBU4_FROM = date(2025, 1, 1)— эффективная дата после 2024-12-31 →fsbu4edition_by_date(effective: date | None) -> Edition | NoneStructureResult(edition: Edition | None, unconfirmed: frozenset[str])—unconfirmed— ключи строк с неуверенным кодом, не подтверждённым структурой (ихneeds_review = true)confirm_codes(lines: Sequence[EvalLine], document_type: DocumentType, variant: FormVariant | None, effective: date | None, rules: Rules) -> StructureResult
Алгоритм (спека «Шаг 8 — подтверждение неуверенного кода структурой»; сигнатура его не определяет):
- Последовательность
S— строкиis_check_lineсorder is not Noneи четырёхзначным кодом, поorder. Неуверенный код —code_confidence is None or code_confidence < 100, иcode_fixedложно. - Для редакции
eи перечняL = sequence(e, document_type, variant or FULL): вSостаются только коды изL(позиции сохраняются). Лучшая подпоследовательность — взвешенная LCSS×Lпо равенству кода с весом позицииw = (len(S) + 1) * уверенность + 1(уверенный — 1, иначе 0): максимум суммы весов = лексикографически «больше уверенных, затем длиннее»; код с повтором вLможет совпасть столько раз, сколько стоит вL. - Редакция — с большим оптимумом; равенство —
edition_by_date(effective); равенство иeffective is None→edition = None, неуверенный код подтверждён, только если подтверждён в обеих редакциях (P1-4). - Неуверенная позиция
iподтверждена ⇔ оптимум с запретом позицииiстрого меньше общего оптимума (то естьiвходит во все лучшие подпоследовательности). - Пятизначный неуверенный код подтверждён, если строка-родитель (
subline_parents) стоит раньше поorderи уверенна или подтверждена; родителя нет — не подтверждён. document_typeDISCLOSUREили нет неуверенных кодов →unconfirmed = ∅;edition— по оптимуму, при пустойS—edition_by_date(effective).
- Step 1: Write the failing test
from datetime import date
from arm_statements.evaluation import EvalLine
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, Edition, FormVariant, LineStatus
from arm_statements.structure import confirm_codes
BAL = DocumentType.BALANCE
Q3_2025 = date(2025, 9, 30)
def seq(*items: tuple[str, float]) -> list[EvalLine]:
return [EvalLine(f"r{i}", i, LineStatus.OK, code, conf, False, ()) for i, (code, conf) in enumerate(items)]
def unconfirmed(lines: list[EvalLine], rules: Rules, effective: date | None = Q3_2025) -> set[str]:
return set(confirm_codes(lines, BAL, FormVariant.FULL, effective, rules).unconfirmed)
def test_uncertain_code_in_place(rules: Rules) -> None:
assert unconfirmed(seq(("1220", 100.0), ("1230", 50.0), ("1240", 100.0)), rules) == set()
def test_swapped_uncertain_neighbours(rules: Rules) -> None:
lines = seq(("1210", 100.0), ("1230", 50.0), ("1220", 50.0), ("1240", 100.0))
assert unconfirmed(lines, rules) == {"r1", "r2"}
def test_conflicting_anchors(rules: Rules) -> None:
lines = seq(("1210", 100.0), ("1240", 100.0), ("1230", 100.0), ("1250", 50.0))
assert unconfirmed(lines, rules) == set()
def test_legal_repeat_1220_fsbu4(rules: Rules) -> None:
lines = seq(("1215", 100.0), ("1220", 100.0), ("1220", 50.0), ("1230", 100.0))
assert unconfirmed(lines, rules) == set()
def test_repeat_outside_orders(rules: Rules) -> None:
lines = seq(("1220", 100.0), ("1230", 100.0), ("1230", 50.0), ("1240", 100.0))
assert unconfirmed(lines, rules) == {"r2"}
def test_66n_blank_in_2025(rules: Rules) -> None:
lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0))
result = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules)
assert (result.edition, result.unconfirmed) == (Edition.RAS_66N, frozenset())
def test_tie_resolved_by_date(rules: Rules) -> None:
lines = seq(("1210", 100.0), ("1230", 100.0))
assert confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition is Edition.FSBU4
assert confirm_codes(lines, BAL, FormVariant.FULL, date(2024, 12, 31), rules).edition is Edition.RAS_66N
def test_confident_nonstandard_code_not_flagged(rules: Rules) -> None:
assert unconfirmed(seq(("1230", 100.0), ("1231", 100.0), ("11101", 100.0)), rules) == set()
def test_subline_follows_confirmed_parent(rules: Rules) -> None:
lines = seq(("1220", 100.0), ("1230", 50.0), ("12301", 60.0), ("1240", 100.0))
assert unconfirmed(lines, rules) == set()
orphan = seq(("1220", 100.0), ("12301", 60.0), ("1240", 100.0))
assert unconfirmed(orphan, rules) == {"r1"}
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_structure.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
structure.pyпо алгоритму выше (DPO(|S|·|L|), повтор с запретом позиции — на каждую неуверенную позицию; размеры форм ≤ 60 строк). -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 8 — подтверждение неуверенного кода перечнем бланка, якоря, законные повторы, выбор редакции"
Task 15: Шаг 8 — needs_review значений и строк, поля шапки, evaluate
Files:
- Create:
$R/arm_statements/review.py - Test:
$R/arm_statements/tests/test_review.py
Interfaces:
-
Consumes:
EvalLine,evaluate_relations,RelationOutcome,confirm_codes,sign_form_of,make_warning,StatementHeader,DocumentDates,CheckRules.abs_codes. -
Produces (
arm_statements/review.py):value_needs_review(lines: Sequence[EvalLine], outcomes: Sequence[RelationOutcome], abs_codes: frozenset[str], rules: Rules, *, accepted: bool = False) -> dict[tuple[str, str], bool]— ключ(line.key, value.column); правила спеки «Шаг 8 —needs_reviewзначения»:DETAIL→False;fixed→False; нуждается в покрытии ⇔confidence is not None and confidence < 100илиnot is_recognized; покрыто ⇔amount is not Noneи в колонке есть посчитанное соотношение с кодом строки, у которогоwithin_tolerance(жёсткое — ещё иwithin_rounding); знак членаabs_codes, нуждающегося в покрытии, подтверждён ⇔ егоsign_form_of(text)равна форме строгого большинства прочих значенийabs_codesтой же колонки сamount is not None,is_recognized, неfixed;True— нуждается и (не покрыто или знак не подтверждён), а также у всех значений строкis_check_lineс кодамиhard_failed-соотношения в его колонке — кромеaccepted=True(жёсткие нарушения акцептованы и статус не держат, P1-20)line_needs_review(lines: Sequence[EvalLine], structure: StructureResult) -> dict[str, bool]—Trueтолько у ключейstructure.unconfirmed;code_fixedи не-OK→FalseEvaluation(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_failedreview_header(header: StatementHeader, dates: DocumentDates, rules: Rules) -> tuple[StatementHeader, list[StatementWarning]]— спека «Шаг 8 — поля шапки» и таблица уровней: дата отчёта с уверенностью < 100, не равнаяdates.confident_latest_absolute→needs_review_fields = ("report_date",)иREPORT_DATE_UNCERTAIN(field="report_date");NO_ORGANIZATION_NO_INN— нет ни организации, ни ИНН;INN_NOT_FOUND— нет ИНН при организации;ORGANIZATION_NOT_FOUND— нет организации при ИНН;INN_UNCERTAIN/ORGANIZATION_UNCERTAIN— поле есть с уверенностью < 100 (field);REPORT_DATE_UNPARSED— текст даты есть, дата не разобрана, а абсолютные даты колонок есть;PERIOD_UNKNOWN— нет ни даты отчёта, ниlatest_absolute;UNITS_NOT_FOUND— нет единиц
-
Step 1: Write the failing test
from datetime import date
from decimal import Decimal as D
from arm_statements.columns import DocumentDates
from arm_statements.evaluation import EvalLine, EvalValue
from arm_statements.header import parse_header
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, LineStatus
from arm_statements.review import evaluate, review_header
from arm_statements.tests.builders import hdr
OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE
NO_DATES = DocumentDates(None, None, None, None)
def ln(code: str | None, text: str, amount: str | None, conf: float = 100.0, recognized: bool = True,
status: LineStatus = OK, order: int = 0) -> EvalLine:
value = EvalValue("0", None if amount is None else D(amount), text, conf, recognized)
return EvalLine(f"{code}-{order}", order, status, code, 100.0, False, (value,))
def flags(lines: list[EvalLine], doc: DocumentType, rules: Rules) -> dict[str, bool]:
result = evaluate(lines, ["0"], doc, date(2025, 9, 30), rules)
return {key: flag for (key, _), flag in result.value_needs_review.items()}
def test_guess_confirmed_by_relation(rules: Rules) -> None:
lines = [ln("1210", "100", "100", order=0), ln("1230", "42831 1", "428311", recognized=False, order=1),
ln("1200", "428411", "428411", order=2)]
assert flags(lines, DocumentType.BALANCE, rules)["1230-1"] is False
def test_sign_in_curly_bracket_confirmed(rules: Rules) -> None:
lines = [ln("2110", "10000000", "10000000", order=0),
ln("2120", "(7744536}", "-7744536", recognized=False, order=1),
ln("2100", "2255464", "2255464", order=2), ln("2210", "(100)", "-100", order=3),
ln("2220", "(200)", "-200", order=4), ln("2200", "2255164", "2255164", order=5)]
assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is False
def test_sign_outside_convention(rules: Rules) -> None:
lines = [ln("2110", "100000", "100000", order=0), ln("2120", "38378", "38378", conf=60.0, order=1),
ln("2100", "61622", "61622", order=2), ln("2210", "(100)", "-100", order=3),
ln("2220", "(200)", "-200", order=4)]
assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is True
def test_null_amount_and_unchecked_code(rules: Rules) -> None:
lines = [ln("2120", "{38378)", None, recognized=False, order=0), ln("2900", "15", "15", conf=80.0, order=1)]
result = flags(lines, DocumentType.INCOME, rules)
assert (result["2120-0"], result["2900-1"]) == (True, True)
def test_detail_values_never_flagged(rules: Rules) -> None:
lines = [ln("2110", "100", "100", order=0), ln(None, "1 425 130", "1425130", conf=78.0, status=DETAIL, order=1)]
assert flags(lines, DocumentType.INCOME, rules)["None-1"] is False
def test_soft_relation_covers_only_exactly(rules: Rules) -> None:
exact = [ln("2300", "1000", "1000", order=0), ln("2410", "200", "200", order=1),
ln("2400", "800", "800", conf=66.0, order=2)]
assert flags(exact, DocumentType.INCOME, rules)["2400-2"] is False
rounding = [ln("2300", "17000000000", "17000000000", order=0), ln("2410", "0", "0", order=1),
ln("2400", "17000000009", "17000000009", conf=66.0, order=2)]
assert flags(rounding, DocumentType.INCOME, rules)["2400-2"] is True
def test_hard_failure_flags_clean_cells(rules: Rules) -> None:
lines = [ln("1210", "100", "100", order=0), ln("1230", "50", "50", order=1), ln("1200", "999", "999", order=2)]
result = evaluate(lines, ["0"], DocumentType.BALANCE, None, rules)
assert result.hard_failures >= 1
assert all(result.value_needs_review[(f"{c}-{i}", "0")] for i, c in enumerate(["1210", "1230", "1200"]))
def test_unreadable_values(rules: Rules) -> None:
lines = [ln(None, "7", "7", conf=60.0, status=UNREADABLE), ln(None, "5", "5", status=UNREADABLE, order=1)]
result = flags(lines, DocumentType.BALANCE, rules)
assert (result["None-0"], result["None-1"]) == (True, False)
def test_report_date_confirmed_or_not(rules: Rules) -> None:
head = parse_header(hdr(report_date=("30.09.2025", 60.0), organization_name="ООО «Пример»",
inn="7700000000", units="тыс. руб."), rules)
ok_dates = DocumentDates(date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30))
reviewed, warnings = review_header(head, ok_dates, rules)
assert (reviewed.needs_review_fields, warnings) == ((), [])
relative_only = DocumentDates(date(2025, 9, 30), None, None, None)
reviewed, warnings = review_header(head, relative_only, rules)
assert reviewed.needs_review_fields == ("report_date",)
assert [(w.code, w.severity.value, w.field) for w in warnings] == [("REPORT_DATE_UNCERTAIN", "error", "report_date")]
def test_header_presence_warnings(rules: Rules) -> None:
_, no_inn = review_header(parse_header(hdr(organization_name="ООО «Пример»", report_date="30.09.2025",
units="тыс. руб."), rules), NO_DATES, rules)
assert [w.code for w in no_inn] == ["INN_NOT_FOUND"]
_, nothing = review_header(parse_header(hdr(), rules), NO_DATES, rules)
assert {w.code for w in nothing} == {"NO_ORGANIZATION_NO_INN", "PERIOD_UNKNOWN", "UNITS_NOT_FOUND"}
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_review.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
review.pyпо Interfaces.abs_codes—rules.checks.abs_codes(document_type, variant or FULL). -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 8 — needs_review по покрытию §7 и конвенции знака, проверки полей шапки"
Task 16: Функция статуса документа и конверта
Files:
- Create:
$R/arm_statements/status.py - Test:
$R/arm_statements/tests/test_status.py
Interfaces:
-
Consumes:
DocumentType,LineStatus,Severity,DocStatus,EnvelopeStatus,StatementWarning. -
Produces (
arm_statements/status.py):StatusLine(line_status: LineStatus, needs_review: bool);StatusValue(line_status: LineStatus, needs_review: bool)StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...], hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])document_status(data: StatusInput) -> DocStatus— спека «Шаг 8 — статус документа и конверта»:DISCLOSURE→DONE; иначеDONE⇔layout_found∧has_organization_or_inn∧period_known∧ все строкиOK/DETAIL∧ нет строк сneeds_review∧ нет значений сneeds_review, кроме значений строкDETAIL∧needs_review_fieldsпуст ∧ (hard_failures == 0илиaccepted) ∧ нетSeverity.ERRORвwarning_severitiesenvelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus— документов нет →ERROR; всеDONEи нет предупреждений файла уровняerror→DONE; иначеNEEDS_CHECK
-
Step 1: Write the failing test
from dataclasses import replace
from arm_statements.issues import make_warning
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, EnvelopeStatus, LineStatus, Severity
from arm_statements.status import StatusInput, StatusLine, StatusValue, document_status, envelope_status
OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE
DONE, CHECK = DocStatus.DONE, DocStatus.NEEDS_CHECK
BASE = StatusInput(
document_type=DocumentType.BALANCE, layout_found=True, has_organization_or_inn=True, period_known=True,
needs_review_fields=(), lines=(StatusLine(OK, False),), values=(StatusValue(OK, False),), hard_failures=0,
accepted=False, warning_severities=(Severity.INFO,),
)
def test_clean_form_done() -> None:
assert document_status(BASE) is DONE
def test_unreadable_line_blocks() -> None:
assert document_status(replace(BASE, lines=(StatusLine(OK, False), StatusLine(UNREADABLE, False)))) is CHECK
def test_detail_value_does_not_block() -> None:
lines = (StatusLine(OK, False), StatusLine(DETAIL, False))
values = (StatusValue(OK, False), StatusValue(DETAIL, True))
assert document_status(replace(BASE, lines=lines, values=values)) is DONE
def test_blocking_conditions() -> None:
assert document_status(replace(BASE, values=(StatusValue(OK, True),))) is CHECK
assert document_status(replace(BASE, lines=(StatusLine(OK, True),))) is CHECK
assert document_status(replace(BASE, needs_review_fields=("report_date",))) is CHECK
assert document_status(replace(BASE, hard_failures=2)) is CHECK
assert document_status(replace(BASE, hard_failures=2, accepted=True)) is DONE
assert document_status(replace(BASE, warning_severities=(Severity.ERROR,))) is CHECK
assert document_status(replace(BASE, layout_found=False)) is CHECK
assert document_status(replace(BASE, has_organization_or_inn=False)) is CHECK
assert document_status(replace(BASE, period_known=False)) is CHECK
def test_disclosure_always_done() -> None:
disclosure = replace(BASE, document_type=DocumentType.DISCLOSURE, layout_found=False, lines=(), values=())
assert document_status(disclosure) is DONE
def test_envelope(rules: Rules) -> None:
assert envelope_status([DONE, CHECK], []) is EnvelopeStatus.NEEDS_CHECK
assert envelope_status([DONE], []) is EnvelopeStatus.DONE
lost = make_warning("TABLE_LOST", "таблица без колонки кодов", rules, locator="file:a.json;table:1")
assert envelope_status([DONE], [lost]) is EnvelopeStatus.NEEDS_CHECK
assert envelope_status([], []) is EnvelopeStatus.ERROR
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_status.py -v
Expected: FAIL — ModuleNotFoundError
-
Step 3: Implement
status.pyпо Interfaces. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: общая функция статуса документа (detail не держит) и статуса конверта"
Task 17: Шаг 6 и конвейер кандидата
Files:
- Create:
$R/arm_statements/pipeline.py - Modify:
$R/arm_statements/__init__.py(экспорт) - Test:
$R/arm_statements/tests/test_pipeline.py
Interfaces:
- Consumes: всё из задач 3–16.
- Produces (
arm_statements/pipeline.py):normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResultnormalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult— по порядку; исключения не перехватывает (хост ловит их на кандидате и пишетTABLE_PARSE_ERROR)
Порядок normalize_candidate (спека «Шаг 6 — тип формы, вариант и документ», «Шаг 8»):
header = parse_header(candidate.header);layout = find_layout(candidate, header).- Нет столбца кодов (
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; тип —
document_type_of(majority_section(коды строк OK)); кодовOKнет —type_hint; нет и его — как п. 2 безtype_hint. Тип по кодам противоречит не-Nonetype_hint→TYPE_CONFLICT(error). - Тип
DISCLOSURE→ документ без строк,DONE. - Иначе:
build_lines,units_warnings;EvalLineизStatementLine(key = ref,order = row_index, колонкиstr(column_index),fixed=False);evaluate(..., effective=layout.dates.effective);checks_from;review_header; значения и строки получаютneeds_review(evaluate(..., accepted=False)); документ:form_variantизEvaluation,checks(у документа со строками — всегда, даже пустые списки), предупреждения в порядке: адаптера, раскладки, строк (ROW_SPLIT,VALUE_OUT_OF_RANGE), единиц,TYPE_CONFLICT, шапки;status = document_status(...)сlayout_found=True,period_known = report_date ∨ latest_absolute,accepted=False.
- Step 1: Write the failing test
from decimal import Decimal as D
from arm_statements import normalize, normalize_candidate
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, FormVariant, Severity
from arm_statements.tests.builders import excel, hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
FULL_HEADER = hdr(organization_name="ООО «Пример»", inn="7700000000", report_date="30.09.2025", units="тыс. руб.")
def bal_rows() -> list[list[str]]:
return [HEAD, ["Запасы", "1210", "60"], ["Дебиторы", "1230", "40"], ["Итого II", "1200", "100"],
["НМА", "1110", "50"], ["Итого I", "1100", "50"], ["БАЛАНС", "1600", "150"],
["Капитал", "1310", "150"], ["Итого III", "1300", "150"], ["БАЛАНС", "1700", "150"]]
def test_clean_ocr_balance_done(rules: Rules) -> None:
result = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules)
doc = result.document
assert doc is not None and result.file_warnings == ()
assert (doc.document_type, doc.form_variant, doc.status) == (DocumentType.BALANCE, FormVariant.FULL, DocStatus.DONE)
assert doc.checks is not None and doc.checks.relations_failed == ()
assert doc.header.inn.value == "7700000000"
def test_disclosure_hint_with_balance_codes(rules: Rules) -> None:
doc = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.DISCLOSURE), rules).document
assert doc is not None and doc.document_type is DocumentType.BALANCE
assert [(w.code, w.severity) for w in doc.warnings if w.code == "TYPE_CONFLICT"] == [("TYPE_CONFLICT",
Severity.ERROR)]
def test_balance_hint_with_income_codes(rules: Rules) -> None:
rows = [HEAD, ["Выручка", "2110", "100"], ["Себестоимость", "2120", "(60)"], ["Валовая", "2100", "40"],
["Коммерческие", "2210", "(10)"], ["Прибыль от продаж", "2200", "30"]]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules).document
assert doc is not None and doc.document_type is DocumentType.INCOME
assert "TYPE_CONFLICT" in [w.code for w in doc.warnings]
def test_excel_other_sheet(rules: Rules) -> None:
result = normalize_candidate(excel([("Примечания", [["Пояснительный текст без цифр"]])]), rules)
assert result.document is None
assert [(w.code, w.severity, w.locator) for w in result.file_warnings] == [("SHEET_OTHER", Severity.INFO,
"sheet:Примечания")]
def test_ocr_lost_and_skipped_tables(rules: Rules) -> None:
lost = normalize_candidate(ocr([["Итого", "", "15 000"]], locator="file:a.json;table:1"), rules)
assert [(w.code, w.locator) for w in lost.file_warnings] == [("TABLE_LOST", "file:a.json;table:1")]
skipped = normalize_candidate(ocr([["Руководитель", "Иванов"]]), rules)
assert [w.code for w in skipped.file_warnings] == ["TABLE_SKIPPED"] and skipped.document is None
def test_hint_without_code_column(rules: Rules) -> None:
doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER),
rules).document
assert doc is not None and (doc.lines, doc.columns, doc.checks) == ((), (), None)
assert doc.status is DocStatus.NEEDS_CHECK and "CODE_COLUMN_NOT_FOUND" in [w.code for w in doc.warnings]
disc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document
assert disc is not None and (disc.status, disc.form_variant, disc.lines) == (DocStatus.DONE, None, ())
def test_disclosure_by_codes(rules: Rules) -> None:
rows = [HEAD, ["Пояснение", "5101", "1"], ["Пояснение", "5102", "2"], ["Пояснение", "5100", "3"]]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
assert doc is not None and (doc.document_type, doc.lines, doc.status) == (DocumentType.DISCLOSURE, (),
DocStatus.DONE)
def test_simplified_balance(rules: Rules) -> None:
rows = [HEAD, ["Матактивы", "1150", "50"], ["Запасы", "1210", "50"], ["БАЛАНС", "1600", "100"],
["Капитал", "1300", "100"], ["БАЛАНС", "1700", "100"]]
doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document
assert doc is not None and doc.form_variant is FormVariant.SIMPLIFIED
assert doc.checks is not None and doc.checks.relations_failed == ()
def test_income_without_inn_still_done(rules: Rules) -> None:
rows = [["Наименование", "Код", "За 9 месяцев 2025 г."], ["Выручка", "2110", D("100")],
["Себестоимость", "2120", D("-60")], ["Валовая прибыль", "2100", D("40")],
["Прибыль от продаж", "2200", D("40")], ["Прибыль до налогообложения", "2300", D("40")],
["Чистая прибыль", "2400", D("40")]]
head = hdr(organization_name="ООО «Пример»", units="тыс. руб.")
doc = normalize_candidate(excel([("ОФР", rows)], header=head), rules).document
assert doc is not None and doc.status is DocStatus.DONE
assert [(w.code, w.severity) for w in doc.warnings] == [("INN_NOT_FOUND", Severity.INFO)]
def test_normalize_collects(rules: Rules) -> None:
result = normalize([ocr(bal_rows(), header=FULL_HEADER), ocr([["Руководитель", "Иванов"]])], rules)
assert len(result.documents) == 1 and [w.code for w in result.file_warnings] == ["TABLE_SKIPPED"]
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_pipeline.py -v
Expected: FAIL — ImportError: cannot import name 'normalize'
-
Step 3: Implement
pipeline.pyпо порядку выше;__init__.pyэкспортирует имена раздела «Публичный API», уже существующие к этой задаче. -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: шаг 6 и конвейер кандидата — тип по кодам, вариант формы, пояснения, предупреждения файла"
Task 18: Точка входа пересчёта по сохранённым данным
Files:
- Create:
$R/arm_statements/recompute.py - Modify:
$R/arm_statements/__init__.py - Test:
$R/arm_statements/tests/test_recompute.py
Interfaces:
- Consumes:
EvalLine,EvalValue,evaluate,checks_from,document_status,StatusInput. - Produces (
arm_statements/recompute.py):StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, value_fixed: bool)StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None, line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None, values: tuple[StoredValue, ...])StoredWarning(code: str, severity: Severity)StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool, period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool, warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks, line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], detail_parents: Mapping[str, str | None], status: DocStatus)—value_needs_review— ключ(line.key, column_key);line_needs_reviewиvalue_needs_review— по всем строкам и значениям формыrecompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult
Правила (спека «Точка входа пакета для пересчёта…»): StoredLine → EvalLine(key, row_index, line_status, line_code, line_code_confidence, code_fixed, values=EvalValue(column_key, amount, text, confidence, is_recognized, fixed=value_fixed)); evaluate(lines, [c.key for c in columns], document_type, effective_date, accepted=accepted); checks = checks_from(outcomes, {c.key: c.column_index}); detail_parents — у каждой строки DETAIL: ближайшая выше по
row_index строка OK с row_index is not None, у строки DETAIL без row_index — None; статус —
document_status с layout_found = any(row_index is not None), hard_failures из Evaluation, остальное из
StoredForm. line_status, суммы и коды не меняются. DISCLOSURE — checks пустые, form_variant = None,
edition = edition_by_date(effective_date), DONE.
- Step 1: Write the failing test
from dataclasses import replace
from datetime import date
from decimal import Decimal as D
from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, recompute
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, DocumentType, Edition, FormVariant, LineStatus, PeriodKind
OK, DETAIL = LineStatus.OK, LineStatus.DETAIL
COL = StoredColumn("c1", 2, date(2025, 9, 30), PeriodKind.ABSOLUTE)
def sl(key: str, row: int | None, code: str | None, amount: str | None, *, status: LineStatus = OK,
conf: float = 100.0, code_fixed: bool = False, value_conf: float = 100.0, value_fixed: bool = False,
recognized: bool = True) -> StoredLine:
value = StoredValue("c1", None if amount is None else D(amount), amount, value_conf, recognized, value_fixed)
return StoredLine(key, row, status, code, conf, None, code_fixed, None, (value,))
def form(*lines: StoredLine, document_type: DocumentType = DocumentType.BALANCE) -> StoredForm:
return StoredForm(document_type, date(2025, 9, 30), True, True, (), False, (), (COL,), tuple(lines))
def test_recompute_after_edit(rules: Rules) -> None:
result = recompute(form(
sl("l1210", 0, "1210", "100"),
sl("l1230", 1, "1230", "300", value_conf=60.0, value_fixed=True),
sl("l1250", 2, "1250", "50", conf=40.0, code_fixed=True),
sl("l1260", None, "1260", "50"),
sl("ldet", 3, None, "7", status=DETAIL, value_conf=60.0),
sl("l1200", 4, "1200", "500"),
), rules)
# 1200 = 100 + 300 + 50 + 50 (с 1250 и строкой правки 1260) — сходится; 1600 нет — «пустая цель»
assert [(f.relation, f.column_key, f.column_index) for f in result.checks.relations_failed] == [
("1600 = 1100 + 1200", "c1", 2)
]
assert result.value_needs_review[("l1230", "c1")] is False
assert result.value_needs_review[("ldet", "c1")] is False
assert result.line_needs_review["l1250"] is False
assert result.detail_parents == {"ldet": "l1250"}
assert (result.edition, result.form_variant, result.status) == (Edition.FSBU4, FormVariant.FULL,
DocStatus.NEEDS_CHECK)
def test_code_change_switches_variant(rules: Rules) -> None:
simplified = form(sl("a", 0, "1150", "50"), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"),
sl("d", 3, "1700", "50"))
assert recompute(simplified, rules).form_variant is FormVariant.SIMPLIFIED
edited = form(sl("a", 0, "1100", "50", code_fixed=True), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"),
sl("d", 3, "1700", "50"))
result = recompute(edited, rules)
assert result.form_variant is FormVariant.FULL
assert result.status is DocStatus.NEEDS_CHECK
def test_accepted_and_empty_edit_line(rules: Rules) -> None:
broken = form(sl("a", 0, "1600", "100"), sl("b", 1, "1700", "90"), sl("e", None, "1220", None))
assert recompute(broken, rules).status is DocStatus.NEEDS_CHECK
assert recompute(replace(broken, accepted=True), rules).status is DocStatus.DONE
- Step 2: Run test to verify it fails
Run: .venv/bin/pytest arm_statements/tests/test_recompute.py -v
Expected: FAIL — ImportError: cannot import name 'StoredColumn'
-
Step 3: Implement
recompute.pyпо правилам выше; экспорт в__init__.py. Строки сортируются по(row_index is None, row_index)доevaluate;evaluate(..., accepted=form.accepted). -
Step 4: Run tests
Run: make check
Expected: всё зелёное.
- Step 5: Commit
git -C "$R" add arm_statements
git -C "$R" commit -m "feat: точка входа пересчёта формы по сохранённым данным (правки, подтверждения, строки правки)"
Task 19: Golden на обезличенных входах (Review Focus), README, итоговая проверка
Files:
- Create:
$R/arm_statements/tests/golden/{aep3_balance,aep3_income,glued_balance,kody_title_balance,npz_income}.candidate.jsonи одноимённые*.expected.json - Create:
$R/arm_statements/tests/test_golden.py - Modify:
$R/README.md(раздел «Публичный API» — копия раздела этого плана; «Как менять словари»; «golden-update») - Modify:
$R/arm_statements/__init__.py(окончательный__all__)
Interfaces:
- Consumes:
normalize_candidate,to_jsonable,candidate_from_dict,builders. - Produces: ничего нового для других задач.
Кандидаты собираются один раз скриптом в теле теста-генератора (builders → to_jsonable → файл) и дальше лежат
файлами; названия, ИНН и адреса — обезличенные, суммы — синтетические, но мусорный текст ячеек — дословно из fixtures
(Review Focus). Эталон *.expected.json — to_jsonable(normalize_candidate(...)); при GOLDEN_UPDATE=1 тест
переписывает эталоны и падает с напоминанием просмотреть diff; без переменной — сравнивает. Ключевые факты каждого
входа проверяются явными assert (эталон ловит дрейф, assert — смысл).
- Step 1: Write the failing test
import json
import os
from decimal import Decimal as D
from pathlib import Path
import pytest
from arm_statements import normalize_candidate
from arm_statements.loader import Rules
from arm_statements.model import DocStatus, LineStatus, StatementDocument
from arm_statements.serialize import candidate_from_dict, to_jsonable
GOLDEN = Path(__file__).parent / "golden"
FULL_CONFIDENCE = 100.0
NAMES = ["aep3_balance", "aep3_income", "glued_balance", "kody_title_balance", "npz_income"]
def run(name: str, rules: Rules) -> StatementDocument:
data = json.loads((GOLDEN / f"{name}.candidate.json").read_text(encoding="utf-8"))
result = normalize_candidate(candidate_from_dict(data), rules)
assert result.document is not None
return result.document
@pytest.mark.parametrize("name", NAMES)
def test_golden_snapshot(name: str, rules: Rules) -> None:
actual = to_jsonable(run(name, rules))
target = GOLDEN / f"{name}.expected.json"
if os.environ.get("GOLDEN_UPDATE") == "1":
target.write_text(json.dumps(actual, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pytest.fail(f"эталон {target.name} пересобран — просмотрите diff и запустите без GOLDEN_UPDATE")
assert actual == json.loads(target.read_text(encoding="utf-8"))
def test_aep3_junk_codes(rules: Rules) -> None: # Review Focus 1
doc = run("aep3_balance", rules)
by_text = {ln.line_code_text: ln for ln in doc.lines}
assert (by_text["| 140"].line_status, by_text["| 140"].line_code) == (LineStatus.OK, "1140")
confidence = by_text["| 140"].line_code_confidence
assert confidence is not None and confidence < FULL_CONFIDENCE
for junk in ("НИ", "изо :"):
assert by_text[junk].line_status is LineStatus.DETAIL and by_text[junk].parent_ref is not None
refs = [ln.ref for ln in doc.lines]
assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines)))
def test_aep3_dirty_income_amounts(rules: Rules) -> None: # Review Focus 2
doc = run("aep3_income", rules)
values = {v.text: v for ln in doc.lines for v in ln.values if v.text}
assert (values["(7744536}"].amount, values["(7744536}"].needs_review) == (D("-7744536"), False)
assert (values["211(1 |"].amount, values["211(1 |"].needs_review) == (None, True)
assert (values["42831 1"].amount, values["42831 1"].needs_review) == (D("428311"), False)
assert doc.status is DocStatus.NEEDS_CHECK
def test_glued_rows(rules: Rules) -> None: # Review Focus 3
doc = run("glued_balance", rules)
split = [ln for ln in doc.lines if "#" in ln.ref]
assert [(ln.line_code, ln.values[0].amount) for ln in split] == [("1500", D("917775")), ("1700", D("2371333"))]
assert [w.code for w in doc.warnings if w.code == "ROW_SPLIT"] == ["ROW_SPLIT"]
assert not [v for ln in doc.lines for v in ln.values if v.needs_review]
def test_kody_title_block(rules: Rules) -> None: # Review Focus 4
doc = run("kody_title_balance", rules)
assert "12267" not in [ln.line_code for ln in doc.lines]
assert doc.header.okopf.value == "12267" and doc.status is DocStatus.DONE
def test_npz_continuation_single_code(rules: Rules) -> None: # Review Focus 5
doc = run("npz_income", rules)
(line_2400,) = [ln for ln in doc.lines if ln.line_code == "2400"]
assert [v.column_index for v in line_2400.values] == [c.column_index for c in doc.columns]
assert all(v.amount is not None for v in line_2400.values)
assert "," in doc.locator and not [w for w in doc.warnings if w.code.startswith("SEGMENT_")]
assert doc.status is DocStatus.DONE
- Step 2: Build golden inputs
Временный генератор (в arm_statements/tests/test_golden.py не остаётся; в коммит не идёт): собрать пять кандидатов
builders и записать to_jsonable(...) в golden/<name>.candidate.json. Содержание:
aep3_balance— OCR ББ на бланке 66н (шапка с ИНН «3|1 21 2|» уверенностью 80 и организацией «НКО «Пример»»), строки 1110, 1150, «| 140» (уверенность 100), «НИ» с суммой, «изо :» с'3'уверенностью 0, 1100, 1210…1200, 1600, раздел III НКО (повтор 1310…1300), 1700; суммы сходятся.aep3_income— OCR ОФР: 2110, 2120"(7744536}", 2100 сходится по модулю; 2210"(100)", 2220"(200)", 2200; строка 2310 с"211(1 |"; 2320"42831 1"и 2300 так, что2300 = …сходится только при 428311 (ячейка 2310null— член пуст, соотношение всё равно посчитано: подобрать 2300 = сумма прочих членов).glued_balance— OCR ББ: …, строка с кодом"1500\n1700", суммами"917 775\n2 371 333", наименованием"Итого по разделу V\nБАЛАНС"; 1510 + 1520 = 917775, 1300 + 1400 + 1500 = 2371333, 1600 = 2371333.kody_title_balance— Excel ББ одного листа «Баланс»: титульный блок справа (столбец F: «Коды», 0710001, 30 | 09 | 2025, ОКПО, ИНН 7700000000, ОКОПФ 12267, ОКФС 16, ОКЕИ 384), таблица без подписи «Код» в столбце C (≥ 6 кодов), шапкаHeaderCellsиз адаптера (организация, ИНН, дата, ОКОПФ «12267», ОКЕИ «384» без текста единиц).npz_income— Excel ОФР на листах «ОФР» (шапка, «Код» в столбце D, 2110…2300, 2410, 2460) и «ОФР_2» (без шапки, одна строка 2400 с кодом в столбце B и суммами в C, D); суммы сходятся,2400 = 2300 + 2410 + 2460точно.
Затем GOLDEN_UPDATE=1 .venv/bin/pytest arm_statements/tests/test_golden.py -k snapshot и просмотр каждого
*.expected.json глазами против Review Focus.
- Step 3: Run tests to verify state
Run: .venv/bin/pytest arm_statements/tests/test_golden.py -v
Expected: PASS всех тестов. Если факт-тест падает — это находка в коде задач 8–17, а не повод править тест: чинить
код, пересобрать эталон, повторить.
- Step 4: README и
__all__
__init__.py экспортирует ровно имена раздела «Публичный API пакета» ниже; тест test_public_api в
arm_statements/tests/test_golden.py не нужен — добавить в arm_statements/tests/test_model.py:
import arm_statements
PUBLIC = {
"Source", "DocumentType", "FormVariant", "Edition", "PeriodKind", "LineStatus", "Severity", "DocStatus",
"EnvelopeStatus", "SignForm", "CellValue", "Cell", "Evidence", "HeaderCells", "HEADER_FIELDS", "StatementWarning",
"FormCandidate", "HeaderField", "DateField", "StatementHeader", "StatementColumn", "StatementValue",
"StatementLine", "FailedRelation", "StatementChecks", "StatementDocument", "CandidateResult",
"NormalizationResult", "Rules", "RulesError", "load_rules", "default_rules", "normalize_candidate", "normalize",
"parse_header", "type_hint", "continuation_type", "sheet_joins", "parse_code", "parse_amount", "parse_date",
"fix_lookalikes", "make_warning", "StatusInput", "StatusLine", "StatusValue", "document_status",
"envelope_status", "StoredForm", "StoredColumn", "StoredLine", "StoredValue", "StoredWarning", "RecomputeResult",
"recompute", "to_jsonable", "candidate_from_dict", "statements_version",
}
def test_public_api() -> None:
assert set(arm_statements.__all__) == PUBLIC
assert all(hasattr(arm_statements, name) for name in PUBLIC)
README.md: «Публичный API» (копия раздела ниже), «Как менять словари» (PR сюда; перечень кодов — только из docs,
байтовой копией; новый код предупреждения — statement_warnings.yaml + таблица §11.9 docs), «golden-update».
- Step 5: Final verification
Run: make check
Expected: ruff, format, mypy — без ошибок; все тесты зелёные; git -C "$R" status чист после коммита; файла
arm_statements/VERSION нет.
- Step 6: Commit
git -C "$R" add README.md arm_statements
git -C "$R" commit -m "test: golden на обезличенных входах aep_3, склейки, блока «Коды» 1С и листа-продолжения НПЗ; публичный API"
Публичный API пакета
Импорт — from arm_statements import …. Всё, что не перечислено, — внутреннее (может меняться без координации с
хостами). Все dataclass-ы — frozen=True, slots=True; последовательности — tuple.
Перечисления (StrEnum, значение — строка контракта)
| Имя | Значения |
|---|---|
Source |
OCR = "ocr", EXCEL = "excel" |
DocumentType |
BALANCE, INCOME, DISCLOSURE |
FormVariant |
FULL = "full", SIMPLIFIED = "simplified" |
Edition |
RAS_66N = "66n", FSBU4 = "fsbu4" |
PeriodKind |
ABSOLUTE = "absolute", CURRENT = "relative:current", PREV_YEAR_END = "relative:prev_year_end", PREV_PREV_YEAR_END = "relative:prev_prev_year_end", PREV_PERIOD = "relative:prev_period", NONE = "none" |
LineStatus |
OK = "ok", UNREADABLE = "unreadable", MISSING = "missing", DETAIL = "detail" |
Severity |
ERROR = "error", INFO = "info" |
DocStatus |
DONE = "done", NEEDS_CHECK = "needs_check" |
EnvelopeStatus |
DONE = "done", NEEDS_CHECK = "needs_check", ERROR = "error" |
SignForm |
PARENTHESES = "parentheses", MINUS = "minus", UNSIGNED = "unsigned" |
Вход шага 1 (строит адаптер хоста)
CellValue = Decimal | date | bool | None
Cell(text: str, value: CellValue, confidence: float, ref: str)
# text — как показывает источник; value — у OCR всегда None, у Excel — по числовому формату (Decimal), дата или bool;
# confidence 0–100 (Excel — 100); ref — OCR "row:<row_index ИС>", Excel "<лист>!<A1>" по правилу Excel
Evidence(okud: Cell | None = None, title: Cell | None = None)
HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None)
HEADER_FIELDS: tuple[str, ...] # имена полей HeaderCells по порядку
StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (),
column_index: int | None = None, field: str | None = None, locator: str | None = None)
FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells,
rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (),
warnings: tuple[StatementWarning, ...] = ())
# rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0);
# locator: "file:<имя json>;table:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу
# (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …)
Выход шагов 2–8
HeaderField(text: str | None, confidence: float | None, value: str | None)
DateField(text: str | None, confidence: float | None, value: date | None)
StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField,
okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField,
address: HeaderField, needs_review_fields: tuple[str, ...] = ())
# контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence
StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None,
period_kind: PeriodKind)
StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None,
confidence: float, is_recognized: bool, needs_review: bool = False)
StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None,
line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None,
parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])
# values — по всем columns документа, в их порядке
FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...],
expected: Decimal, actual: Decimal)
# сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key
StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])
StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str,
header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...],
checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])
# checks is None ⇔ lines == (); form_variant is None ⇔ DISCLOSURE (у BALANCE/INCOME без строк — FULL, P1-19)
CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])
NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])
Функции
normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult
# шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER)
normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult
# по порядку; исключение кандидата не перехватывается — хост ловит его и пишет TABLE_PARSE_ERROR
document_status(data: StatusInput) -> DocStatus
envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus
# ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост
recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult
parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader
type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None
continuation_type(text: str | None, rules: Rules) -> DocumentType | None # маркер «Форма 0710001 с.2»
sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int,
form_amount_columns: int, rules: Rules) -> bool # лист без маркера и шапки
parse_code(text: str | None, rules: Rules) -> CodeParse # CodeParse(code: str | None, fixed: bool)
parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse
# AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None)
parse_date(text: str | None, rules: Rules) -> date | None
fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str
make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None,
field: str | None = None, locator: str | None = None) -> StatementWarning
# уровень — из словаря statement_warnings.yaml; хосты создают JSON_FAILED, TABLE_PARSE_ERROR, FILE_TYPE_MISMATCH,
# XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED только через неё
to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение
candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate # обратная для кандидата
statements_version(path: Path | None = None) -> str | None # содержимое api/arm_statements/VERSION копии
load_rules(directory: Path | None = None) -> Rules # RulesError при ошибке словаря
default_rules() -> Rules # кэш словарей пакета
CodeParse и AmountParse в __all__ не входят (возвращаются функциями; при необходимости аннотаций —
arm_statements.codes.CodeParse, arm_statements.amounts.AmountParse).
Статус
StatusLine(line_status: LineStatus, needs_review: bool)
StatusValue(line_status: LineStatus, needs_review: bool) # line_status — статус строки значения
StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool,
needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...],
hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])
# needs_review на входе уже учитывает правки и подтверждения; значения строк DETAIL функция игнорирует
Пересчёт по сохранённым данным (ядро)
StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)
StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None,
is_recognized: bool, value_fixed: bool)
# text — source_text (у значения правки None); value_fixed — is_modified или is_confirmed
StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None,
line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None,
values: tuple[StoredValue, ...])
# row_index None — строка правки (в §7 и варианте — пока есть сумма; в структуре и родителях detail — нет);
# code_fixed — line_code_modified или is_confirmed строки
StoredWarning(code: str, severity: Severity)
StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool,
period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool,
warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])
RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks,
line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool],
detail_parents: Mapping[str, str | None], status: DocStatus)
# value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL
Словари (Rules) — что нужно хостам
Rules.headers.labels / auditor / legal_form / form_titles / okud / continuation — метки шапки и заголовки
для адаптера Excel (план 3); Rules.code_orders.sequence(edition, form, variant) — перечень кодов редакции (ядру —
порядок и состав строк формы, план 4); Rules.lookalikes — буквы-двойники; Rules.warnings — уровни предупреждений.
Решения автора плана без обсуждения (P1-n)
Контроллер переносит их в design.md docs, раздел ## Decisions made without discussion (дата 18.10.2026).
-
P1-1. Конфиг линтеров пакета — только в корневом
pyproject.tomlрепозиторияarm-statements(в деревоarm_statements/не входит и в хост не копируется):ruff.tomlвнутриarm_statements/по иерархии ruff перекрыл бы конфиг хоста, а спека требует «конфиг хоста применяется к копии». Код пакета написан под самый строгий конфиг, так что он проходит любой хост. Цена: «конфиг линтеров вместе с копией» (формулировка спеки) читается как «код, проходящий конфиг», а не как файл; см. O-1. -
P1-2. Версии инструментов пакета — пины scan-gateway (
ruff==0.16.10,mypy==2.4.0,pytest==9.1.1): это единственный хост с явными пинами; у tabular-parser и arm-core>=, в их окружениях встречаются ruff 0.16.10–0.17.1. Цена: новая версия ruff в хосте может найти новое замечание в копии; см. O-2. -
P1-3.
PLR2004в тестах пакета не отключается:per-file-ignoresхостов (tests/**у scan-gateway) на копиюapi/arm_statements/tests/не распространяются. Цена: числа в тестах — кортежами иD("…"). -
P1-4. Ничья редакций при
effective = None—edition = None, неуверенный код подтверждён только при подтверждении в обеих редакциях. Цена: лишняя подсветка кода у формы без даты (она и такneeds_check—PERIOD_UNKNOWN). -
P1-5. «Книга xlsb без числовых форматов» шаг 4 узнаёт по предупреждению адаптера
XLSB_NO_FORMATSвFormCandidate.warnings— отдельного поля у кандидата спека не задаёт. Цена: адаптер Excel обязан класть это предупреждение в кандидат каждой формы книги xlsb. -
P1-6. У
DISCLOSUREпредупреждения шапки не выдаются, статус всегдаdone(спека: «DISCLOSURE—doneбез условий»). Цена: пустая шапка пояснений не видна предупреждением. -
P1-7. Код Excel из текстовой ячейки с заменой двойника — неуверенный (≤ 99) и проходит структурную проверку, как OCR; «у Excel
needs_reviewпо структуре не ставится» прочитано как «у чистых кодов Excel» (уверенность 100). Цена: редкий текстовый код «I1О0» в Excel может подсветиться. -
P1-8. Строка правки (
row_index = None) у ядра участвует в §7 и в выборе варианта, только если у неё есть сумма; в структурной последовательности и как родительdetail— нет (позиции в файле нет). Цена: код правки не служит якорем для подтверждения соседних неуверенных кодов. -
P1-9. Двухстрочный заголовок склеивается, когда подпись верхней строки отдельно — не период, а «верхняя + нижняя» — период («На 30 сентября» + «2025 г.»; нижняя «2025 г.» сама по себе дала бы ошибочное 31.12.2025). Цена: заголовок в два ряда без даты не склеивается (колонка всё равно остаётся по числам); если над строкой заголовка стоит посторонний текст, который вместе с нижней подписью случайно читается как период, подпись склеится.
-
P1-10. Текстовая сумма без пробелов длиннее 16 целых цифр —
amount = null,string_value= текст,is_recognized = false, безVALUE_OUT_OF_RANGE(предупреждение — только для числовогоvalueExcel, как в спеке). -
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.0form_variantобязателен иnullтолько уDISCLOSURE. -
P1-20. При
accepted(у ядра) правило «needs_reviewу всех ячеек несошедшегося жёсткого соотношения» не применяется: иначе акцепт снимал бы нарушение со статуса, а подсвеченные им ячейки держали быneeds_checkчерез условие «нет значений сneeds_review» (спека: «accepted⇒ жёсткие нарушения статус не держат»). У сервисаacceptedвсегда ложно. Цена: после акцепта ячейки несошедшегося соотношения не подсвечиваются (нарушение видно вchecks).
Открытые вопросы (O-n)
- O-1. Спека: копия лежит «вместе с … конфигом линтеров»; design Р-1: «конфиг пакета подключается к
pytestиruffхоста как include». План (P1-1) не кладёт конфиг в дерево пакета. Нужен ли хостам файл конфига пакета (и как он подключается без перекрытия конфига хоста) — решается в плане 2 (sync-statements,quality-checks). - O-2. Версии ruff/mypy в хостах разные (tabular-parser и arm-core —
>=, scan-gateway —==0.16.10);ruff format --checkкопии на другой версии ruff может разойтись со стилем пакета. Предложение для плана 2: хосты закрепляют те же версии, что пакет. - O-3.
pytestхостов:norecursedirs/testpathsдолжны включатьarm_statements/tests(спека требует), аpytest.iniхоста задаётenv(APP_ENV=test) — тестам пакета он не нужен и не мешает; подтверждение — в плане 2. - O-4. Dockerfile хостов должен копировать
api/arm_statements/rules/*.yamlв образ (словари читаются с диска) — в план 2 или планы сервисов.