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