From ca8615eab10dfef85b677a08c67fa26c6d88367c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:54:04 +0400 Subject: [PATCH] =?UTF-8?q?plan:=20=D0=A1=D1=80=D0=B5=D0=B7=202b=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=91=D0=91?= =?UTF-8?q?/=D0=9E=D0=A4=D0=A0=20=D0=B8=D0=B7=20Excel,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20v1.2=20(17=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit План superpowers по спеке change slice-2b-statement-parser: контракт v1.2 в docs/парсере/копиях, словари форм, columns/detect/header/lines/checks/merge, BookParser, границы периода ОСВ, синтетика st01…st05, манифест и golden, интеграция, документы, завершение. Refs #26 Co-Authored-By: Claude Fable 5.1 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- .../2026-10-06-slice-2b-statement-parser.md | 3585 +++++++++++++++++ 1 file changed, 3585 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md diff --git a/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md new file mode 100644 index 0000000..2878e1b --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-2b-statement-parser.md @@ -0,0 +1,3585 @@ +# Срез 2b — парсер ББ и ОФР из Excel, контракт v1.2 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** tabular-parser разбирает ББ (0710001) и ОФР (0710002) из книг Excel в `statementDocument` контракта v1.2 на всех 15 реальных файлах `fixtures/statements-xlsx/**` без правил под файл; контракт v1.2 раскатан на docs, arm-core, scan-gateway. + +**Architecture:** `BookParser` (книга → классификация листов → раздача) заменяет `OsvParser.parse`; `OsvParser` остаётся конвейером листа ОСВ; новый пакет `src/parser/statements/` (columns → detect → header → lines → checks → merge/statement) со словарями `rules/statement_*.yaml`, где соотношения §7 — данные. Контракт растёт MINOR-ом: `DISCLOSURE`, `statementDocument.checks`, границы периода ОСВ. + +**Tech Stack:** Python 3.11, pydantic v2, openpyxl/xlrd/pyxlsb (уже есть), PyYAML, pytest; Decimal для денег. + +**Spec:** `tabular-parser/openspec/changes/slice-2b-statement-parser/` (proposal, design D-1…D-11, specs `statement-parsing`, дельта `tabular-recognition-service`) и `docs/openspec/changes/slice-2b-statement-parser/` (design D-1…D-4, дельта `recognition-contract`). Исполнитель читает оба. + +## Рабочее окружение (читать до первой задачи) + +- Workspace `/Users/andrey.epifancev/projects/awp-ca` — не git. Worktree среза: `.wt/slice-2b/` = `tabular-parser` (ветка `feat/14-slice-2b-statement-parser`), `docs` (ветка `feat/26-slice-2b-statement-parser`), симлинки `fixtures`, `.claude`, `CLAUDE.md`. Для задачи 3 добавляются worktree `.wt/slice-2b/arm-core` и `.wt/slice-2b/scan-gateway` (создаёт задача 3). +- Все пути ниже — от `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-2b/tabular-parser`, если не сказано иное; `T` = этот каталог, `API` = `T/api`, `DOCS` = `.wt/slice-2b/docs`, `FX` = `.wt/slice-2b/fixtures` (симлинк на основной `fixtures/` — для golden и синтетики создаётся worktree `feat/14-statements-golden`, см. задачу 14). +- Никаких `cd` в основные checkout'ы (`tabular-parser/`, `docs/`, `arm-core/` на main): хук guard-main смотрит на cwd. Команды: `make -C T `, `git -C T …`, pytest — `cd API && .venv/bin/pytest …` в одном Bash (cwd в worktree на ветке — допустимо) либо `API/.venv/bin/pytest -c API/pytest.ini --rootdir API …`. +- Перед задачей 1 выполнить один раз: `git -C T fetch origin && git -C T rebase origin/main` (на main к этому моменту должны быть PR агента A: #13, #15, #17 — план написан по их состоянию; если ещё не слиты, спросить владельца), `make -C T venv`, `codegraph init T`. +- Коммиты: русская тема (`feat(statements): …`, `test(statements): …`, `feat(contract): …`), тело `Refs #14`, трейлеры `Co-Authored-By: Claude <модель> ` и `Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b`. + +## Global Constraints + +- Никаких литералов координат в `src/parser/**` (кроме `grid.py`, `readers/`): букв колонок, адресов ячеек, имён листов, сравнений номера строки с числом — линтер `tests/unit/parser/test_no_coordinates.py` (spec «Словари форм, координаты, golden»). +- Словари — данные: синонимы, метки шапки, соотношения §7 — только `api/rules/statement_*.yaml`; код не содержит ни одного слова из формы (spec, design D-10). +- Деньги — `Decimal` через `numbers.to_money`; `float` в домене запрещён (config.yaml rules.design). +- Единицы не нормализуются; первичка не мутируется: `amount` как в ячейке (design D-6). +- Контракт MINOR 1.2: только необязательные поля и новые значения enum; `CONTRACT_VERSION = "1.2"`; копия `api/contracts/` байт в байт равна `docs/contracts/` (spec `tabular-recognition-service` «Копия контракта идентична источнику»). +- Порог колонки кодов 60 %, диапазоны ББ 1100–1799, ОФР 2100–2999, расшифровки 5000–5999, пятизначные — по первым четырём цифрам (design D-4, решение №3). +- Допуск соотношений ±1 в единицах формы, отсутствующий код = 0, двойной расчёт с `abs_terms` (design D-7, решение №7). +- Документы книги: ОСВ в порядке листов, затем формы в порядке листов (design D-1, решение №11). +- Golden правятся только `make golden-update` с просмотром diff и отдельным коммитом в fixtures. +- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`, `periods.yaml`, `osv_headers.yaml`) не правятся, кроме задачи 6b (границы периода в `header.py`/`osv.py`). + +## Review Focus + +1. **Код как число с дробной частью** («1110.0» из xlrd-float, Decimal `1110`): колонка кодов должна найтись. Тест — задача 7 (`test_code_from_numeric_cell`). +2. **ИНН аудитора в шапке 1С** (второй «ИНН» ниже строки «аудиторской организации»): в `header.inn` должен попасть ИНН организации. Тест — задача 9 (`test_inn_skips_auditor_block`). +3. **Расходы ОФР со знаком минус в 1С против скобок в 66н**: `2100 = 2110 − 2120` сходится в обоих написаниях. Тест — задача 11 (`test_relation_holds_with_abs_terms`). +4. **Лист-продолжение между двумя формами разного типа** (книга НПЗ: ОФР идёт после пяти листов баланса): продолжение присоединяется к последнему открытому документу, а не к первому. Тест — задача 12 (`test_continuation_joins_last_open_document`). +5. **Книга только из листов DISCLOSURE** (пояснения без ББ/ОФР): результат `done` с документами без строк, не `INVALID_DOCUMENT`. Тест — задача 12 (`test_disclosure_only_book_is_done`). + +--- + +### Task 1: Контракт v1.2 в docs — схема и примеры + +**Files:** +- Modify: `DOCS/contracts/recognition-result.schema.json` +- Modify: `DOCS/contracts/examples/result-statement-minimal.json`, `DOCS/contracts/examples/result-osv-minimal.json` +- Create: `DOCS/contracts/examples/result-statement-excel.json` +- Test: проверка валидатором из venv tabular-parser (jsonschema уже в зависимостях парсера: `API/.venv/bin/python -c "import jsonschema"`) + +**Interfaces:** +- Produces: схема v1.2 с `$defs.statementDocument.properties.checks`, `document_type.enum` + `DISCLOSURE`, `$defs.osvDocument.properties.header.properties.period_start/period_end/is_from_year_start`. + +- [ ] **Step 1: Написать проверочный скрипт (падает на старой схеме)** + +Создать `DOCS/contracts/examples/.check_v12.py`? Нет — временные файлы только в scratchpad. Скрипт — в `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/cedd7915-2efe-41e5-988c-a9117033598b/scratchpad/check_v12.py`: + +```python +import json, sys +from pathlib import Path +import jsonschema + +DOCS = Path(sys.argv[1]) +schema = json.loads((DOCS / "contracts/recognition-result.schema.json").read_text(encoding="utf-8")) +defs = schema["$defs"] +assert "DISCLOSURE" in defs["statementDocument"]["properties"]["document_type"]["enum"] +checks = defs["statementDocument"]["properties"]["checks"] +assert checks["type"] == "object" +assert set(checks["properties"]) == {"relations_failed", "soft_relations_failed", "duplicate_line_codes"} +head = defs["osvDocument"]["properties"]["header"]["properties"] +assert head["period_start"]["format"] == "date" and head["is_from_year_start"]["type"] == ["boolean", "null"] +assert schema["properties"]["schema_version"]["description"].endswith('"1.2"') +validator = jsonschema.Draft202012Validator(schema) +for example in sorted((DOCS / "contracts/examples").glob("result-*.json")): + if "message" in example.name: + continue + validator.validate(json.loads(example.read_text(encoding="utf-8"))) + print("ok", example.name) +``` + +- [ ] **Step 2: Запустить, убедиться, что падает на `DISCLOSURE`** + +Run: `API/.venv/bin/python -I /check_v12.py DOCS` +Expected: `AssertionError` на первой проверке. + +- [ ] **Step 3: Изменить схему** + +В `$defs.statementDocument.properties.document_type`: `"enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]`. После `lines` добавить: + +```json +"checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе»): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } +} +``` + +В `$defs` добавить: + +```json +"failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } +} +``` + +В `$defs.osvDocument.properties.header.properties` после `is_cumulative`: + +```json +"period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, +"period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, +"is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" } +``` + +`title`: заменить «v1.1» на «v1.2»; `properties.schema_version.description`: «…производитель контракта v1.2 пишет "1.2"». `$comment` не трогать. + +- [ ] **Step 4: Примеры** + +`result-statement-minimal.json`: `"schema_version": "1.2"`; в документ после `lines` — `"checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] }`. +`result-osv-minimal.json`: `"schema_version": "1.2"`; в `header` после `is_cumulative` — `"period_start": "2025-01-01", "period_end": "2025-03-31", "is_from_year_start": true` (подобрать под `period_text` примера). +Новый `result-statement-excel.json`: + +```json +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} +``` + +- [ ] **Step 5: Проверить** + +Run: `API/.venv/bin/python -I /check_v12.py DOCS` +Expected: `ok result-error.json`, `ok result-osv-minimal.json`, `ok result-osv-needs-check.json`, `ok result-statement-excel.json`, `ok result-statement-minimal.json`. + +- [ ] **Step 6: Commit (docs)** + +```bash +git -C DOCS add contracts +git -C DOCS commit -m "feat(contract): v1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ, примеры + +Refs #26" +``` + +--- + +### Task 2: Контракт v1.2 в tabular-parser — модели, копия схемы, версия + +**Files:** +- Modify: `API/src/contract/result.py` (StatementDocument, OsvHeader, новые модели), `API/src/contract/common.py:30` (`CONTRACT_VERSION`) +- Copy: `DOCS/contracts/*.json` и `examples/*.json` → `API/contracts/` (cp, не править руками) +- Modify: `API/tests/result_contract.py`? — нет; тест примеров уже перебирает каталог. Обновить ожидания `"1.1"` → `"1.2"`: `grep -rn '"1\.1"' API/tests` (test_worker, test_roundtrip, test_cli и т. п.) +- Test: `API/tests/unit/test_contract_v12.py` + +**Interfaces:** +- Produces: `FailedRelation(column_index, relation, expected, actual)`, `StatementChecks(relations_failed, soft_relations_failed, duplicate_line_codes)`, `StatementDocument.checks: StatementChecks | None`, `StatementDocument.document_type` с `DISCLOSURE`, `OsvHeader.period_start/period_end: date | None`, `OsvHeader.is_from_year_start: bool | None`, `CONTRACT_VERSION == "1.2"`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/test_contract_v12.py +"""Контракт v1.2: новые необязательные поля и значение DISCLOSURE (docs change slice-2b design D-1).""" + +import datetime +from decimal import Decimal + +from src.contract.common import CONTRACT_VERSION +from src.contract.result import FailedRelation, OsvHeader, StatementChecks, StatementDocument, StatementHeader + + +def test_contract_version_is_1_2() -> None: + assert CONTRACT_VERSION == "1.2" + + +def test_statement_document_accepts_disclosure_and_checks() -> None: + document = StatementDocument( + document_type="DISCLOSURE", + locator="sheet:Пояснения", + header=StatementHeader(form_name="Пояснения"), + columns=[], + lines=[], + ) + assert document.checks is None + checked = StatementDocument.model_validate( + { + "document_type": "BALANCE", + "locator": "sheet:ББ", + "header": {}, + "columns": [], + "lines": [], + "checks": { + "relations_failed": [{"column_index": 0, "relation": "1600 = 1100 + 1200", "expected": "10.00", "actual": "9.00"}], + "soft_relations_failed": [], + "duplicate_line_codes": ["1110"], + }, + } + ) + assert checked.checks == StatementChecks( + relations_failed=[FailedRelation(column_index=0, relation="1600 = 1100 + 1200", expected=Decimal("10.00"), actual=Decimal("9.00"))], + soft_relations_failed=[], + duplicate_line_codes=["1110"], + ) + wire = checked.model_dump(mode="json", by_alias=True, exclude_none=True) + assert wire["checks"]["relations_failed"][0]["expected"] == "10.00" + + +def test_osv_header_period_bounds_are_optional_dates() -> None: + header = OsvHeader(organization_name="ООО", account_code_raw="60", period_text="1 квартал 2025 г.") + assert header.period_start is None and header.is_from_year_start is None + filled = OsvHeader.model_validate( + {"organization_name": "ООО", "account_code_raw": "60", "period_text": "x", "period_start": "2024-10-01", "period_end": "2025-09-30", "is_from_year_start": False} + ) + assert filled.period_start == datetime.date(2024, 10, 1) + assert filled.period_end == datetime.date(2025, 9, 30) +``` + +- [ ] **Step 2: Запустить — падает (`ImportError: FailedRelation`)** + +Run: `cd API && .venv/bin/pytest tests/unit/test_contract_v12.py -q` + +- [ ] **Step 3: Реализация** + +`API/src/contract/common.py:30`: `CONTRACT_VERSION = "1.2"`. +`API/src/contract/result.py`: в `OsvHeader` после `is_cumulative`: + +```python + period_start: ContractDate | None = None # начало периода по шапке (v1.2, OQ-131) + period_end: ContractDate | None = None + is_from_year_start: bool | None = None # period_start — 1 января; is_cumulative не переопределяет +``` + +Перед `StatementDocument`: + +```python +class FailedRelation(ContractModel): + """Нарушенное соотношение §7 по одной колонке: `expected` — левая часть как в файле, `actual` — правая по строкам.""" + + column_index: IntLike + relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + expected: Money + actual: Money + + +class StatementChecks(ContractModel): + """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" + + relations_failed: list[FailedRelation] = Field(default_factory=list) + soft_relations_failed: list[FailedRelation] = Field(default_factory=list) + duplicate_line_codes: list[str] = Field(default_factory=list) +``` + +В `StatementDocument`: `document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"]` и после `lines`: `checks: OptionalNotNull[StatementChecks] = None`. В `__all__` добавить `FailedRelation`, `StatementChecks`. Docstring модуля: «v1.2». + +Скопировать схемы: `cp DOCS/contracts/*.schema.json API/contracts/ && cp DOCS/contracts/examples/*.json API/contracts/examples/`. Обновить таблицу примеров в тесте контракта парсера, если она есть (`grep -n "result-statement-minimal" API/tests -r`): добавить `result-statement-excel.json`. + +Заменить ожидания версии: `grep -rn '"1\.1"' API/tests API/src` → в `test_worker`, `tests/integration/test_roundtrip.py:301`, docstrings — `"1.2"`. + +- [ ] **Step 4: Проверить** + +Run: `make -C T check` +Expected: зелёный, включая тест идентичности копии схемы и все примеры. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/contract api/contracts api/tests +git -C T commit -m "feat(contract): модели v1.2 — DISCLOSURE, StatementChecks, границы периода ОСВ; копия схемы + +Refs #14" +``` + +--- + +### Task 3: Копии схемы v1.2 в arm-core и scan-gateway (два bounded-PR без изменения поведения) + +**Files (arm-core, worktree `.wt/slice-2b/arm-core`, ветка `chore/contract-v1-2`):** +- Copy: `DOCS/contracts/**` → `arm-core/api/contracts/` +- Modify: `arm-core/api/src/contract/result.py` (те же правки, что в задаче 2: `OsvHeader` + 3 поля, `FailedRelation`, `StatementChecks`, `StatementDocument.document_type` + `DISCLOSURE`, `checks`), `arm-core/api/tests/test_contract.py` (таблица примеров: добавить `result-statement-excel.json`) +- Не менять: `CONTRACT_VERSION` ядра (ядро — потребитель), `services/result_mapping.py`. + +**Files (scan-gateway, worktree `.wt/slice-2b/scan-gateway`, ветка `chore/contract-v1-2`):** +- Copy: `DOCS/contracts/**` → `scan-gateway/api/contracts/` +- Modify: `scan-gateway/api/src/contract/result.py` (то же), `scan-gateway/api/tests/unit/test_contract.py` (таблица примеров) +- Не менять: `CONTRACT_VERSION = "1.0"` scan-gateway (он не пишет новых полей). + +- [ ] **Step 1: Worktree и маркер bounded** + +```bash +W=/Users/andrey.epifancev/projects/awp-ca +git -C $W/arm-core fetch origin && git -C $W/arm-core worktree add -b chore/contract-v1-2 $W/.wt/slice-2b/arm-core origin/main +git -C $W/scan-gateway fetch origin && git -C $W/scan-gateway worktree add -b chore/contract-v1-2 $W/.wt/slice-2b/scan-gateway origin/main +touch $W/.wt/slice-2b/arm-core/.bounded $W/.wt/slice-2b/scan-gateway/.bounded +make -C $W/.wt/slice-2b/arm-core venv; make -C $W/.wt/slice-2b/scan-gateway venv +``` + +- [ ] **Step 2: Тест (в каждом репозитории) — копия и модель принимают пример v1.2** + +В `arm-core/api/tests/test_contract.py` и `scan-gateway/api/tests/unit/test_contract.py` таблица примеров получает строку для `result-statement-excel.json` (как у `result-statement-minimal.json`). Запустить тесты контракта — падают: файла нет в `api/contracts/examples`, `DISCLOSURE` не в Literal. + +Run (arm-core): `cd $W/.wt/slice-2b/arm-core/api && .venv/bin/pytest tests/test_contract.py -q` +Run (scan-gateway): `cd $W/.wt/slice-2b/scan-gateway/api && .venv/bin/pytest tests/unit/test_contract.py -q` + +- [ ] **Step 3: Скопировать схемы и примеры, внести правки моделей (идентичные задаче 2, шаг 3, кроме `CONTRACT_VERSION`)** + +```bash +cp $W/.wt/slice-2b/docs/contracts/*.schema.json $W/.wt/slice-2b/arm-core/api/contracts/ +cp $W/.wt/slice-2b/docs/contracts/examples/*.json $W/.wt/slice-2b/arm-core/api/contracts/examples/ +cp $W/.wt/slice-2b/docs/contracts/*.schema.json $W/.wt/slice-2b/scan-gateway/api/contracts/ +cp $W/.wt/slice-2b/docs/contracts/examples/*.json $W/.wt/slice-2b/scan-gateway/api/contracts/examples/ +``` + +- [ ] **Step 4: Проверить — `make check` в обоих репозиториях зелёный** + +Для arm-core дополнительно: `cd $W/.wt/slice-2b/arm-core/api && .venv/bin/pytest -m "integration and not e2e" -k "mapping or contract" -q` (маппинг ОСВ по-прежнему читает только `osvDocument`; новые поля шапки игнорируются). + +- [ ] **Step 5: Commit, push, PR в каждом репозитории** + +```bash +git -C $W/.wt/slice-2b/arm-core add api/contracts api/src/contract api/tests/test_contract.py +git -C $W/.wt/slice-2b/arm-core commit -m "chore(contract): копия схемы v1.2 и модели (DISCLOSURE, checks, границы периода), без изменения логики + +Refs RSHB/arm-tabular-parser#14" +git -C $W/.wt/slice-2b/arm-core push -u origin chore/contract-v1-2 +tea pulls create --login home --repo RSHB/arm-core --base main --head chore/contract-v1-2 --title "chore(contract): копия схемы v1.2 (потребитель, без логики)" --description "Копия docs/contracts v1.2 + модели. Мержить после RSHB/arm-ka-docs#27 и до PR парсера RSHB/arm-tabular-parser#16. Refs RSHB/arm-tabular-parser#14." +``` + +То же для scan-gateway (`--repo RSHB/arm-scan-gateway`). + +--- + +### Task 4: Словари форм `rules/statement_*.yaml` и их модели + +**Files:** +- Create: `API/rules/statement_headers.yaml`, `API/rules/statement_periods.yaml`, `API/rules/statement_checks.yaml` +- Modify: `API/src/parser/rules.py` (модели `StatementHeaderRules`, `StatementPeriodRules`, `StatementCheckRules`, `RelationRule`, `Rules` + 3 поля, `load_rules`, константы имён файлов), `API/rules/README.md` +- Test: `API/tests/unit/parser/test_statement_rules.py` + +**Interfaces:** +- Produces: `Rules.statement_headers: StatementHeaderRules`, `Rules.statement_periods: StatementPeriodRules`, `Rules.statement_checks: StatementCheckRules`; `FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"]`; `StatementHeaderRules.form_type(text) -> FormType | None`, `.okud_type(text) -> FormType | None`, `.is_continuation(text) -> bool`, `.is_code_caption(text)`, `.is_name_caption(text)`, `.is_explanations_caption(text)`, `.label(key, text) -> re.Match | None`, `.is_auditor_label(text)`, `.has_legal_form(text)`, `.code_ranges: dict[FormType, tuple[int, int]]`, `.code_min_share: float`, `.min_codes: int`; `StatementPeriodRules.period_end(text) -> date | None`, `.date_triple(day, month, year) -> date | None`; `StatementCheckRules.relations_for(form) -> list[RelationRule]`, `RelationRule(form, relation, strict, abs_terms, target: str, terms: tuple[tuple[int, str], ...])`, `StatementCheckRules.tolerance: Decimal`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/test_statement_rules.py +"""Словари форм (design D-10): загрузка, обязательные ключи, разбор соотношений, периоды форм.""" + +import datetime +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from src.parser.rules import DEFAULT_RULES_DIR, STATEMENT_CHECKS_FILE, Rules, load_rules + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def test_form_type_by_title_and_okud(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.form_type("Бухгалтерский баланс") == "BALANCE" + assert headers.form_type("ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") == "INCOME" + assert headers.form_type("Отчет о прибылях и убытках") == "INCOME" + assert headers.form_type("Пояснения к бухгалтерскому балансу") == "DISCLOSURE" + assert headers.form_type("Бухгалтерская отчетность") is None # титул — не тип + assert headers.okud_type("Форма по ОКУД 0710001") == "BALANCE" + assert headers.okud_type("0710002") == "INCOME" + assert headers.okud_type("0710004") == "DISCLOSURE" + assert headers.okud_type("0710001") == "BALANCE" + + +def test_continuation_marker_and_captions(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.is_continuation("Форма 0710001 с.2") + assert headers.is_continuation("Форма 0710002 с. 2") + assert not headers.is_continuation("Бухгалтерский баланс") + assert headers.is_code_caption("Код") and headers.is_code_caption("Коды") + assert not headers.is_code_caption("Код строки по ОКУД 0710001") + assert headers.is_name_caption("Наименование показателя") + assert headers.is_explanations_caption("Пояснения") + assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)} + assert headers.code_min_share == 0.6 and headers.min_codes == 3 + + +def test_header_labels(rules: Rules) -> None: + headers = rules.statement_headers + assert headers.label("organization", "Организация:") is not None + assert headers.label("organization", "Организация (орган исполнительной власти)") is not None + assert headers.label("inn", "Идентификационный номер налогоплательщика") is not None + assert headers.label("inn", "ИНН") is not None + assert headers.label("units", "Единица измерения:") is not None + assert headers.label("date", "Дата (число, месяц, год)") is not None + assert headers.label("date", "Отчетная дата\n(число, месяц, год)") is not None + assert headers.label("address", "Местонахождение (адрес)") is not None + assert headers.label("address", "Адрес в пределах места нахождения") is not None + assert headers.label("okopf_okfs", "по ОКОПФ / ОКФС") is not None + assert headers.is_auditor_label("Наименование аудиторской организации") + assert headers.has_legal_form('ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"') and not headers.has_legal_form("Коды") + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("На 31 декабря 2024 г.", datetime.date(2024, 12, 31)), + ("на 30 сентября 2025 г.", datetime.date(2025, 9, 30)), + ("На 31 декабря \n2023 года", datetime.date(2023, 12, 31)), + ("31.12.2024", datetime.date(2024, 12, 31)), + ("За 9 месяцев 2025 г.", datetime.date(2025, 9, 30)), + ("За 9 месяцев\n2025 года", datetime.date(2025, 9, 30)), + ("За Январь - Декабрь 2024 г.", datetime.date(2024, 12, 31)), + ("за январь - сентябрь 2025 г.", datetime.date(2025, 9, 30)), + ("За 2024 г.", datetime.date(2024, 12, 31)), + ("2024", datetime.date(2024, 12, 31)), + ("Изменение, %", None), + ("Пояснения", None), + ], +) +def test_period_end(rules: Rules, text: str, expected: datetime.date | None) -> None: + assert rules.statement_periods.period_end(text) == expected + + +def test_date_triple(rules: Rules) -> None: + assert rules.statement_periods.date_triple("31", "12", "2024") == datetime.date(2024, 12, 31) + assert rules.statement_periods.date_triple("30", "9", "2025") == datetime.date(2025, 9, 30) + assert rules.statement_periods.date_triple("", "12", "2024") is None + + +def test_relations_parsed_into_terms(rules: Rules) -> None: + checks = rules.statement_checks + balance = {rule.relation: rule for rule in checks.relations_for("BALANCE")} + rule = balance["1600 = 1100 + 1200"] + assert rule.strict and rule.target == "1600" and rule.terms == ((1, "1100"), (1, "1200")) + income = {rule.relation: rule for rule in checks.relations_for("INCOME")} + gross = income["2100 = 2110 - 2120"] + assert gross.terms == ((1, "2110"), (-1, "2120")) and gross.abs_terms == ("2120",) + assert not income["2400 = 2300 - 2410 + 2420"].strict + assert checks.tolerance == Decimal("1") + assert checks.relations_for("DISCLOSURE") == [] + + +def test_bad_relation_rejected(tmp_path: Path) -> None: + for name in DEFAULT_RULES_DIR.glob("*.yaml"): + (tmp_path / name.name).write_bytes(name.read_bytes()) + broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8")) + broken["relations"].append({"form": "BALANCE", "relation": "1600 = 1100 +", "strict": True}) + (tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8") + with pytest.raises(RuntimeError, match="statement_checks.yaml"): + load_rules(tmp_path) +``` + +- [ ] **Step 2: Запустить — падает (`ImportError: STATEMENT_CHECKS_FILE`)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/test_statement_rules.py -q` + +- [ ] **Step 3: Словари** + +`API/rules/statement_headers.yaml`: + +```yaml +# Словарь форм ББ/ОФР (05-domain-rules.md §12): заголовки форм и ОКУД, маркер продолжения, подписи колонок, метки шапки. +# Правила записи — как в osv_headers.yaml: паттерн ищется в НОРМАЛИЗОВАННОМ тексте (нижний регистр, «е» вместо «ё», +# дефисы/тире/переносы -> пробел), без учёта регистра; новый синоним = строка в YAML. + +# Заголовок формы -> тип документа. Титул «Бухгалтерская отчетность» (НПЗ) типом не является и здесь не перечислен. +form_titles: + BALANCE: + - 'бухгалтерский\s+баланс' + INCOME: + - 'отчет\s+о\s+финансовых\s+результатах' + - 'отчет\s+о\s+прибылях\s+и\s+убытках' + DISCLOSURE: + - 'пояснени\w*\s+к\s+бухгалтерскому\s+балансу' + - '^расшифровк' + - 'отчет\s+об\s+изменениях\s+капитала' + - 'отчет\s+о\s+движении\s+денежных\s+средств' + - 'отчет\s+о\s+целевом\s+использовании' + +# Код ОКУД -> тип (ячейка «Форма по ОКУД 0710001» или сам код). Прочие формы 0710xxx — расшифровки (design D-3, решение №2) +okud: + BALANCE: + - '\b0710001\b' + INCOME: + - '\b0710002\b' + DISCLOSURE: + - '\b07100(0[3-9]|[1-9]\d)\b' + +# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2» +continuation: + - '\bформа\s+0710\d{3}\s+с\.?\s*\d+' + +# Подписи колонок заголовка таблицы: ячейка целиком +code_caption: + - '^код(ы)?$' +name_caption: + - 'наименование\s+показателя' +explanations_caption: + - '^пояснени' +# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число +column_numbering: '^\d{1,2}$' + +# Метки шапки документа (значение — правее в той же строке либо в следующей строке) +labels: + organization: + - '^организация\b' + inn: + - '^инн\b' + - 'идентификационный\s+номер\s+налогоплательщика' + okpo: + - 'по\s+окпо' + okved: + - 'по\s+оквэд' + okopf: + - 'по\s+окопф\b(?!\s*/)' + okfs: + - 'по\s+окфс' + okopf_okfs: + - 'по\s+окопф\s*/\s*окфс' + okei: + - 'по\s+океи' + units: + - 'единица\s+измерения' + address: + - 'местонахождение\s*\(адрес\)' + - 'адрес\s+в\s+пределах\s+места\s+нахождения' + - '^адрес\b' + date: + - '^(отчетная\s+)?дата\b' + okud_label: + - 'форма\s+по\s+окуд' + +# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации +auditor: + - 'аудитор' + +# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ) +legal_form: + - '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b' + - '"' + - '«' + +# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в колонке, +# минимум кодов, чтобы колонка считалась колонкой кодов +line_codes: + ranges: + BALANCE: [1100, 1799] + INCOME: [2100, 2999] + DISCLOSURE: [5000, 5999] + min_share: 0.6 + min_codes: 3 +``` + +`API/rules/statement_periods.yaml`: + +```yaml +# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §12 п. 4. +# Именованные группы обязательны: d (день), m (месяц числом или названием), y (год), n (число месяцев). +months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pапрел)|(?Pма[йя])|(?Pиюн)|(?Pиюл)|(?Pавгуст)|(?Pсентябр)|(?Pоктябр)|(?Pноябр)|(?Pдекабр)' +# «На 31 декабря 2024 г.», «на 30 сентября 2025 г.», «31 декабря 2023 года» (без «на» — подзаголовок формы) +on_date: '(?P\d{1,2})\s+(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «31.12.2024», «30.09.2025 г.» +numeric_date: '(?P\d{1,2})\.(?P\d{1,2})\.(?P(19|20)\d{2})' +# «За 9 месяцев 2025 г.», «за 6 мес. 2025» +for_months: '\b(?P\d{1,2})\s*мес\w*\.?\s*(?P(19|20)\d{2})' +# «За Январь - Декабрь 2024 г.», «за январь сентябрь 2025 г.» (дефис уже заменён пробелом) +for_range: '(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(по\s+)?(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «За 2024 г.», «2024 год», ячейка «2024» +for_year: '(^|\bза\s+)(?P(19|20)\d{2})(\s*(г\b|год)|\s*$)' +``` + +`API/rules/statement_checks.yaml`: + +```yaml +# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. +# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check +# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в +# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). +tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) +subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду +relations: + - {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} + - {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true} + - {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true} + - {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true} + - {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true} + - {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true} + - {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true} + - {form: BALANCE, relation: "1600 = 1700", strict: true} + - {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]} + - {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]} + - {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]} + - {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]} + - {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false} + - {form: INCOME, relation: "2410 = 2411 + 2412", strict: false} + - {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false} +``` + +- [ ] **Step 4: Модели в `rules.py`** + +Добавить после `DataTypeRules`: + +```python +STATEMENT_HEADERS_FILE = "statement_headers.yaml" +STATEMENT_PERIODS_FILE = "statement_periods.yaml" +STATEMENT_CHECKS_FILE = "statement_checks.yaml" + +FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"] +StatementLabel = Literal[ + "organization", "inn", "okpo", "okved", "okopf", "okfs", "okopf_okfs", "okei", "units", "address", "date", "okud_label" +] +_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4}(\s*[+\-]\s*\d{4})*)\s*$") +_TERM = re.compile(r"([+\-])?\s*(\d{4})") + + +class _LineCodeRules(_Model): + ranges: dict[FormType, tuple[int, int]] + min_share: float + min_codes: int + + @model_validator(mode="after") + def _check(self) -> "_LineCodeRules": + if set(self.ranges) != set(get_args(FormType)): + raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}") + if not 0 < self.min_share <= 1 or self.min_codes < 1: + raise ValueError("line_codes: min_share в (0, 1], min_codes ≥ 1") + return self + + +class StatementHeaderRules(_Model): + form_titles: dict[FormType, list[Pattern]] + okud: dict[FormType, list[Pattern]] + continuation: list[Pattern] + code_caption: list[Pattern] + name_caption: list[Pattern] + explanations_caption: list[Pattern] + column_numbering: Pattern + labels: dict[StatementLabel, list[Pattern]] + auditor: list[Pattern] + legal_form: list[Pattern] + line_codes: _LineCodeRules + + @model_validator(mode="after") + def _check_required_keys(self) -> "StatementHeaderRules": + for field_name, expected in ( + ("form_titles", set(get_args(FormType))), + ("okud", set(get_args(FormType))), + ("labels", set(get_args(StatementLabel))), + ): + missing = expected - set(getattr(self, field_name)) + if missing: + raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}") + return self + + def form_type(self, text: str) -> FormType | None: + return first_match(((pattern, kind) for kind, patterns in self.form_titles.items() for pattern in patterns), text) + + def okud_type(self, text: str) -> FormType | None: + return first_match(((pattern, kind) for kind, patterns in self.okud.items() for pattern in patterns), text) + + def is_continuation(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.continuation) + + def is_code_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.code_caption) + + def is_name_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.name_caption) + + def is_explanations_caption(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.explanations_caption) + + def label(self, key: StatementLabel, text: str) -> re.Match[str] | None: + for pattern in self.labels[key]: + found = pattern.search(text) + if found is not None: + return found + return None + + def is_auditor_label(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.auditor) + + def has_legal_form(self, text: str) -> bool: + return any(pattern.search(text) for pattern in self.legal_form) + + @property + def code_ranges(self) -> dict[FormType, tuple[int, int]]: + return dict(self.line_codes.ranges) + + @property + def code_min_share(self) -> float: + return self.line_codes.min_share + + @property + def min_codes(self) -> int: + return self.line_codes.min_codes + + +class StatementPeriodRules(_Model): + months: Pattern + on_date: Pattern + numeric_date: Pattern + for_months: Pattern + for_range: Pattern + for_year: Pattern + + @model_validator(mode="after") + def _check_groups(self) -> "StatementPeriodRules": + if self.months.group_names != _MONTH_GROUPS: + raise ValueError("months: ожидаются именованные группы m01…m12") + for name, pattern, groups in ( + ("on_date", self.on_date, {"d", "m", "y"}), + ("numeric_date", self.numeric_date, {"d", "m", "y"}), + ("for_months", self.for_months, {"n", "y"}), + ("for_range", self.for_range, {"m", "y"}), + ("for_year", self.for_year, {"y"}), + ): + missing = groups - pattern.group_names + if missing: + raise ValueError(f"{name}: нет именованных групп {sorted(missing)}") + return self + + def _month(self, text: str) -> int | None: + if text.isdigit(): + number = int(text) + return number if 1 <= number <= _MONTHS_IN_YEAR else None + found = self.months.search(text) + return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m")) + + def date_triple(self, day: str, month: str, year: str) -> datetime.date | None: + """Дата из трёх ячеек «число | месяц | год» (строки или числа как текст); невалидная — None.""" + month_number = self._month(month.strip()) if month.strip() else None + if not day.strip().isdigit() or month_number is None or not year.strip().isdigit(): + return None + try: + return datetime.date(int(year), month_number, int(day)) + except ValueError: + return None + + def period_end(self, text: str) -> datetime.date | None: + """Дата колонки/подзаголовка: «на <дата>» — она сама; «за N месяцев/за январь–март/за год» — конец периода.""" + found = self.on_date.search(text) or self.numeric_date.search(text) + if found is not None: + return self.date_triple(found.group("d"), found.group("m"), found.group("y")) + found = self.for_months.search(text) + if found is not None: + return _month_end(int(found.group("y")), int(found.group("n"))) + found = self.for_range.search(text) + if found is not None: + month = self._month(found.group("m")) + return None if month is None else _month_end(int(found.group("y")), month) + found = self.for_year.search(text) + if found is not None: + return datetime.date(int(found.group("y")), _MONTHS_IN_YEAR, 31) + return None + + +def _month_end(year: int, month: int) -> datetime.date | None: + if not 1 <= month <= _MONTHS_IN_YEAR: + return None + last = calendar.monthrange(year, month)[1] + return datetime.date(year, month, last) + + +class RelationRule(_Model): + form: FormType + relation: str + strict: bool = True + abs_terms: tuple[str, ...] = () + + @property + def target(self) -> str: + return _parse_relation(self.relation)[0] + + @property + def terms(self) -> tuple[tuple[int, str], ...]: + return _parse_relation(self.relation)[1] + + @model_validator(mode="after") + def _check(self) -> "RelationRule": + target, terms = _parse_relation(self.relation) + codes = {code for _, code in terms} + unknown = set(self.abs_terms) - codes + if unknown: + raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{self.relation}»") + if target in codes: + raise ValueError(f"«{self.relation}»: код слева встречается справа") + return self + + +def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]: + found = _RELATION.match(relation) + if found is None: + raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных кодов") + terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs"))) + return found.group("target"), terms + + +class StatementCheckRules(_Model): + tolerance: Decimal + subline_check: bool = True + relations: list[RelationRule] + + def relations_for(self, form: str) -> list[RelationRule]: + return [rule for rule in self.relations if rule.form == form] +``` + +Импорты вверху: `import calendar`, `import datetime`, `from decimal import Decimal`. `_MONTHS_IN_YEAR` и `_MONTH_GROUPS` уже есть (ветка #11). В `Rules` добавить поля `statement_headers`, `statement_periods`, `statement_checks`; в `load_rules` — три `_load(...)`. `__all__` дополнить. + +`Pattern.group_names` — есть в ветке #11 (используется `PeriodRules`); если нет — добавить `@property def group_names(self) -> frozenset[str]: return frozenset(self._regex.groupindex)`. + +- [ ] **Step 5: Запустить тест и весь `make check` (старые словари и тесты не задеты)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/test_statement_rules.py tests/unit/parser/test_rules.py -q && cd .. && make check` +Expected: PASS. Внимание: `tests/fixtures/test_rules_coverage.py` теперь увидит новые словари через `iter_patterns`? Нет — `DICTIONARIES` в `tests/rules_coverage.py` перечисляет четыре файла, новые добавятся в задаче 14. `test_coverage_sees_every_dictionary_entry_and_all_files` сравнивает только перечисленные. + +- [ ] **Step 6: README словарей** + +В `API/rules/README.md` в таблицу добавить три строки: `statement_headers.yaml` (`form_titles`, `okud`, `continuation`, `code_caption`, `name_caption`, `explanations_caption`, `column_numbering`, `labels`, `auditor`, `legal_form`, `line_codes`), `statement_periods.yaml` (`months`, `on_date`, `numeric_date`, `for_months`, `for_range`, `for_year`), `statement_checks.yaml` (`tolerance`, `subline_check`, `relations[].form/relation/strict/abs_terms`) с описанием «что описывает» из комментариев YAML. + +- [ ] **Step 7: Commit** + +```bash +git -C T add api/rules api/src/parser/rules.py api/tests/unit/parser/test_statement_rules.py +git -C T commit -m "feat(statements): словари форм — заголовки и ОКУД, метки шапки, периоды, соотношения §7 как данные + +Refs #14" +``` + +--- + +### Task 5: `statements/columns.py` — колонка кодов, заголовок таблицы, колонки-периоды + +**Files:** +- Create: `API/src/parser/statements/__init__.py` (пустой docstring пакета), `API/src/parser/statements/columns.py` +- Test: `API/tests/unit/parser/statements/__init__.py` (пустой), `API/tests/unit/parser/statements/test_columns.py` + +**Interfaces:** +- Consumes: `Sheet`, `Cell` (`src.parser.grid`), `Rules.statement_headers`, `Rules.statement_periods` (задача 4). +- Produces: `line_code(cell, rules) -> str | None`; `find_code_column(sheet, rules, first_row, last_row) -> int | None`; `PeriodColumn(col, label, period_date)`; `TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped_labels, first_data_row)`; `find_layout(sheet, rules, first_row, last_row) -> TableLayout`; `LayoutNotFoundError(reason)`; `is_numbering_row(sheet, row, rules) -> bool`; `cell_label(sheet, row, col) -> str` (текст с переносами, схлопнутыми в пробел). + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_columns.py +"""Заголовок таблицы формы, колонка кодов, наименований и периодов (design D-4; spec «Заголовок таблицы, колонка кодов +и колонки-периоды определяются по тексту»).""" + +import datetime +from decimal import Decimal + +import pytest +from gridbuilder import M, at, sheet_from_rows + +from src.parser.grid import Cell +from src.parser.rules import Rules, load_rules +from src.parser.statements.columns import LayoutNotFoundError, find_code_column, find_layout, line_code + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def test_line_code_from_text_and_numeric_cells(rules: Rules) -> None: + assert line_code(Cell("1110", "text"), rules) == "1110" + assert line_code(Cell(Decimal("1110"), "number"), rules) == "1110" + assert line_code(Cell(Decimal("1110.0"), "number"), rules) == "1110" # Review Focus 1: xlrd даёт float + assert line_code(Cell("11101", "text"), rules) == "11101" + assert line_code(Cell("2110", "text"), rules) == "2110" + assert line_code(Cell("5200", "text"), rules) == "5200" + assert line_code(Cell("2023", "text"), rules) is None # год, не код + assert line_code(Cell("0710001", "text"), rules) is None + assert line_code(Cell("", "empty"), rules) is None + + +ONE_C_BALANCE = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + [], + ["Пояснения", "Наименование показателя", None, "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "Изменение, %"], + [None, "АКТИВ"], + [None, "Нематериальные активы", None, "1110", 5971264, 27405634, 12], + [None, "Нематериальные активы в организации", None, "11101", 5852464, 27214614, 0], + [None, None, None, None, 0, 0, 0], + ["1", "Основные средства", None, "1150", 2039077663, 1809528568, 7], + [None, "Итого по разделу I", None, "1100", 2045048927, 1836934202, 7], +] + + +def test_code_column_by_share(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + assert find_code_column(sheet, rules, 1, sheet.n_rows) == 4 + + +def test_code_column_needs_three_codes_and_sixty_percent(rules: Rules) -> None: + sheet = sheet_from_rows([["x", "1110"], ["y", "abc"], ["z", "def"], ["w", "ghi"], ["v", "1150"]]) + + assert find_code_column(sheet, rules, 1, 5) is None + + +def test_layout_one_c(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (4, 4, 5) + assert layout.code_col == 4 and layout.name_col == 2 + assert [(column.col, column.label, column.period_date) for column in layout.periods] == [ + (5, "На 31 декабря 2024 г.", datetime.date(2024, 12, 31)), + (6, "На 31 декабря 2023 г.", datetime.date(2023, 12, 31)), + ] + assert layout.skipped_labels == ("Изменение, %",) + + +def test_layout_tdsheet_with_numbering_row_and_line_breaks(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["БУХГАЛТЕРСКИЙ БАЛАНС"], + ["Наименование показателя", "Коды", "За 9 месяцев\n2025 года", "На 31 декабря \n2024 года"], + ["1", "2", "3", "4"], + ["Нематериальные активы", "1110", 0, 0], + ["Основные средства", "1150", Decimal("898852.21795"), 966270], + ["ИТОГО по разделу I", "1100", Decimal("898852.21795"), 966270], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (2, 3, 4) + assert layout.name_col == 1 and layout.code_col == 2 + assert [column.label for column in layout.periods] == ["За 9 месяцев 2025 года", "На 31 декабря 2024 года"] + assert layout.periods[0].period_date == datetime.date(2025, 9, 30) + + +def test_layout_two_row_header(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Наименование показателя", "Код", "На 31 декабря", "На 31 декабря"], + [None, None, "2024 г.", "2023 г."], + ["Запасы", "1210", 10, 20], + ["Дебиторская задолженность", "1230", 30, 40], + ["Итого по разделу II", "1200", 40, 60], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert (layout.header_row, layout.header_last_row, layout.first_data_row) == (1, 2, 3) + assert [column.period_date for column in layout.periods] == [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + + +def test_layout_without_code_caption(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Показатель", None, "На 31.12.2024", "На 31.12.2023"], + ["Основные средства", "1150", 100, 90], + ["Запасы", "1210", 50, 40], + ["БАЛАНС", "1600", 150, 130], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 1 and layout.code_col == 2 and layout.name_col == 1 + assert [column.period_date for column in layout.periods] == [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + + +def test_layout_continuation_without_header(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [None, None, None, None, "в том числе:"], + [None, None, None, None, "Биологические активы", "11901", 0, 0, 0], + [None, None, None, None, "Итого по разделу I", "1100", 11853698344, 11570097340, 9391157985], + [None, None, None, "II. Оборотные активы"], + [None, None, None, "Запасы", None, "1210", 1001705104, 1426756617, 3393013092], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row is None and layout.first_data_row == 1 + assert layout.code_col == 6 + assert [(column.col, column.label, column.period_date) for column in layout.periods] == [(7, "", None), (8, "", None), (9, "", None)] + + +def test_explanations_column_is_not_name(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C_BALANCE) + + assert find_layout(sheet, rules, 1, sheet.n_rows).name_col == 2 + + +def test_no_code_column_raises(rules: Rules) -> None: + sheet = sheet_from_rows([["Показатель", "Сумма"], ["Основные средства", 100], ["Запасы", 50]]) + + with pytest.raises(LayoutNotFoundError, match="колонка кодов не найдена"): + find_layout(sheet, rules, 1, 3) + + +def test_bare_year_in_title_block_is_not_a_header(rules: Rules) -> None: + """У НПЗ над таблицей стоит «Отчетная дата | 30 | 09 | 2025»: строка с подписью «Код» побеждает.""" + sheet = sheet_from_rows( + [ + [*at({6: "Отчетная дата", 22: "30", 24: "09", 25: "2025"})], + [*at({4: "Наименование показателя", 12: "Код", 15: "На 30 сентября 2025 г.", 20: "На 31 декабря 2024 г."})], + [*at({4: "Гудвил", 12: "1105", 15: 0, 20: 0})], + [*at({4: "Нематериальные активы", 12: "1110", 15: 586260, 20: 905786})], + [*at({4: "Итого по разделу I", 12: "1100", 15: 586260, 20: 905786})], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 2 and [column.col for column in layout.periods] == [15, 20] +``` + +- [ ] **Step 2: Запустить — падает (`ModuleNotFoundError: src.parser.statements`)** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements/test_columns.py -q` + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/__init__.py +"""Конвейер форм ББ/ОФР (Срез 2b, spec `statement-parsing`): columns → detect → header → lines → checks → merge/statement. + +Как и в ОСВ, структура ищется по содержимому: синонимы и соотношения §7 — `rules/statement_*.yaml`. +""" +``` + +```python +# API/src/parser/statements/columns.py +"""Заголовок таблицы формы, колонка кодов, колонка наименований, колонки-периоды (design D-4). + +Колонка кодов — та, где не меньше `line_codes.min_share` непустых значений блока — коды строк форм; строка заголовка +— с подписью «Код» и ячейками-периодами правее; заголовок в две строки склеивается; строка нумерации «1 2 3 4 5» +пропускается. Ячейки заголовка без периода («Изменение, %») в колонки не попадают. +""" + +import re +from dataclasses import dataclass +from datetime import date + +from src.parser.grid import Cell, Sheet +from src.parser.rules import Rules + +__all__ = [ + "LayoutNotFoundError", + "PeriodColumn", + "TableLayout", + "cell_label", + "find_code_column", + "find_layout", + "is_numbering_row", + "line_code", +] + +_CODE = re.compile(r"^(\d{4,5})(\.0+)?$") +_SHORT_TEXT = 3 # значения колонки «Пояснения»: 1–2 символа/цифры +_MIN_NUMBERING_CELLS = 3 +NO_CODE_COLUMN = "колонка кодов не найдена" + + +class LayoutNotFoundError(Exception): + """Таблица формы не найдена: `reason` — что именно (колонка кодов).""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class PeriodColumn: + col: int + label: str # как в заголовке (переносы → пробел); "" у продолжения без заголовка + period_date: date | None + + +@dataclass(frozen=True) +class TableLayout: + header_row: int | None # None — заголовка таблицы нет (лист-продолжение) + header_last_row: int | None # вторая строка заголовка или строка нумерации + code_col: int + name_col: int | None + periods: tuple[PeriodColumn, ...] + skipped_labels: tuple[str, ...] # заголовки правее кодов без периода + first_data_row: int + + +def cell_label(sheet: Sheet, row: int, col: int) -> str: + return " ".join(sheet.text_at(row, col).split()) + + +def line_code(cell: Cell, rules: Rules) -> str | None: + """Код строки формы: 4–5 цифр, первые четыре — в диапазонах словаря; число `1110.0` читается как «1110».""" + found = _CODE.match(cell.text.replace(" ", "")) + if found is None: + return None + code = found.group(1) + head = int(code[:4]) + ranges = rules.statement_headers.code_ranges.values() + return code if any(low <= head <= high for low, high in ranges) else None + + +def find_code_column(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int | None: + """Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`.""" + counts: dict[int, list[int]] = {} + for row in range(first_row, last_row + 1): + for col, cell in sheet.row_cells(row): + tally = counts.setdefault(col, [0, 0]) + tally[1] += 1 + if line_code(cell, rules) is not None: + tally[0] += 1 + headers = rules.statement_headers + best: int | None = None + for col in sorted(counts): + codes, nonempty = counts[col] + if codes < headers.min_codes or codes / nonempty < headers.code_min_share: + continue + if best is None or codes > counts[best][0]: + best = col + return best + + +def is_numbering_row(sheet: Sheet, row: int, rules: Rules) -> bool: + cells = sheet.row_cells(row) + pattern = rules.statement_headers.column_numbering + return len(cells) >= _MIN_NUMBERING_CELLS and all(pattern.search(cell.text) for _, cell in cells) + + +def _first_code_row(sheet: Sheet, rules: Rules, code_col: int, first_row: int, last_row: int) -> int: + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is not None: + return row + return last_row + 1 + + +def _is_period_label(label: str, rules: Rules, *, allow_bare_year: bool) -> bool: + if rules.statement_periods.period_end(label) is None: + return False + return allow_bare_year or not rules.statement_periods.for_year.search(label) or any( + pattern.search(label) + for pattern in (rules.statement_periods.on_date, rules.statement_periods.numeric_date, rules.statement_periods.for_months, rules.statement_periods.for_range) + ) + + +def _find_header_row(sheet: Sheet, rules: Rules, code_col: int, first_row: int, first_code_row: int) -> int | None: + """Последняя над первым кодом строка с подписью «Код» в колонке кодов; без подписи — последняя строка с двумя и + более ячейками-периодами правее кодов либо с одной, но не голым годом (титульный блок НПЗ содержит «2025»).""" + captioned = [ + row for row in range(first_row, first_code_row) if rules.statement_headers.is_code_caption(sheet.text_at(row, code_col)) + ] + if captioned: + return captioned[-1] + for row in range(first_code_row - 1, first_row - 1, -1): + labels = [cell_label(sheet, row, col) for col, _ in sheet.row_cells(row) if col > code_col] + strong = [label for label in labels if _is_period_label(label, rules, allow_bare_year=False)] + weak = [label for label in labels if _is_period_label(label, rules, allow_bare_year=True)] + if strong or len(weak) >= 2: # noqa: PLR2004 - две даты подряд — заголовок, одна «2025» — титул + return row + return None + + +def _header_last_row(sheet: Sheet, rules: Rules, header_row: int, code_col: int, first_code_row: int) -> int: + """Вторая строка заголовка (продолжение подписей без кодов) и строка нумерации колонок.""" + last = header_row + following = header_row + 1 + if following < first_code_row and not is_numbering_row(sheet, following, rules) and not sheet.is_row_empty(following): + last = following + if last + 1 < first_code_row and is_numbering_row(sheet, last + 1, rules): + last += 1 + return last + + +def _joined_label(sheet: Sheet, header_row: int, last_row: int, col: int, rules: Rules) -> str: + parts = [cell_label(sheet, header_row, col)] + for row in range(header_row + 1, last_row + 1): + if is_numbering_row(sheet, row, rules): + continue + origin_row, _ = sheet.merged_origin(row, col) + if origin_row == header_row: # merged-продолжение той же ячейки + continue + parts.append(cell_label(sheet, row, col)) + return " ".join(part for part in parts if part) + + +def _period_columns( + sheet: Sheet, rules: Rules, header_row: int, header_last_row: int, code_col: int +) -> tuple[tuple[PeriodColumn, ...], tuple[str, ...]]: + periods: list[PeriodColumn] = [] + skipped: list[str] = [] + seen: set[int] = set() + for row in range(header_row, header_last_row + 1): + for col, _ in sheet.row_cells(row): + origin_row, origin_col = sheet.merged_origin(row, col) + if origin_col <= code_col or origin_col in seen or is_numbering_row(sheet, row, rules): + continue + seen.add(origin_col) + label = _joined_label(sheet, origin_row, header_last_row, origin_col, rules) + end = rules.statement_periods.period_end(label) + if end is None and not label: + continue + if end is None: + skipped.append(label) + else: + periods.append(PeriodColumn(origin_col, label, end)) + periods.sort(key=lambda column: column.col) + return tuple(periods), tuple(skipped) + + +def _value_columns(sheet: Sheet, rules: Rules, code_col: int, first_row: int, last_row: int) -> tuple[PeriodColumn, ...]: + """Продолжение без заголовка: колонки правее кодов, где в строках с кодами есть хотя бы одно число.""" + found: set[int] = set() + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is None: + continue + found.update(col for col, cell in sheet.row_cells(row) if col > code_col and cell.kind == "number") + return tuple(PeriodColumn(col, "", None) for col in sorted(found)) + + +def _name_column(sheet: Sheet, rules: Rules, code_col: int, header_row: int | None, first_row: int, last_row: int) -> int | None: + """Колонка левее кодов с наибольшим числом текстовых значений в строках с кодами; «Пояснения» (по подписи или по + коротким значениям) исключается; при равенстве — ближайшая к кодам.""" + texts: dict[int, int] = {} + long_texts: dict[int, int] = {} + for row in range(first_row, last_row + 1): + if line_code(sheet.cell(row, code_col), rules) is None: + continue + for col, cell in sheet.row_cells(row): + if col >= code_col or cell.kind != "text": + continue + texts[col] = texts.get(col, 0) + 1 + if len(cell.text) > _SHORT_TEXT: + long_texts[col] = long_texts.get(col, 0) + 1 + candidates = [ + col + for col in texts + if long_texts.get(col, 0) > 0 + and not (header_row is not None and rules.statement_headers.is_explanations_caption(sheet.text_at(header_row, col))) + ] + if not candidates: + return None + return max(candidates, key=lambda col: (texts[col], col)) + + +def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> TableLayout: + code_col = find_code_column(sheet, rules, first_row, last_row) + if code_col is None: + raise LayoutNotFoundError(NO_CODE_COLUMN) + first_code_row = _first_code_row(sheet, rules, code_col, first_row, last_row) + header_row = _find_header_row(sheet, rules, code_col, first_row, first_code_row) + if header_row is None: + periods = _value_columns(sheet, rules, code_col, first_row, last_row) + name_col = _name_column(sheet, rules, code_col, None, first_row, last_row) + return TableLayout(None, None, code_col, name_col, periods, (), first_row) + header_last_row = _header_last_row(sheet, rules, header_row, code_col, first_code_row) + periods, skipped = _period_columns(sheet, rules, header_row, header_last_row, code_col) + name_col = _name_column(sheet, rules, code_col, header_row, header_last_row + 1, last_row) + return TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped, header_last_row + 1) +``` + +Проверить на `ONE_C_BALANCE`: вторая строка заголовка — `_header_last_row` берёт строку 5 («АКТИВ»)? Нет: условие «не пустая и не нумерация» слишком слабое. Усилить: следующая строка считается продолжением заголовка, только если в ней нет текста в колонке наименований **или** все её непустые ячейки правее кодов. Реализация: `following` принимается, когда `all(col > code_col for col, _ in sheet.row_cells(following))` либо `sheet.text_at(following, code_col) == "" and not any(cell.kind == "number" for _, cell in sheet.row_cells(following)) and any(col > code_col for col, _ in sheet.row_cells(following))`. «АКТИВ» стоит левее кодов и ничего правее — не продолжение; «2024 г. | 2023 г.» — правее — продолжение. + +- [ ] **Step 4: Запустить тесты — PASS; линтер координат и `make check`** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements -q && .venv/bin/pytest tests/unit/parser/test_no_coordinates.py -q && cd .. && make lint` + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements api/tests/unit/parser/statements +git -C T commit -m "feat(statements): колонка кодов, заголовок таблицы и колонки-периоды по содержимому + +Refs #14" +``` + +--- + +### Task 6: `statements/detect.py` — блоки форм, тип, продолжения; расширение `classify_sheet` + +**Files:** +- Create: `API/src/parser/statements/detect.py` +- Modify: `API/src/parser/detect.py` (`SheetKind.STATEMENT_CONTINUATION`, `classify_sheet`), `API/src/parser/rules.py` (удалить `statement_markers` из `OsvHeaderRules`), `API/rules/osv_headers.yaml` (удалить `statement_markers`), `API/rules/README.md` +- Test: `API/tests/unit/parser/statements/test_detect.py`, правки `API/tests/unit/parser/test_detect.py`, `API/tests/fixtures/test_detect_real.py` (если проверяет `statement_markers`) + +**Interfaces:** +- Consumes: `find_code_column`, `line_code` (задача 5), `Rules.statement_headers`. +- Produces: `FormBlock(kind: FormType, form_name: str | None, okud: str | None, title_row: int | None, first_row: int, last_row: int)`; `find_forms(sheet, rules) -> tuple[list[FormBlock], list[str]]`; `StatementSheetKind = Literal["statement", "continuation", "other"]`; `classify_statement_sheet(sheet, rules) -> StatementSheetKind`; `continuation_form(sheet, rules) -> tuple[FormType | None, str | None]` (тип по ОКУД маркера и текст маркера); `SheetKind.STATEMENT_CONTINUATION`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_detect.py +"""Блоки форм на листе, тип формы, лист-продолжение (design D-2, D-3; spec «Листы форм и их продолжения находятся по +содержимому»).""" + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.detect import SheetKind, classify_sheet +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import classify_statement_sheet, continuation_form, find_forms + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +NPZ_FIRST_SHEET = [ + [None, None, "Бухгалтерская отчетность"], + [None, None, "за январь - сентябрь 2025 г."], + [*at({3: "Организация:", 7: 'ООО "НПЗ"', 22: "по ОКПО", 23: "16098811"})], + [*at({3: "Идентификационный номер налогоплательщика", 22: "ИНН", 23: "2315995868"})], + [], + [None, None, "Бухгалтерский баланс"], + [None, None, "на 30 сентября 2025 г."], + [*at({22: "Форма по ОКУД", 23: "0710001"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 13: "Код", 16: "На 30 сентября 2025 г.", 21: "На 31 декабря 2024 г."})], + [*at({5: "Гудвил", 13: "1105", 16: 0, 21: 0})], + [*at({5: "Нематериальные активы", 13: "1110", 16: 586260, 21: 905786})], + [*at({5: "Итого по разделу I", 13: "1100", 16: 586260, 21: 905786})], +] + + +def test_title_after_report_header_gives_one_balance_block(rules: Rules) -> None: + sheet = sheet_from_rows(NPZ_FIRST_SHEET) + + blocks, warnings = find_forms(sheet, rules) + + assert len(blocks) == 1 and warnings == [] + block = blocks[0] + assert block.kind == "BALANCE" and block.form_name == "Бухгалтерский баланс" and block.okud == "0710001" + assert (block.title_row, block.first_row, block.last_row) == (6, 1, sheet.n_rows) + + +def test_two_forms_on_one_sheet_split_at_second_title(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Бухгалтерский баланс"], + ["Наименование", "Код", "На 31.12.2024"], + ["Запасы", "1210", 10], + ["БАЛАНС", "1600", 10], + [], + ["Отчет о финансовых результатах"], + ["Наименование", "Код", "За 2024 г."], + ["Выручка", "2110", 100], + ] + ) + + blocks, _ = find_forms(sheet, rules) + + assert [(block.kind, block.first_row, block.last_row) for block in blocks] == [("BALANCE", 1, 5), ("INCOME", 6, 8)] + + +def test_okud_without_title_and_disclosure(rules: Rules) -> None: + sheet = sheet_from_rows([["Форма по ОКУД", "0710002"], ["Код", "За 2024 г."], ["2110", 1]]) + assert find_forms(sheet, rules)[0][0].kind == "INCOME" + + disclosure = sheet_from_rows([["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["текст"]]) + assert find_forms(disclosure, rules)[0][0].kind == "DISCLOSURE" + odds = sheet_from_rows([["Отчет о движении денежных средств"], ["Код", "За 2024 г."], ["4110", 1]]) + assert find_forms(odds, rules)[0][0].kind == "DISCLOSURE" + + +def test_title_and_okud_conflict_prefers_okud_with_warning(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["Форма по ОКУД", "0710002"]]) + + blocks, warnings = find_forms(sheet, rules) + + assert blocks[0].kind == "INCOME" and warnings and "0710002" in warnings[0] + + +def test_classify_statement_continuation_and_other(rules: Rules) -> None: + one_c_page_two = sheet_from_rows([[*at({17: "Форма 0710001 с.2"})], ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], [None, "Уставный капитал", "1310", 250000]]) + npz_page = sheet_from_rows([["в том числе:"], ["Биологические активы", "11901", 0, 0], ["Итого по разделу I", "1100", 5, 5], ["Запасы", "1210", 1, 1]]) + header_only = sheet_from_rows([["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], ["Запасы", None, None, 5]]) + other = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]]) + + assert classify_statement_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) == "statement" + assert classify_statement_sheet(one_c_page_two, rules) == "continuation" + assert classify_statement_sheet(npz_page, rules) == "continuation" + assert classify_statement_sheet(header_only, rules) == "continuation" + assert classify_statement_sheet(other, rules) == "other" + assert continuation_form(one_c_page_two, rules) == ("BALANCE", "Форма 0710001 с.2") + assert continuation_form(npz_page, rules) == (None, None) + + +def test_sheet_kind_wiring(rules: Rules) -> None: + assert classify_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) is SheetKind.STATEMENT + assert classify_sheet(sheet_from_rows([["Форма 0710002 с.2"], ["Код", "За 2024 г."], ["2510", 0]]), rules) is SheetKind.STATEMENT_CONTINUATION + assert classify_sheet(sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"], ["Бухгалтерский баланс"]]), rules) is SheetKind.OSV +``` + +- [ ] **Step 2: Запустить — падает (нет модуля)** + +- [ ] **Step 3: Реализация `statements/detect.py`** + +```python +"""Блоки форм на листе и вид листа форм (design D-2, D-3). + +Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были +строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская +отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или +колонкой кодов — продолжение. +""" + +import re +from dataclasses import dataclass +from typing import Literal + +from src.parser.grid import Sheet +from src.parser.rules import FormType, Rules +from src.parser.statements.columns import cell_label, find_code_column, line_code + +__all__ = ["FormBlock", "StatementSheetKind", "classify_statement_sheet", "continuation_form", "find_forms"] + +StatementSheetKind = Literal["statement", "continuation", "other"] +_OKUD_CODE = re.compile(r"0710\d{3}") + + +@dataclass(frozen=True) +class FormBlock: + kind: FormType + form_name: str | None + okud: str | None + title_row: int | None + first_row: int + last_row: int + + +@dataclass(frozen=True) +class _Candidate: + row: int + title: str | None + title_kind: FormType | None + okud: str | None + okud_kind: FormType | None + + +def _candidates(sheet: Sheet, rules: Rules) -> list[_Candidate]: + headers = rules.statement_headers + found: list[_Candidate] = [] + for row in range(1, sheet.n_rows + 1): + title, title_kind, okud, okud_kind = None, None, None, None + for col, cell in sheet.row_cells(row): + label = cell_label(sheet, row, col) + kind = headers.form_type(label) + if kind is not None and title is None: + title, title_kind = label, kind + by_okud = headers.okud_type(label) + if by_okud is not None and okud is None: + code = _OKUD_CODE.search(label) + okud, okud_kind = (code.group(0) if code else label), by_okud + if title is not None or okud is not None: + found.append(_Candidate(row, title, title_kind, okud, okud_kind)) + return found + + +def _code_rows(sheet: Sheet, rules: Rules) -> list[int]: + return [row for row in range(1, sheet.n_rows + 1) if any(line_code(cell, rules) for _, cell in sheet.row_cells(row))] + + +def find_forms(sheet: Sheet, rules: Rules) -> tuple[list[FormBlock], list[str]]: + """Блоки форм сверху вниз и предупреждения (заголовок и ОКУД противоречат — тип по ОКУД).""" + candidates = _candidates(sheet, rules) + if not candidates: + return [], [] + code_rows = _code_rows(sheet, rules) + groups: list[list[_Candidate]] = [] + for candidate in candidates: + if groups and not any(groups[-1][0].row < row < candidate.row for row in code_rows): + groups[-1].append(candidate) + else: + groups.append([candidate]) + blocks: list[FormBlock] = [] + warnings: list[str] = [] + for index, group in enumerate(groups): + title = next((item for item in group if item.title is not None), None) + okud = next((item for item in group if item.okud is not None), None) + kind = okud.okud_kind if okud is not None else title.title_kind # type: ignore[union-attr] + if title is not None and okud is not None and title.title_kind != okud.okud_kind: + warnings.append( + f"лист «{sheet.name}»: заголовок «{title.title}» и ОКУД {okud.okud} противоречат, тип по ОКУД" + ) + first_row = 1 if index == 0 else group[0].row + last_row = sheet.n_rows if index == len(groups) - 1 else groups[index + 1][0].row - 1 + blocks.append( + FormBlock( + kind=kind, # type: ignore[arg-type] + form_name=title.title if title is not None else None, + okud=okud.okud if okud is not None else None, + title_row=group[0].row, + first_row=first_row, + last_row=last_row, + ) + ) + return blocks, warnings + + +def continuation_form(sheet: Sheet, rules: Rules) -> tuple[FormType | None, str | None]: + """Маркер «Форма 0710001 с.2»: тип формы по ОКУД в маркере и сам текст; без маркера — `(None, None)`.""" + headers = rules.statement_headers + for row in range(1, sheet.n_rows + 1): + for col, _ in sheet.row_cells(row): + label = cell_label(sheet, row, col) + if headers.is_continuation(label): + return headers.okud_type(label), label + return None, None + + +def _has_table_header(sheet: Sheet, rules: Rules) -> bool: + headers, periods = rules.statement_headers, rules.statement_periods + for row in range(1, sheet.n_rows + 1): + cells = sheet.row_cells(row) + caption = next((col for col, cell in cells if headers.is_code_caption(cell.text)), None) + if caption is None: + continue + if any(col > caption and periods.period_end(cell_label(sheet, row, col)) is not None for col, _ in cells): + return True + return False + + +def classify_statement_sheet(sheet: Sheet, rules: Rules) -> StatementSheetKind: + if find_forms(sheet, rules)[0]: + return "statement" + if continuation_form(sheet, rules)[1] is not None or _has_table_header(sheet, rules): + return "continuation" + if find_code_column(sheet, rules, 1, sheet.n_rows) is not None: + return "continuation" + return "other" +``` + +`src/parser/detect.py`: `SheetKind` + `STATEMENT_CONTINUATION = "statement_continuation"`; `classify_sheet`: + +```python +def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind: + """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по + `statements.detect` (признаки формы ищутся по всему листу до таблицы).""" + head = [text for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1) for _, text in _texts(sheet, row)] + if any(_search(rules.headers.sheet_markers, text) for text in head): + return SheetKind.OSV + return _STATEMENT_KINDS[classify_statement_sheet(sheet, rules)] +``` + +с `_STATEMENT_KINDS = {"statement": SheetKind.STATEMENT, "continuation": SheetKind.STATEMENT_CONTINUATION, "other": SheetKind.OTHER}` и импортом `from src.parser.statements.detect import classify_statement_sheet` (цикла нет: `statements/*` не импортируют `src.parser.detect`). Удалить `statement_markers` из `OsvHeaderRules`, `osv_headers.yaml`, README словарей (иначе тест покрытия словарей сочтёт их мёртвыми). В `tests/unit/parser/test_detect.py` параметр `"Форма по ОКУД 0710001"` остаётся валидным. + +- [ ] **Step 4: Прогнать unit и fixtures-тесты детектора** + +Run: `cd API && .venv/bin/pytest tests/unit/parser/statements tests/unit/parser/test_detect.py tests/unit/parser/test_rules.py -q && .venv/bin/pytest tests/fixtures/test_detect_real.py -q` +Expected: PASS; реальные ОСВ по-прежнему `OSV`. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/rules api/tests +git -C T commit -m "feat(statements): блоки форм, тип по заголовку и ОКУД, листы-продолжения; classify_sheet без statement_markers + +Refs #14" +``` + +--- + +### Task 7: `book.py` — `BookParser`, `OsvParser` как конвейер листа, `get_parser` + +**Files:** +- Create: `API/src/parser/book.py` +- Modify: `API/src/parser/osv.py` (удалить `parse`, `_parse_book`, `_error`, `_elapsed_ms`, `max_bytes`/`timeout_seconds`, конструктор → `OsvParser(rules)`; сделать публичными `sheet_line`, `unique`, `data_rows`), `API/src/parser/__init__.py` (`get_parser`), `API/src/parser/cli.py` (не меняется, использует `get_parser`) +- Modify tests: `git -C T mv api/tests/unit/parser/test_osv_parser.py api/tests/unit/parser/test_book_parser.py` (замена `OsvParser(rules, max_bytes=…, timeout_seconds=…)` → `book(rules, …)` хелпер), `API/tests/fixtures/test_rules_coverage.py` (сборка `BookParser`), любые `isinstance(parser, OsvParser)` → `BookParser` +- Test: новые тесты в `test_book_parser.py` + +**Interfaces:** +- Produces: `BookParser(rules, *, osv: OsvParser, statements: StatementSheetParser, max_bytes, timeout_seconds, clock)` с `.parse(data, file_type) -> ParseOutcome`, `.max_bytes`, `.timeout_seconds`; `StatementSheetParser` (Protocol): `parse_sheets(sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline) -> tuple[list[StatementDocument], list[str], bool]`; `SkippingStatementParser` (временная заглушка до задачи 12: документов нет, предупреждение `sheet=<имя> skipped: statement (slice 2b)`, ok=True); `OsvParser(rules).parse_sheet(sheet, deadline)`; `get_parser()` → `BookParser`. + +- [ ] **Step 1: Тесты (добавить в `test_book_parser.py` после переименования)** + +```python +from src.parser.book import BookParser, SkippingStatementParser, StatementSheetParser +from src.parser.osv import OsvParser + + +def book(rules: Rules, *, statements: StatementSheetParser | None = None, **kwargs: Any) -> BookParser: + kwargs.setdefault("max_bytes", MAX_BYTES) + kwargs.setdefault("timeout_seconds", TIMEOUT_SECONDS) + return BookParser(rules, osv=OsvParser(rules), statements=statements or SkippingStatementParser(), **kwargs) + + +class _FakeStatements: + """Возвращает по одному документу DISCLOSURE на лист формы и запоминает, что получил.""" + + def __init__(self) -> None: + self.received: list[tuple[str, SheetKind]] = [] + + def parse_sheets(self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline) -> tuple[list[StatementDocument], list[str], bool]: + self.received = [(sheet.name, kind) for sheet, kind in sheets] + documents = [ + StatementDocument(document_type="DISCLOSURE", locator=f"sheet:{sheet.name}", header=StatementHeader(), columns=[], lines=[]) + for sheet, _ in sheets + ] + return documents, [f"sheet={sheet.name} statements-fake" for sheet, _ in sheets], True + + +def test_statement_sheets_go_to_statement_parser_in_sheet_order(rules: Rules) -> None: + fake = _FakeStatements() + data = xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]], "Лист_1": mini_osv_rows(), "с.2": [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]]}) + + outcome = book(rules, statements=fake).parse(data, "XLSX") + + assert fake.received == [("ББ", SheetKind.STATEMENT), ("с.2", SheetKind.STATEMENT_CONTINUATION)] + assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument", "StatementDocument"] + assert outcome.status is ResultStatus.DONE + assert "sheet=ББ statements-fake" in (outcome.metrics.warnings or []) + + +def test_skipping_parser_keeps_slice_2a_behaviour(rules: Rules) -> None: + data = xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]], "Лист_1": mini_osv_rows()}) + + outcome = book(rules).parse(data, "XLSX") + + assert len(outcome.documents) == 1 and "sheet=ББ skipped: statement (slice 2b)" in (outcome.metrics.warnings or []) + + +def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"Справка": [["Наименование", "Сумма"], ["Аренда", 100]]}), "XLSX") + + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert outcome.error.code is ErrorCode.INVALID_DOCUMENT and "нет листов ОСВ или форм" in outcome.error.message + + +def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None: + class _NotOk(_FakeStatements): + def parse_sheets(self, sheets, deadline): # type: ignore[no-untyped-def] + documents, warnings, _ = super().parse_sheets(sheets, deadline) + return documents, warnings, False + + outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]]}), "XLSX") + + assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 +``` + +`mini_osv_rows()` — вынести из существующих тестов `test_osv_parser.py` строки мини-ОСВ (там они есть как список `ROWS`/функция; переименовать по месту). Существующие тесты, проверяющие `skipped: statement (slice 2b)`, остаются зелёными при `SkippingStatementParser`. + +- [ ] **Step 2: Запустить — падает (нет `src.parser.book`)** + +- [ ] **Step 3: Реализация `book.py`** + +```python +"""Оркестратор книги (design D-1): лимит размера → чтение → дедлайн → вид листа → конвейеры ОСВ и форм → статус. + +Карта исходов та же, что была у `OsvParser.parse` (D-6 Среза 2a): нет документов ОСВ и форм → `INVALID_DOCUMENT`; +лист ОСВ без заголовка/колонок → `INVALID_FORMAT`; книга не открывается → `UNREADABLE`; сигнатура/лимиты → +`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Нарушение контрольных сумм ОСВ или +соотношений формы хотя бы в одном документе → `needs_check`. Документы: ОСВ в порядке листов, затем формы. +""" + +import time +from collections.abc import Callable, Sequence +from typing import Protocol + +from loguru import logger + +from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument +from src.parser import ParseOutcome +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind, classify_sheet +from src.parser.errors import InvalidDocumentError, ParserError, UnsupportedFormatError +from src.parser.grid import Sheet +from src.parser.osv import OsvParser, data_rows, sheet_line, unique +from src.parser.readers import read_book +from src.parser.readers._common import one_line_reason +from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR +from src.parser.rules import Rules + +__all__ = ["BookParser", "SkippingStatementParser", "StatementSheetParser"] + +_MS_IN_SECOND = 1000 +STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATION}) + + +class StatementSheetParser(Protocol): + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: ... + + +class SkippingStatementParser: + """Поведение Среза 2a: листы форм пропускаются с предупреждением. Заменяется `StatementParser` (задача 12).""" + + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: + return [], [sheet_line(sheet, "skipped: statement (slice 2b)") for sheet, _ in sheets], True + + +class BookParser: + def __init__( # noqa: PLR0913 - все зависимости оркестратора именованные + self, + rules: Rules, + *, + osv: OsvParser, + statements: StatementSheetParser, + max_bytes: int, + timeout_seconds: float, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._rules = rules + self._osv = osv + self._statements = statements + self._max_bytes = max_bytes + self._timeout_seconds = timeout_seconds + self._clock = clock + + @property + def max_bytes(self) -> int: + return self._max_bytes + + @property + def timeout_seconds(self) -> float: + return self._timeout_seconds + + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + started = self._clock() + warnings: list[str] = [] + try: + documents, ok = self._parse_book(data, file_type, warnings) + except ParserError as exc: + if exc.code is ErrorCode.UNREADABLE: + logger.warning("Книга не читается: {reason}", reason=exc.message) + return self._error(exc.code, exc.message, warnings, started) + except Exception as exc: + reason = one_line_reason(exc) + logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason) + return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started) + rows = sum(data_rows(document) if isinstance(document, OsvDocument) else len(document.lines) for document in documents) + metrics = Metrics(duration_ms=self._elapsed_ms(started), rows=rows, warnings=warnings) + return ParseOutcome(status=ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK, documents=tuple(documents), metrics=metrics) + + def _parse_book( + self, data: bytes, file_type: FileType, warnings: list[str] + ) -> tuple[list[OsvDocument | StatementDocument], bool]: + if len(data) > self._max_bytes: + raise UnsupportedFormatError( + f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: книга не открывается" + ) + deadline = Deadline(self._timeout_seconds, self._clock) + sheets, read_warnings = read_book(data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes) + warnings.extend(unique(read_warnings)) + osv_documents: list[OsvDocument] = [] + statement_sheets: list[tuple[Sheet, SheetKind]] = [] + ok = True + for sheet in sheets: + deadline.check(f"лист «{sheet.name}»") + kind = classify_sheet(sheet, self._rules) + if kind in STATEMENT_KINDS: + statement_sheets.append((sheet, kind)) + continue + if kind is SheetKind.OTHER: + warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) + continue + document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) + osv_documents.append(document) + warnings.extend(sheet_warnings) + ok = ok and sheet_ok + statement_documents, statement_warnings, statements_ok = self._statements.parse_sheets(statement_sheets, deadline) + warnings.extend(statement_warnings) + documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents] + if not documents: + raise InvalidDocumentError(f"в книге нет листов ОСВ или форм: листов просмотрено {len(sheets)}") + return documents, ok and statements_ok + + def _elapsed_ms(self, started: float) -> int: + return max(0, round((self._clock() - started) * _MS_IN_SECOND)) + + def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome: + fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)} + if warnings: + fields["warnings"] = warnings + return ParseOutcome(status=ResultStatus.ERROR, error=MessageError(code=code, message=" ".join(message.split())), metrics=Metrics.model_validate(fields)) +``` + +`osv.py`: удалить `parse`, `_parse_book`, `_error`, `_elapsed_ms`, свойства и параметры лимитов; `__init__(self, rules)`; переименовать `_sheet_line → sheet_line`, `_unique → unique`, `_data_rows → data_rows` (и в `__all__`); docstring модуля — «конвейер листа ОСВ; книгу разбирает `book.BookParser`». Предупреждение «skipped: нет признаков ОСВ» → «…ОСВ или формы» (golden синтетики s06 изменится — учесть в задаче 14). + +`__init__.py::get_parser`: + +```python + from src.parser.book import BookParser, SkippingStatementParser # noqa: PLC0415 + from src.parser.osv import OsvParser # noqa: PLC0415 + + rules = load_rules(rules_dir) + ... + return BookParser(rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=max_bytes, timeout_seconds=timeout_seconds) +``` + +`tests/fixtures/test_rules_coverage.py`: `base = get_parser(); assert isinstance(base, BookParser); parser = BookParser(rules, osv=OsvParser(rules), statements=SkippingStatementParser(), max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)`. + +- [ ] **Step 4: `make check` и golden-check (снимки ОСВ не должны измениться, кроме текста предупреждения s06)** + +Run: `make -C T check && make -C T golden-check` +Expected: тесты зелёные; `golden-check` показывает diff только у `s06_two_osv_and_other_sheet` (текст предупреждения) — не обновлять сейчас, обновление снимков — задача 14 одним коммитом в fixtures. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/tests +git -C T commit -m "refactor(parser): BookParser — оркестратор книги; OsvParser — конвейер листа ОСВ; заглушка форм до конвейера + +Refs #14" +``` + +--- + +### Task 8: Границы периода в шапке ОСВ (`period_start`, `period_end`, `is_from_year_start`) + +**Files:** +- Modify: `API/src/parser/header.py` (`_Period` + `start`, `end`; `_month_range_period`, `_parse_period`; `DocHeader` + `period_start`, `period_end`; `parse_doc_header`), `API/src/parser/osv.py` (`_header`) +- Test: `API/tests/unit/parser/test_header.py` (добавить), `API/tests/unit/parser/test_book_parser.py` (один тест через книгу) + +**Interfaces:** +- Produces: `DocHeader.period_start: date | None`, `DocHeader.period_end: date | None`; `OsvHeader.period_start/period_end/is_from_year_start` заполнены парсером ОСВ. + +- [ ] **Step 1: Тест (в `test_header.py`, рядом с тестами периода ветки #11)** + +```python +@pytest.mark.parametrize( + ("period_text", "start", "end", "from_year_start"), + [ + ("1 квартал 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True), + ("3 квартал 2025 г.", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False), + ("9 месяцев 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 9, 30), True), + ("2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True), + ("Октябрь 2024 г. - Сентябрь 2025 г.", datetime.date(2024, 10, 1), datetime.date(2025, 9, 30), False), + ("Апрель - Июнь 2025 г.", datetime.date(2025, 4, 1), datetime.date(2025, 6, 30), False), + ], +) +def test_period_bounds(rules: Rules, period_text: str, start: datetime.date, end: datetime.date, from_year_start: bool) -> None: + sheet = osv_sheet(period=period_text) # хелпер этого файла, строящий шапку варианта 1 с заданным периодом + header = find_table_header(sheet, rules) + + doc = parse_doc_header(sheet, header, rules) + + assert (doc.period_start, doc.period_end) == (start, end) + assert (doc.period_start == datetime.date(doc.period_start.year, 1, 1)) is from_year_start + + +def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: + doc = parse_doc_header(osv_sheet(period="за неизвестно что"), find_table_header(osv_sheet(period="за неизвестно что"), rules), rules) + + assert doc.period_start is None and doc.period_end is None +``` + +Если хелпера `osv_sheet(period=…)` в файле нет — посмотреть, как тесты ветки #11 строят шапку (`sheet_from_rows` с `TITLE`), и собрать строки с «Оборотно-сальдовая ведомость по счету 60 за ». + +- [ ] **Step 2: Запустить — падает (`DocHeader` без `period_start`)** + +- [ ] **Step 3: Реализация** + +В `header.py`: + +```python +@dataclass(frozen=True) +class _Period: + year: int | None + quarter: Quarter | None + is_cumulative: bool | None + start: datetime.date | None = None + end: datetime.date | None = None + + +def _month_end(year: int, month: int) -> datetime.date: + return datetime.date(year, month, calendar.monthrange(year, month)[1]) + + +def _bounds(year: int | None, quarter: Quarter | None, is_cumulative: bool | None) -> tuple[datetime.date | None, datetime.date | None]: + """Границы квартального/годового периода: конец — конец квартала; начало — 1 января у нарастающего, иначе первый + месяц квартала; без года или квартала — неизвестны.""" + if year is None or quarter is None: + return None, None + end_month = QUARTERS.index(quarter) * _MONTHS_IN_QUARTER + _MONTHS_IN_QUARTER + start_month = 1 if is_cumulative else end_month - _MONTHS_IN_QUARTER + 1 + return datetime.date(year, start_month, 1), _month_end(year, end_month) +``` + +В `_month_range_period` каждое `return _Period(...)` дополняется `start=datetime.date(span.start_year, span.start_month, 1), end=_month_end(span.end_year, span.end_month)`. В `_parse_period` после вычисления `year`/`rule`: `start, end = _bounds(year_value, quarter, is_cumulative)` и в `_Period(..., start=start, end=end)`. `DocHeader` + `period_start: datetime.date | None`, `period_end: datetime.date | None`; `parse_doc_header` передаёт `period.start`, `period.end`. + +В `osv.py::_header`: + +```python + period_start=doc.period_start, + period_end=doc.period_end, + is_from_year_start=None if doc.period_start is None else (doc.period_start.month == 1 and doc.period_start.day == 1), +``` + +- [ ] **Step 4: `make check`; `golden-check` покажет новые поля во всех снимках ОСВ — ожидаемо, обновление в задаче 14** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/header.py api/src/parser/osv.py api/tests +git -C T commit -m "feat(osv): границы периода period_start/period_end и is_from_year_start в шапке (контракт v1.2, OQ-131) + +Refs #14" +``` + +--- + +### Task 9: `statements/header.py` — шапка документа + +**Files:** +- Create: `API/src/parser/statements/header.py` +- Test: `API/tests/unit/parser/statements/test_header.py` + +**Interfaces:** +- Consumes: `Rules.statement_headers.label/is_auditor_label/has_legal_form`, `Rules.statement_periods.period_end/date_triple`, `cell_label`, `FormBlock` (задача 6), `TableLayout.header_row` (задача 5), `numbers.inn_digits` (`src.parser.numbers`: ИНН из ячейки — 10/12 цифр). +- Produces: `parse_header(sheet, rules, first_row, last_row, block: FormBlock | None) -> tuple[StatementHeader, list[str]]` — область `[first_row, last_row]` (до строки заголовка таблицы); `block=None` для продолжения (все поля `None`, `form_name` — текст маркера). + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_header.py +"""Шапка формы по меткам (design D-5; spec «Шапка документа»).""" + +import datetime + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import FormBlock, find_forms +from src.parser.statements.header import parse_header + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ONE_C = [ + [*at({9: "Бухгалтерский баланс"})], + [*at({9: "на 31 декабря 2024 г."})], + [*at({34: "Коды"})], + [*at({33: "Форма по ОКУД", 34: "0710001"})], + [*at({33: "Дата (число, месяц, год)", 34: "31", 37: "12", 40: "2024"})], + [*at({2: "Организация", 4: 'Акционерное общество "Унагранде Компани"', 33: "по ОКПО", 34: "47888105"})], + [*at({2: "Идентификационный номер налогоплательщика", 33: "ИНН", 34: "3249004697"})], + [*at({2: "Вид экономической\nдеятельности", 7: "Производство сыра", 30: "по\nОКВЭД 2", 34: "10.51.3"})], + [*at({2: "Организационно-правовая форма / форма собственности", 34: "12267", 39: "16"})], + [*at({2: "Непубличные акционерные общества", 14: " /", 15: "Частная собственность", 33: "по ОКОПФ / ОКФС"})], + [*at({2: "Единица измерения:", 7: "в тыс. рублей", 33: "по ОКЕИ", 34: "384"})], + [*at({2: "Местонахождение (адрес)"})], + [*at({2: "242440, Брянская обл, г Севск, ул Ленина, д. 130"})], + [*at({2: "Наименование аудиторской организации"})], + [*at({2: 'АО "ЮНИКОН"'})], + [*at({2: "Идентификационный номер налогоплательщика аудиторской организации", 33: "ИНН", 34: "7716021332"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 22: "Код", 25: "На 31 декабря 2024 г."})], + [*at({5: "Нематериальные активы", 22: "1110", 25: 1})], +] + + +def _block(sheet, rules): # type: ignore[no-untyped-def] + return find_forms(sheet, rules)[0][0] + + +def test_one_c_header(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C) + + header, warnings = parse_header(sheet, rules, 1, 16, _block(sheet, rules)) + + assert header.form_name == "Бухгалтерский баланс" and header.okud == "0710001" + assert header.organization_name == 'Акционерное общество "Унагранде Компани"' + assert header.inn == "3249004697" # Review Focus 2: не ИНН аудитора + assert header.okpo == "47888105" and header.okopf == "12267" and header.okfs == "16" + assert header.units == "в тыс. рублей" + assert header.report_date_text == "на 31 декабря 2024 г." and header.report_date == datetime.date(2024, 12, 31) + assert header.address == "242440, Брянская обл, г Севск, ул Ленина, д. 130" + assert header.organization_name_confidence == 100 and header.inn_confidence == 100 + assert warnings == [] + + +def test_organization_above_label_and_income_period(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({3: "Отчет о финансовых результатах"})], + [*at({3: "за январь - сентябрь 2025 г."})], + [*at({13: "Форма по ОКУД", 14: "0710002"})], + [*at({7: "Отчетная дата\n(число, месяц, год)", 14: "30", 15: "09", 16: "2025"})], + [*at({7: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'})], + [*at({3: "Организация:", 13: "по ОКПО", 14: "16098811"})], + [*at({3: "Единица измерения", 8: "тыс. руб.", 13: "по ОКЕИ", 14: "384"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 10: "Код", 11: "За январь - сентябрь 2025 г."})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 7, _block(sheet, rules)) + + assert header.organization_name == 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + assert header.inn is None and header.okpo == "16098811" and header.units == "тыс. руб." + assert header.report_date == datetime.date(2025, 9, 30) and header.report_date_text == "за январь - сентябрь 2025 г." + + +def test_date_from_numeric_triple_and_name_below_label(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({2: "БУХГАЛТЕРСКИЙ БАЛАНС"})], + [*at({17: "Форма по ОКУД", 18: "0710001"})], + [*at({17: "Дата (число, месяц, год)", 18: 30, 19: 9, 20: 2025})], + [*at({2: "Организация (орган исполнительной власти)", 7: 'ООО "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"', 17: "по ОКПО", 18: "85262693"})], + [*at({2: "Идентификационный номер налогоплательщика", 17: "ИНН", 18: "7016006635"})], + [*at({2: "Организационно-правовая форма***", 7: "ООО", 15: "по ОКОПФ/\nОКФС", 18: "65", 19: "16"})], + [*at({2: "Единица измерения:", 7: "тыс. руб", 17: "по ОКЕИ", 18: "384"})], + [*at({2: "Местонахождение (адрес)", 7: "636141, Томская обл"})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 8, _block(sheet, rules)) + + assert header.report_date == datetime.date(2025, 9, 30) and header.report_date_text is None + assert header.inn == "7016006635" and header.okopf == "65" and header.okfs == "16" + assert header.address == "636141, Томская обл" + + +def test_continuation_header_is_empty(rules: Rules) -> None: + sheet = sheet_from_rows([[*at({17: "Форма 0710001 с.2"})], ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."]]) + + header, warnings = parse_header(sheet, rules, 1, 1, None) + + assert header.form_name == "Форма 0710001 с.2" and header.organization_name is None and header.inn is None + assert header.report_date is None and warnings == [] + + +def test_missing_units_and_organization_warn(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["на 31 декабря 2024 г."]]) + + header, warnings = parse_header(sheet, rules, 1, 2, _block(sheet, rules)) + + assert header.units is None and header.organization_name is None + assert any("единица измерения" in warning for warning in warnings) and any("организация" in warning for warning in warnings) +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/header.py +"""Шапка формы по меткам словаря (design D-5): значение — правее метки в той же строке, иначе в следующей строке. + +Особые правила: ИНН ниже метки «аудитор» не берётся; организация — после «Организация», а при пустом значении — +ближайшая строка выше с признаком ОПФ (НПЗ); дата — из подзаголовка формы («на <дата>», «за <период>») либо из тройки +«число | месяц | год»; единицы — текст как в файле. Найденные поля получают `*_confidence = 100`. +""" + +import re +from collections.abc import Iterator + +from src.contract.result import StatementHeader +from src.parser.grid import Sheet +from src.parser.numbers import inn_digits +from src.parser.rules import Rules, StatementLabel +from src.parser.statements.columns import cell_label +from src.parser.statements.detect import FormBlock + +__all__ = ["parse_header"] + +_FULL_CONFIDENCE = 100 +_DIGITS = re.compile(r"\d+") +NO_ORGANIZATION_WARNING = "в шапке не найдена организация" +NO_UNITS_WARNING = "в шапке не найдена единица измерения" + + +def _cells(sheet: Sheet, first_row: int, last_row: int) -> Iterator[tuple[int, int, str]]: + for row in range(first_row, last_row + 1): + for col, _ in sheet.row_cells(row): + yield row, col, cell_label(sheet, row, col) + + +def _right(sheet: Sheet, row: int, col: int) -> list[tuple[int, str]]: + return [(c, cell_label(sheet, row, c)) for c, _ in sheet.row_cells(row) if c > col] + + +def _value_right_or_below(sheet: Sheet, row: int, col: int, last_row: int) -> str | None: + right = _right(sheet, row, col) + if right: + return right[0][1] + if row + 1 <= last_row: + below = [(c, cell_label(sheet, row + 1, c)) for c, _ in sheet.row_cells(row + 1)] + if below: + return below[0][1] + return None + + +def _label_rows(sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int) -> list[tuple[int, int, str]]: + return [(row, col, text) for row, col, text in _cells(sheet, first_row, last_row) if rules.statement_headers.label(key, text)] + + +def _auditor_from(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int: + """Первая строка блока аудитора; ниже неё ИНН/названия — не организации.""" + rows = [row for row, _, text in _cells(sheet, first_row, last_row) if rules.statement_headers.is_auditor_label(text)] + return min(rows) if rows else last_row + 1 + + +def _organization(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "organization", first_row, min(last_row, auditor_from - 1)): + value = _value_right_or_below(sheet, row, col, last_row) + if value and not rules.statement_headers.label("okpo", value) and not value.isdigit(): + return value + for above in range(row - 1, first_row - 1, -1): + texts = [text for _, _, text in _cells(sheet, above, above)] + if any(rules.statement_headers.has_legal_form(text) for text in texts): + return next(text for text in texts if rules.statement_headers.has_legal_form(text)) + return None + + +def _inn(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "inn", first_row, min(last_row, auditor_from - 1)): + for c, _ in _right(sheet, row, col): + digits = inn_digits(sheet.cell(row, c)) + if digits is not None: + return digits + return None + + +def _labelled(sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, key, first_row, last_row): + value = _value_right_or_below(sheet, row, col, last_row) + if value: + return value + return None + + +def _okopf_okfs(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> tuple[str | None, str | None]: + """«по ОКОПФ / ОКФС» с двумя числами правее (в той же или предыдущей строке — у 1С коды стоят строкой выше).""" + for row, col, _ in _label_rows(sheet, rules, "okopf_okfs", first_row, last_row): + for candidate in (row, row - 1): + numbers = [text for _, text in _right(sheet, candidate, col) if _DIGITS.fullmatch(text)] + if len(numbers) >= 2: # noqa: PLR2004 - пара кодов + return numbers[0], numbers[1] + if len(numbers) == 1: + return numbers[0], None + return _labelled(sheet, rules, "okopf", first_row, last_row), _labelled(sheet, rules, "okfs", first_row, last_row) + + +def _units(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> str | None: + for row, col, _ in _label_rows(sheet, rules, "units", first_row, last_row): + for _, text in _right(sheet, row, col): + if not rules.statement_headers.label("okei", text) and not text.isdigit(): + return text + return None + + +def _report_date(sheet: Sheet, rules: Rules, block: FormBlock, first_row: int, last_row: int) -> tuple[str | None, "datetime.date | None"]: + periods = rules.statement_periods + if block.title_row is not None and block.title_row + 1 <= last_row: + # Подзаголовок — только следующая строка за заголовком формы («на 31 декабря 2024 г.», «за 9 месяцев 2025 г.»); + # глубже лежат «Форма по ОКУД» и тройка даты, где голый «2025» принялся бы за период + for _, _, text in _cells(sheet, block.title_row + 1, block.title_row + 1): + end = periods.period_end(text) + if end is not None: + return text, end + for row, col, _ in _label_rows(sheet, rules, "date", first_row, last_row): + parts = [text for _, text in _right(sheet, row, col)] + if len(parts) >= 3: # noqa: PLR2004 - число, месяц, год + found = periods.date_triple(parts[0], parts[1], parts[2]) + if found is not None: + return None, found + return None, None + + +def parse_header( + sheet: Sheet, rules: Rules, first_row: int, last_row: int, block: FormBlock | None +) -> tuple[StatementHeader, list[str]]: + if block is None: + marker = next((text for _, _, text in _cells(sheet, first_row, last_row) if rules.statement_headers.is_continuation(text)), None) + return StatementHeader(form_name=marker), [] + auditor_from = _auditor_from(sheet, rules, first_row, last_row) + organization = _organization(sheet, rules, first_row, last_row, auditor_from) + inn = _inn(sheet, rules, first_row, last_row, auditor_from) + okopf, okfs = _okopf_okfs(sheet, rules, first_row, last_row) + units = _units(sheet, rules, first_row, last_row) + date_text, report_date = _report_date(sheet, rules, block, first_row, last_row) + warnings: list[str] = [] + if organization is None: + warnings.append(NO_ORGANIZATION_WARNING) + if units is None: + warnings.append(NO_UNITS_WARNING) + header = StatementHeader( + form_name=block.form_name, + organization_name=organization, + organization_name_confidence=_FULL_CONFIDENCE if organization else None, + inn=inn, + inn_confidence=_FULL_CONFIDENCE if inn else None, + report_date_text=date_text, + report_date=report_date, + units=units, + okud=block.okud, + okpo=_labelled(sheet, rules, "okpo", first_row, last_row), + okopf=okopf, + okfs=okfs, + address=_labelled(sheet, rules, "address", first_row, min(last_row, auditor_from - 1)), + ) + return header, warnings +``` + +Уточнения при реализации: `import datetime` для аннотации; `_labelled("okpo")` берёт первое значение правее метки — у 1С «по ОКПО | 47888105» в той же строке. `_organization`: значение правее «Организация» у 1С — название (col 4), у НПЗ справа только «по ОКПО» → отбрасывается по метке → поиск выше. Для `inn_digits` проверить сигнатуру в `numbers.py` (принимает `Cell`). + +- [ ] **Step 4: Прогнать, `make lint`** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/header.py api/tests/unit/parser/statements/test_header.py +git -C T commit -m "feat(statements): шапка формы по меткам — организация, ИНН не аудитора, дата, единицы, ОКПО/ОКОПФ/ОКФС, адрес + +Refs #14" +``` + +--- + +### Task 10: `statements/lines.py` — строки с кодом и значения + +**Files:** +- Create: `API/src/parser/statements/lines.py` +- Test: `API/tests/unit/parser/statements/test_lines.py` + +**Interfaces:** +- Consumes: `TableLayout`, `line_code`, `cell_label` (задача 5), `numbers.to_money`. +- Produces: `LinesResult(lines: list[StatementLine], duplicates: list[str], skipped_with_sums: int, fractional_values: int, numeric_values: int, warnings: list[str])`; `collect_lines(sheet, rules, layout, first_row, last_row) -> LinesResult`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_lines.py +"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»).""" + +from decimal import Decimal + +import pytest +from gridbuilder import sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.columns import find_layout +from src.parser.statements.lines import collect_lines + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ROWS = [ + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + [None, "АКТИВ"], + [None, "Нематериальные активы", "1110", Decimal("5971264.14"), 27405634], + [None, "Нематериальные активы\nв организации", "11101", Decimal("5852464.14"), "27 214 614,65"], + [None, None, None, 0, 0], + [None, "в том числе:"], + [None, "Финансовые вложения", "1170", None, 10], + [None, "Прочие", "1190", "–", "(2 100)"], + [None, "Запасы", "1210", "н/д", 5], + [None, "Запасы", "1210", 1, 1], + [None, "Итого по разделу I", "1100", Decimal("11823728.28"), 27405644], +] + + +def test_lines_values_and_skips(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) + + codes = [line.line_code for line in result.lines] + assert codes == ["1110", "11101", "1170", "1190", "1210", "1100"] + by_code = {line.line_code: line for line in result.lines} + assert by_code["11101"].line_name == "Нематериальные активы в организации" + assert [value.amount for value in by_code["11101"].values] == [Decimal("5852464.14"), Decimal("27214614.65")] + assert by_code["1170"].values[0].amount is None and by_code["1170"].values[0].is_recognized is True + assert by_code["1190"].values[0].amount is None and by_code["1190"].values[1].amount == Decimal("-2100.00") + unrecognised = by_code["1210"].values[0] + assert unrecognised.amount is None and unrecognised.string_value == "н/д" and unrecognised.is_recognized is False + assert all(value.confidence == 100 for line in result.lines for value in line.values) + assert all(line.line_code_confidence == 100 and line.line_name_confidence == 100 for line in result.lines) + assert [value.column_index for value in by_code["1110"].values] == [0, 1] + assert result.duplicates == ["1210"] and by_code["1210"].values[1].amount == Decimal("5.00") + assert result.skipped_with_sums == 1 # строка нулей без кода + assert result.fractional_values == 4 and result.numeric_values == 9 + + +def test_fractional_warning_when_units_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="в тыс. рублей") + + assert any(warning.startswith("значений с дробной частью при единице тыс. руб.: 4 из 9") for warning in result.warnings) + assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings) + + +def test_no_fractional_warning_without_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="руб.") + + assert not any("дробной частью" in warning for warning in result.warnings) + + +def test_line_without_name_column(rules: Rules) -> None: + sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]]) + layout = find_layout(sheet, rules, 1, 4) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, 4) + + assert [line.line_name for line in result.lines] == ["", "", ""] +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/lines.py +"""Строки формы с кодом и их значения (design D-6). + +Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них +есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money` +(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не +нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ `FRACTIONAL_SHARE` — предупреждение. +""" + +from dataclasses import dataclass, field +from decimal import Decimal + +from src.contract.result import StatementLine, StatementValue +from src.parser.grid import Sheet +from src.parser.numbers import to_money +from src.parser.rules import Rules, normalize_text +from src.parser.statements.columns import TableLayout, cell_label, line_code + +__all__ = ["FRACTIONAL_SHARE", "LinesResult", "collect_lines"] + +_FULL_CONFIDENCE = 100 +FRACTIONAL_SHARE = 0.1 +_THOUSANDS = "тыс" + + +@dataclass +class LinesResult: + lines: list[StatementLine] = field(default_factory=list) + duplicates: list[str] = field(default_factory=list) + skipped_with_sums: int = 0 + fractional_values: int = 0 + numeric_values: int = 0 + warnings: list[str] = field(default_factory=list) + + +def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[StatementValue, bool]: + """Значение ячейки и признак «число с дробной частью».""" + cell = sheet.cell(*sheet.merged_origin(row, col)) + parsed = to_money(cell) + if parsed.value is not None: + return StatementValue(column_index=column_index, amount=parsed.value, confidence=_FULL_CONFIDENCE, is_recognized=True), parsed.value % 1 != 0 + if parsed.warning is None: # пусто или прочерк + return StatementValue(column_index=column_index, amount=None, confidence=_FULL_CONFIDENCE, is_recognized=True), False + return StatementValue(column_index=column_index, amount=None, string_value=cell.text, confidence=_FULL_CONFIDENCE, is_recognized=False), False + + +def collect_lines( + sheet: Sheet, rules: Rules, layout: TableLayout, first_row: int, last_row: int, *, units: str | None = None +) -> LinesResult: + result = LinesResult() + seen: set[str] = set() + for row in range(first_row, last_row + 1): + if sheet.is_row_empty(row): + continue + code = line_code(sheet.cell(row, layout.code_col), rules) + if code is None: + if any(to_money(sheet.cell(row, column.col)).value is not None for column in layout.periods): + result.skipped_with_sums += 1 + continue + if code in seen: + result.duplicates.append(code) + continue + seen.add(code) + values: list[StatementValue] = [] + for index, column in enumerate(layout.periods): + value, fractional = _value(sheet, row, column.col, index) + values.append(value) + if value.amount is not None: + result.numeric_values += 1 + result.fractional_values += int(fractional) + name = cell_label(sheet, row, layout.name_col) if layout.name_col is not None else "" + result.lines.append( + StatementLine(line_code=code, line_code_confidence=_FULL_CONFIDENCE, line_name=name, line_name_confidence=_FULL_CONFIDENCE, values=values) + ) + if result.skipped_with_sums: + result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}") + if units and _THOUSANDS in normalize_text(units) and result.numeric_values and result.fractional_values / result.numeric_values >= FRACTIONAL_SHARE: + result.warnings.append(f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}") + return result +``` + +Примечание к тесту: `to_money` возвращает квантованный Decimal, поэтому `% 1` корректно; дробных значений четыре (5971264.14, 5852464.14, 27214614.65, 11823728.28). Дубликат «1210»: первое вхождение (`["н/д", 5]`) остаётся, второе пропускается. + +- [ ] **Step 4: Прогнать, lint** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/lines.py api/tests/unit/parser/statements/test_lines.py +git -C T commit -m "feat(statements): строки с кодом, значения Decimal, дубликаты кодов, предупреждение о единицах + +Refs #14" +``` + +--- + +### Task 11: `statements/checks.py` — соотношения §7 + +**Files:** +- Create: `API/src/parser/statements/checks.py` +- Test: `API/tests/unit/parser/statements/test_checks.py` + +**Interfaces:** +- Consumes: `Rules.statement_checks` (`RelationRule.target/terms/abs_terms/strict`, `tolerance`, `subline_check`), `StatementLine`, `PeriodColumn`. +- Produces: `run_statement_checks(lines: Sequence[StatementLine], column_count: int, form: FormType, rules) -> tuple[StatementChecks, list[str]]` — `duplicate_line_codes` заполняет вызывающий (`statement.py`), здесь только соотношения. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_checks.py +"""Соотношения §7 как данные (design D-7; spec «Соотношения §7 — данные, проверка по колонкам»).""" + +from decimal import Decimal + +import pytest + +from src.contract.result import StatementLine, StatementValue +from src.parser.rules import Rules, load_rules +from src.parser.statements.checks import run_statement_checks + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def line(code: str, *amounts: str | None) -> StatementLine: + values = [StatementValue(column_index=index, amount=None if amount is None else Decimal(amount), confidence=100, is_recognized=True) for index, amount in enumerate(amounts)] + return StatementLine(line_code=code, line_code_confidence=100, line_name=code, line_name_confidence=100, values=values) + + +def test_balance_relations_hold_with_missing_codes_as_zero(rules: Rules) -> None: + lines = [line("1150", "100", "90"), line("1100", "100", "90"), line("1210", "50", "40"), line("1200", "50", "40"), line("1600", "150", "130"), line("1310", "150", "130"), line("1300", "150", "130"), line("1700", "150", "130")] + + checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules) + + assert checks.relations_failed == [] and checks.soft_relations_failed == [] and warnings == [] + + +def test_broken_total_reported_per_column(rules: Rules) -> None: + lines = [line("1100", "100", "100"), line("1200", "50", "50"), line("1600", "155", "150")] + + checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules) + + failed = [(item.column_index, item.relation, item.expected, item.actual) for item in checks.relations_failed] + assert (0, "1600 = 1100 + 1200", Decimal("155"), Decimal("150")) in failed + assert not any(item.column_index == 1 and item.relation == "1600 = 1100 + 1200" for item in checks.relations_failed) + assert any("1600 = 1100 + 1200" in warning and "колонка 0" in warning for warning in warnings) + + +def test_tolerance_one_unit(rules: Rules) -> None: + lines = [line("1100", "100"), line("1200", "50"), line("1600", "151"), line("1700", "151"), line("1300", "151")] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert checks.relations_failed == [] + + +def test_relation_holds_with_abs_terms(rules: Rules) -> None: + """Review Focus 3: 1С пишет расходы со знаком минус, 66н — в скобках; оба варианта сходятся.""" + one_c = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")] + brackets = [line("2110", "1000"), line("2120", "600"), line("2100", "400")] + + assert run_statement_checks(one_c, 1, "INCOME", rules)[0].relations_failed == [] + assert run_statement_checks(brackets, 1, "INCOME", rules)[0].relations_failed == [] + + +def test_soft_relation_and_sublines(rules: Rules) -> None: + lines = [line("2400", "-100"), line("2410", "-100"), line("2411", "-80"), line("2412", "-30"), line("1110", "10"), line("11101", "6"), line("11102", "3")] + + checks, warnings = run_statement_checks(lines, 1, "INCOME", rules) + balance, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert [item.relation for item in checks.soft_relations_failed] == ["2410 = 2411 + 2412"] + assert checks.relations_failed == [] + assert [item.relation for item in balance.soft_relations_failed] == ["1110 = 11101 + 11102"] + assert any("мягкое" in warning for warning in warnings) + + +def test_null_values_count_as_zero_and_empty_form_passes(rules: Rules) -> None: + lines = [line("1100", None), line("1200", None), line("1600", None)] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + assert checks.relations_failed == [] +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация** + +```python +# API/src/parser/statements/checks.py +"""Соотношения §7 по колонкам (design D-7): правила — `statement_checks.yaml`; отсутствующий код — 0; правая часть +считается как записано и с членами `abs_terms` по модулю; допуск `tolerance`. Жёсткие нарушения → `relations_failed`, +мягкие и «сумма подстрок = родитель» → `soft_relations_failed`; всё дублируется предупреждениями. +""" + +from collections.abc import Sequence +from decimal import Decimal + +from src.contract.result import FailedRelation, StatementChecks, StatementLine +from src.parser.rules import FormType, RelationRule, Rules + +__all__ = ["run_statement_checks"] + +_ZERO = Decimal("0") +_PARENT_LEN = 4 + + +def _amounts(lines: Sequence[StatementLine], column_count: int) -> list[dict[str, Decimal]]: + """Код → сумма по каждой колонке (null → 0).""" + columns: list[dict[str, Decimal]] = [{} for _ in range(column_count)] + for item in lines: + for value in item.values: + if 0 <= value.column_index < column_count: + columns[value.column_index][item.line_code] = value.amount if value.amount is not None else _ZERO + return columns + + +def _rhs(amounts: dict[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal: + total = _ZERO + for sign, code in rule.terms: + value = amounts.get(code, _ZERO) + if absolute and code in rule.abs_terms: + value = abs(value) + total += sign * value + return total + + +def _holds(amounts: dict[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]: + expected = amounts.get(rule.target, _ZERO) + plain = _rhs(amounts, rule, absolute=False) + if abs(expected - plain) <= tolerance: + return True, expected, plain + if rule.abs_terms: + with_abs = _rhs(amounts, rule, absolute=True) + if abs(expected - with_abs) <= tolerance: + return True, expected, with_abs + return False, expected, plain + + +def _subline_rules(lines: Sequence[StatementLine], form: FormType, rules: Rules) -> list[RelationRule]: + """«1110 = 11101 + 11102 + …» для каждого четырёхзначного кода формы (в диапазоне кодов этой формы), у которого + есть пятизначные подстроки.""" + low, high = rules.statement_headers.code_ranges[form] + codes = [item.line_code for item in lines] + rules_out: list[RelationRule] = [] + for parent in codes: + if len(parent) != _PARENT_LEN or not low <= int(parent) <= high: + continue + children = [code for code in codes if len(code) == _PARENT_LEN + 1 and code.startswith(parent)] + if children: + rules_out.append(RelationRule(form=form, relation=f"{parent} = {' + '.join(children)}", strict=False)) + return rules_out + + +def run_statement_checks( + lines: Sequence[StatementLine], column_count: int, form: FormType, rules: Rules +) -> tuple[StatementChecks, list[str]]: + config = rules.statement_checks + relations = list(config.relations_for(form)) + if config.subline_check: + relations.extend(_subline_rules(lines, form, rules)) + checks = StatementChecks() + warnings: list[str] = [] + for column_index, amounts in enumerate(_amounts(lines, column_count)): + for rule in relations: + holds, expected, actual = _holds(amounts, rule, config.tolerance) + if holds: + continue + failed = FailedRelation(column_index=column_index, relation=rule.relation, expected=expected, actual=actual) + if rule.strict: + checks.relations_failed.append(failed) + warnings.append(f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}") + else: + checks.soft_relations_failed.append(failed) + warnings.append(f"мягкое соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}") + return checks, warnings +``` + +`RelationRule` в `_subline_rules` требует четырёхзначные коды в `relation` по регулярке `_RELATION` — пятизначные подстроки не пройдут. Ослабить `_RELATION`/`_TERM` в `rules.py` до `\d{4,5}` (справа), цель — `\d{4}`. Обновить тест задачи 4, если нужно. + +- [ ] **Step 4: Прогнать, lint** + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser/statements/checks.py api/src/parser/rules.py api/tests +git -C T commit -m "feat(statements): соотношения §7 из словаря — жёсткие/мягкие, abs_terms, допуск, подстроки + +Refs #14" +``` + +--- + +### Task 12: `statements/statement.py` + `merge.py` — `StatementParser`, склейка, статус; подключение в `get_parser` + +**Files:** +- Create: `API/src/parser/statements/statement.py`, `API/src/parser/statements/merge.py` +- Modify: `API/src/parser/__init__.py` (`get_parser` → `StatementParser(rules)`), `API/src/parser/book.py` (удалить `SkippingStatementParser`), `API/tests/unit/parser/test_book_parser.py` (тест заглушки → тест реального конвейера), `API/tests/fixtures/test_rules_coverage.py` +- Test: `API/tests/unit/parser/statements/test_statement.py` + +**Interfaces:** +- Consumes: всё из задач 5–11, `SheetKind`, `Deadline`. +- Produces: `StatementParser(rules)` с `parse_sheets(sheets: Sequence[tuple[Sheet, SheetKind]], deadline) -> tuple[list[StatementDocument], list[str], bool]`; `LOCATOR_PREFIX = "sheet:"`, `LOCATOR_SEPARATOR = ","`; предупреждения вида `sheet=<имя> …`; константы `CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом"`, `NO_CODE_COLUMN_WARNING`. + +- [ ] **Step 1: Тест** + +```python +# API/tests/unit/parser/statements/test_statement.py +"""Конвейер форм: блоки, склейка продолжений, статус документа (design D-8; spec «Склейка листов-продолжений и статус +документа»). Книги собираются `gridbuilder`, вид листа задаётся явно, как это делает `BookParser`.""" + +from decimal import Decimal + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.rules import Rules, load_rules +from src.parser.statements.statement import CONTINUATION_WARNING, StatementParser + + +@pytest.fixture(scope="module") +def parser() -> StatementParser: + return StatementParser(load_rules()) + + +def deadline() -> Deadline: + return Deadline(120) + + +BALANCE_PAGE_1 = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + ["Форма по ОКУД", "0710001"], + ["Организация", 'ООО "Пример"', "ИНН", "7700000000"], + ["Единица измерения:", "тыс. руб."], + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Основные средства", "1150", 100, 90], + ["Итого по разделу I", "1100", 100, 90], + ["Запасы", "1210", 50, 40], + ["Итого по разделу II", "1200", 50, 40], + ["БАЛАНС", "1600", 150, 130], +] +BALANCE_PAGE_2 = [ + [*at({6: "Форма 0710001 с.2"})], + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Уставный капитал", "1310", 150, 130], + ["Итого по разделу III", "1300", 150, 130], + ["БАЛАНС", "1700", 150, 130], +] +INCOME = [ + ["Отчет о финансовых результатах"], + ["за 2024 г."], + ["Организация", 'ООО "Пример"'], + ["Единица измерения:", "тыс. руб."], + ["Наименование показателя", "Код", "За 2024 г."], + ["Выручка", "2110", 1000], + ["Себестоимость продаж", "2120", -600], + ["Валовая прибыль (убыток)", "2100", 400], + ["Коммерческие расходы", "2210", -50], + ["Управленческие расходы", "2220", -50], + ["Прибыль (убыток) от продаж", "2200", 300], + ["Проценты к уплате", "2330", -100], + ["Прибыль (убыток) до налогообложения", "2300", 200], + ["Налог на прибыль", "2410", -40], + ["Чистая прибыль (убыток)", "2400", 160], +] +# Полный набор: 2200 = 2100 − 2210 − 2220 и 2300 = 2200 − 2330 сходятся через abs_terms (знаки 1С), 2400 — мягкое +NPZ_CONTINUATION = [ + ["в том числе:"], + ["Биологические активы", "11901", 0, 0], + ["Расходы будущих периодов", "11903", 0, 0], + ["Прочие внеоборотные активы", "1190", 0, 0], +] + + +def test_full_balance_over_two_sheets_is_done(parser: StatementParser) -> None: + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), SheetKind.STATEMENT), (sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, warnings, ok = parser.parse_sheets(sheets, deadline()) + + assert ok and len(documents) == 1 + document = documents[0] + assert document.document_type == "BALANCE" and document.locator == "sheet:ББ1,ББ2" + assert [line.line_code for line in document.lines] == ["1150", "1100", "1210", "1200", "1600", "1310", "1300", "1700"] + assert document.checks is not None and document.checks.relations_failed == [] + assert document.header.inn == "7700000000" and document.header.units == "тыс. руб." + assert len(document.columns) == 2 and document.columns[0].label == "На 31 декабря 2024 г." + + +def test_page_one_alone_fails_total_relation(parser: StatementParser) -> None: + documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_1), SheetKind.STATEMENT)], deadline()) + + assert not ok + assert [item.relation for item in documents[0].checks.relations_failed] == ["1600 = 1700", "1600 = 1700"] + assert any("1600 = 1700" in warning for warning in warnings) + + +def test_lonely_continuation_is_needs_check(parser: StatementParser) -> None: + documents, warnings, ok = parser.parse_sheets([(sheet_from_rows(BALANCE_PAGE_2, "с.2"), SheetKind.STATEMENT_CONTINUATION)], deadline()) + + assert not ok and documents[0].document_type == "BALANCE" + assert documents[0].header.organization_name is None and documents[0].header.form_name == "Форма 0710001 с.2" + assert [line.line_code for line in documents[0].lines] == ["1310", "1300", "1700"] + assert f"sheet=с.2 {CONTINUATION_WARNING}" in warnings + + +def test_continuation_joins_last_open_document(parser: StatementParser) -> None: + """Review Focus 4: продолжение после ОФР относится к ОФР, а не к балансу.""" + income_tail = [["Прочее", "2460", 0], ["Совокупный финансовый результат периода", "2500", 160]] + sheets = [ + (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), + (sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), + (sheet_from_rows(income_tail, "ОФР_2"), SheetKind.STATEMENT_CONTINUATION), + ] + + documents, _, _ = parser.parse_sheets(sheets, deadline()) + + assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] + assert documents[1].locator == "sheet:ОФР,ОФР_2" and [line.line_code for line in documents[1].lines][-2:] == ["2460", "2500"] + + +def test_continuation_without_header_maps_value_columns_by_order(parser: StatementParser) -> None: + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), (sheet_from_rows(NPZ_CONTINUATION, "ББ_2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, _, _ = parser.parse_sheets(sheets, deadline()) + + tail = documents[0].lines[-1] + assert tail.line_code == "1190" and [value.column_index for value in tail.values] == [0, 1] + assert [line.line_code for line in documents[0].lines][-3:] == ["11901", "11903", "1190"] + + +def test_continuation_with_other_column_count_opens_new_document(parser: StatementParser) -> None: + three_columns = [["Форма 0710001 с.2"], ["Наименование", "Код", "На 31.12.2024", "На 31.12.2023", "На 31.12.2022"], ["Уставный капитал", "1310", 1, 1, 1]] + sheets = [(sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), (sheet_from_rows(three_columns, "с.2"), SheetKind.STATEMENT_CONTINUATION)] + + documents, warnings, ok = parser.parse_sheets(sheets, deadline()) + + assert len(documents) == 2 and not ok and any(CONTINUATION_WARNING in warning for warning in warnings) + + +def test_two_forms_on_one_sheet(parser: StatementParser) -> None: + sheet = sheet_from_rows([*BALANCE_PAGE_1, *BALANCE_PAGE_2[2:], [], *INCOME], "Отчетность") + + documents, _, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert [document.document_type for document in documents] == ["BALANCE", "INCOME"] and ok + assert all(document.locator == "sheet:Отчетность" for document in documents) + assert "2110" not in [line.line_code for line in documents[0].lines] + + +def test_disclosure_only_book_is_done(parser: StatementParser) -> None: + """Review Focus 5.""" + sheet = sheet_from_rows([["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["1. Нематериальные активы"], ["текст"]], "Пояснения") + + documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert ok and documents[0].document_type == "DISCLOSURE" and documents[0].lines == [] and documents[0].checks is None + + +def test_no_code_column_is_needs_check_with_warning(parser: StatementParser) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["Организация", "ООО Х"], ["Показатель", "На 31.12.2024"], ["Основные средства", 100], ["Запасы", 50], ["БАЛАНС", 150]], "ББ") + + documents, warnings, ok = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].lines == [] and documents[0].columns == [] + assert "sheet=ББ колонка кодов не найдена" in warnings + + +def test_duplicate_code_is_needs_check(parser: StatementParser) -> None: + rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1]] + + documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].checks.duplicate_line_codes == ["1210"] + + +def test_template_without_sums_is_needs_check(parser: StatementParser) -> None: + rows = [["Бухгалтерский баланс"], ["Наименование показателя", "Код", "На 31 декабря 2024 г."], ["Основные средства", "1150"], ["Запасы", "1210"], ["БАЛАНС", "1600"]] + + documents, _, ok = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline()) + + assert not ok and documents[0].checks.relations_failed == [] and all(value.amount is None for line in documents[0].lines for value in line.values) +``` + +- [ ] **Step 2: Запустить — падает** + +- [ ] **Step 3: Реализация `merge.py` и `statement.py`** + +```python +# API/src/parser/statements/merge.py +"""Черновик документа формы и присоединение листов-продолжений (design D-8).""" + +from dataclasses import dataclass, field + +from src.contract.result import StatementHeader, StatementLine +from src.parser.rules import FormType +from src.parser.statements.columns import PeriodColumn, TableLayout + +__all__ = ["Draft", "can_join", "join"] + + +@dataclass +class Draft: + kind: FormType | None # None — продолжение без ОКУД в маркере → OTHER + header: StatementHeader + periods: tuple[PeriodColumn, ...] + sheet_names: list[str] + lines: list[StatementLine] = field(default_factory=list) + duplicates: list[str] = field(default_factory=list) + has_code_column: bool = True + lonely_continuation: bool = False + fractional_values: int = 0 + numeric_values: int = 0 + warnings: list[str] = field(default_factory=list) + + @property + def codes(self) -> set[str]: + return {line.line_code for line in self.lines} + + +def can_join(draft: Draft, layout: TableLayout) -> bool: + """Продолжение присоединяется, если число колонок-сумм совпало с открытым документом.""" + return draft.has_code_column and len(layout.periods) == len(draft.periods) + + +def join(draft: Draft, sheet_name: str, lines: list[StatementLine], duplicates: list[str]) -> None: + """Строки продолжения в конец документа; коды, уже бывшие в документе, — в дубликаты.""" + for line in lines: + if line.line_code in draft.codes: + draft.duplicates.append(line.line_code) + continue + draft.lines.append(line) + draft.duplicates.extend(duplicates) + draft.sheet_names.append(sheet_name) +``` + +```python +# API/src/parser/statements/statement.py +"""Конвейер форм (design D-3…D-8): листы книги по порядку → блоки форм → шапка, таблица, строки → продолжения → +соотношения §7 → `statementDocument`. `ok` документа: жёсткие соотношения сошлись, дубликатов нет, колонка кодов +найдена, есть колонка-период, в шапке есть организация или ИНН, документ не одинокое продолжение. DISCLOSURE на +статус не влияет. +""" + +from collections.abc import Sequence + +from src.contract.result import StatementColumn, StatementDocument, StatementHeader +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.rules import Rules +from src.parser.statements.checks import run_statement_checks +from src.parser.statements.columns import NO_CODE_COLUMN, LayoutNotFoundError, TableLayout, find_layout +from src.parser.statements.detect import FormBlock, continuation_form, find_forms +from src.parser.statements.header import parse_header +from src.parser.statements.lines import collect_lines +from src.parser.statements.merge import Draft, can_join, join + +__all__ = ["CONTINUATION_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"] + +LOCATOR_PREFIX = "sheet:" +LOCATOR_SEPARATOR = "," +CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом" +SKIPPED_COLUMN_WARNING = "колонка без периода пропущена" + + +def _line(sheet: Sheet, text: str) -> str: + return f"sheet={sheet.name} {text}" + + +class StatementParser: + def __init__(self, rules: Rules) -> None: + self._rules = rules + + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: + drafts: list[Draft] = [] + warnings: list[str] = [] + for sheet, kind in sheets: + deadline.check(f"лист «{sheet.name}»: форма") + if kind is SheetKind.STATEMENT: + blocks, block_warnings = find_forms(sheet, self._rules) + warnings.extend(_line(sheet, text) for text in block_warnings) + for block in blocks: + drafts.append(self._block_draft(sheet, block, warnings)) + else: + self._continuation(sheet, drafts, warnings) + documents = [self._document(draft) for draft in drafts] + for draft, document in zip(drafts, documents, strict=True): + warnings.extend(draft.warnings) + ok = all(self._is_ok(draft, document) for draft, document in zip(drafts, documents, strict=True)) + return documents, warnings, ok + + # --- блоки --- + + def _block_draft(self, sheet: Sheet, block: FormBlock, warnings: list[str]) -> Draft: + if block.kind == "DISCLOSURE": + header, _ = parse_header(sheet, self._rules, block.first_row, block.last_row, block) + return Draft(kind=block.kind, header=header, periods=(), sheet_names=[sheet.name]) + try: + layout = find_layout(sheet, self._rules, block.first_row, block.last_row) + except LayoutNotFoundError: + header, header_warnings = parse_header(sheet, self._rules, block.first_row, block.last_row, block) + warnings.extend(_line(sheet, text) for text in [NO_CODE_COLUMN, *header_warnings]) + return Draft(kind=block.kind, header=header, periods=(), sheet_names=[sheet.name], has_code_column=False) + header_last = (layout.header_row or layout.first_data_row) - 1 + header, header_warnings = parse_header(sheet, self._rules, block.first_row, header_last, block) + draft = Draft(kind=block.kind, header=header, periods=layout.periods, sheet_names=[sheet.name]) + self._fill(sheet, draft, layout, layout.first_data_row, block.last_row, header_warnings, warnings) + return draft + + def _continuation(self, sheet: Sheet, drafts: list[Draft], warnings: list[str]) -> None: + try: + layout = find_layout(sheet, self._rules, 1, sheet.n_rows) + except LayoutNotFoundError: + warnings.append(_line(sheet, NO_CODE_COLUMN)) + return + open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None) + if open_draft is not None and can_join(open_draft, layout): + result = collect_lines(sheet, self._rules, layout, layout.first_data_row, sheet.n_rows, units=open_draft.header.units) + join(open_draft, sheet.name, result.lines, result.duplicates) + open_draft.fractional_values += result.fractional_values + open_draft.numeric_values += result.numeric_values + warnings.extend(_line(sheet, text) for text in result.warnings) + return + kind, marker = continuation_form(sheet, self._rules) + header, _ = parse_header(sheet, self._rules, 1, (layout.header_row or layout.first_data_row) - 1, None) + draft = Draft(kind=kind, header=header, periods=layout.periods, sheet_names=[sheet.name], lonely_continuation=True) + warnings.append(_line(sheet, CONTINUATION_WARNING)) + self._fill(sheet, draft, layout, layout.first_data_row, sheet.n_rows, [], warnings) + drafts.append(draft) + + def _fill( # noqa: PLR0913 - все части документа передаются явно + self, sheet: Sheet, draft: Draft, layout: TableLayout, first_row: int, last_row: int, header_warnings: list[str], warnings: list[str] + ) -> None: + result = collect_lines(sheet, self._rules, layout, first_row, last_row, units=draft.header.units) + draft.lines = result.lines + draft.duplicates = result.duplicates + draft.fractional_values, draft.numeric_values = result.fractional_values, result.numeric_values + texts = [*header_warnings, *result.warnings] + if layout.skipped_labels: + texts.append(f"{SKIPPED_COLUMN_WARNING}: {', '.join(layout.skipped_labels)}") + warnings.extend(_line(sheet, text) for text in texts) + + # --- документ --- + + def _document(self, draft: Draft) -> StatementDocument: + kind = draft.kind or "OTHER" + columns = [StatementColumn(column_index=index, label=column.label, period_date=column.period_date) for index, column in enumerate(draft.periods)] + locator = LOCATOR_PREFIX + LOCATOR_SEPARATOR.join(draft.sheet_names) + if kind == "DISCLOSURE" or not draft.has_code_column: + return StatementDocument(document_type=kind, locator=locator, header=draft.header, columns=columns if draft.has_code_column else [], lines=[]) + checks, check_warnings = run_statement_checks(draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules) + checks.duplicate_line_codes = list(dict.fromkeys(draft.duplicates)) + draft.warnings.extend(f"sheet={draft.sheet_names[0]} {text}" for text in check_warnings) + return StatementDocument(document_type=kind, locator=locator, header=draft.header, columns=columns, lines=draft.lines, checks=checks) + + @staticmethod + def _is_ok(draft: Draft, document: StatementDocument) -> bool: + if document.document_type == "DISCLOSURE": + return True + header: StatementHeader = document.header + return ( + draft.has_code_column + and not draft.lonely_continuation + and bool(document.columns) + and bool(header.organization_name or header.inn) + and document.checks is not None + and not document.checks.relations_failed + and not document.checks.duplicate_line_codes + ) +``` + +Для `kind == "OTHER"` (продолжение без ОКУД в маркере) соотношения считаются по правилам ББ — зафиксировать как решение без обсуждения в design (таких документов `ok` всё равно нет: одинокое продолжение). + +`get_parser`: заменить `SkippingStatementParser()` на `StatementParser(rules)` (`from src.parser.statements.statement import StatementParser`); удалить `SkippingStatementParser` из `book.py`, тест заглушки в `test_book_parser.py` переписать: книга с листом баланса даёт `StatementDocument` (как в `test_statement_sheets_go_to_statement_parser_in_sheet_order`, но без фейка). `tests/fixtures/test_rules_coverage.py`: `statements=StatementParser(rules)`. + +- [ ] **Step 4: `make check`; прогнать CLI на реальных файлах (ручная сверка, без golden)** + +```bash +cd API && for f in ../../fixtures/statements-xlsx/npz/2025-Q3/*.xlsx ../../fixtures/statements-xlsx/mezheninovskaya/2025-Q3/*.xlsx "../../fixtures/statements-xlsx/unagrande-co/2024-FY/Б.Б Лист 1.xls"; do ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "$f" | .venv/bin/python -c "import json,sys; r=json.load(sys.stdin); print(r['status'], [(d['document_type'], d['locator'][:40], len(d.get('lines',[])), d.get('checks',{}).get('relations_failed')) for d in r['documents']]); print(*r['metrics']['warnings'], sep='\n')"; done +``` +Expected: НПЗ — `done`, BALANCE (5 листов) и INCOME (3 листа), `relations_failed = []`; Межениновская — `done`; Унагранде стр. 1 — `needs_check` с `1600 = 1700`. Расхождения разбирать здесь (systematic-debugging), а не подгонять ожидания. + +- [ ] **Step 5: Commit** + +```bash +git -C T add api/src/parser api/tests +git -C T commit -m "feat(statements): конвейер форм — блоки, склейка продолжений, статус документа; подключение в get_parser + +Refs #14" +``` + +--- + +### Task 13: Синтетика форм `st01…st05` + +**Files:** +- Create: `API/tests/synthetic/generate_statements.py` +- Modify: `API/tests/fixtures_helpers.py` (`statement_files`, `statement_synthetic_files`, `STATEMENTS_DIR = "statements-xlsx"`), `API/tests/conftest.py` (параметры `statement_file`, `statement_synthetic_file`) +- Test: `API/tests/synthetic/test_generate_statements.py` (детерминизм, имена), `API/tests/synthetic/test_statement_synthetic_files.py` (сценарии спеки на файлах, маркер `fixtures`) +- Fixtures (репозиторий arm-ka-fixtures, worktree `.wt/slice-2b/fixtures-wt`, ветка `feat/14-statements-golden`): `statements-xlsx/synthetic/st0*.xlsx`, `statements-xlsx/synthetic/README.md` + +**Interfaces:** +- Produces: `generate_statements.FILE_NAMES`, `generate_all(directory)`, `main(argv)`; `fixtures_helpers.statement_files(root) -> list[Path]` (все книги `statements-xlsx/**`, кроме `synthetic/`; `templates/` входят), `statement_synthetic_files(root)`. + +- [ ] **Step 1: Worktree fixtures** + +```bash +W=/Users/andrey.epifancev/projects/awp-ca +git -C $W/fixtures fetch origin && git -C $W/fixtures worktree add -b feat/14-statements-golden $W/.wt/slice-2b/fixtures-wt origin/main +rm $W/.wt/slice-2b/fixtures && ln -s fixtures-wt $W/.wt/slice-2b/fixtures +``` + +- [ ] **Step 2: Тест генератора** + +```python +# API/tests/synthetic/test_generate_statements.py +from pathlib import Path + +from tests.synthetic.generate_statements import FILE_NAMES, generate_all + +EXPECTED = ("st01_both_forms_one_sheet.xlsx", "st02_csv_like.xlsx", "st03_no_codes.xlsx", "st04_mixed_book.xlsx", "st05_continuation_only.xlsx") + + +def test_names_and_determinism(tmp_path: Path) -> None: + assert FILE_NAMES == EXPECTED + first = {path.name: path.read_bytes() for path in generate_all(tmp_path / "a")} + second = {path.name: path.read_bytes() for path in generate_all(tmp_path / "b")} + assert first == second and set(first) == set(EXPECTED) +``` + +- [ ] **Step 3: Генератор** + +```python +# API/tests/synthetic/generate_statements.py +"""Синтетические формы ББ/ОФР (Срез 2b): `python -m tests.synthetic.generate_statements <каталог>` из `api/`. + +st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две +строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ +и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`). +""" + +import argparse +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +from tests.synthetic.generate import ORG, SheetSpec, build_osv, OsvSpec, base_tree, to_xlsx + +__all__ = ["FILE_NAMES", "generate_all", "main"] + +BALANCE_LINES = [("Основные средства", "1150", 100, 90), ("Итого по разделу I", "1100", 100, 90), ("Запасы", "1210", 50, 40), ("Итого по разделу II", "1200", 50, 40), ("БАЛАНС", "1600", 150, 130), ("Уставный капитал", "1310", 150, 130), ("Итого по разделу III", "1300", 150, 130), ("БАЛАНС", "1700", 150, 130)] +INCOME_LINES = [ + ("Выручка", "2110", 1000, 900), ("Себестоимость продаж", "2120", "(600)", "(500)"), ("Валовая прибыль (убыток)", "2100", 400, 400), + ("Коммерческие расходы", "2210", "(50)", "(40)"), ("Управленческие расходы", "2220", "(50)", "(60)"), ("Прибыль (убыток) от продаж", "2200", 300, 300), + ("Проценты к уплате", "2330", "(100)", "(100)"), ("Прибыль (убыток) до налогообложения", "2300", 200, 200), + ("Налог на прибыль", "2410", "(40)", "(40)"), ("Чистая прибыль (убыток)", "2400", 160, 160), +] # полный набор: все жёсткие соотношения ОФР сходятся; скобки — запись 66н + + +def _head(sheet: SheetSpec, row: int, title: str, subtitle: str, okud: str) -> int: + sheet.put(row, 2, title) + sheet.put(row + 1, 2, subtitle) + sheet.put(row + 2, 6, "Форма по ОКУД") + sheet.put(row + 2, 7, okud) + sheet.put(row + 3, 1, "Организация") + sheet.put(row + 3, 3, ORG) + sheet.put(row + 3, 6, "ИНН") + sheet.put(row + 3, 7, "7700000000") + sheet.put(row + 4, 1, "Единица измерения:") + sheet.put(row + 4, 3, "тыс. руб.") + return row + 6 + + +def _table(sheet: SheetSpec, row: int, labels: Sequence[str], lines: Sequence[tuple], *, caption: str | None = "Код") -> int: + sheet.put(row, 2, "Наименование показателя") + if caption: + sheet.put(row, 4, caption) + for offset, label in enumerate(labels): + sheet.put(row, 5 + offset, label) + for index, (name, code, *values) in enumerate(lines, start=row + 1): + sheet.put(index, 2, name) + sheet.put(index, 4, code) + for offset, value in enumerate(values): + sheet.put(index, 5 + offset, value) + return row + 1 + len(lines) + + +def _st01() -> list[SheetSpec]: + sheet = SheetSpec("Отчетность") + row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + row = _table(sheet, row, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES) + row = _head(sheet, row + 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002") + _table(sheet, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES) + return [sheet] + + +def _st02() -> list[SheetSpec]: + sheet = SheetSpec("Лист1") + sheet.put(1, 1, "Бухгалтерский баланс") + sheet.put(2, 1, "Организация") + sheet.put(2, 2, 'ООО "Синтетика"') + sheet.put(3, 1, "ИНН") + sheet.put(3, 2, "7700000001") + sheet.put(5, 1, "Показатель") + sheet.put(5, 3, "На 31 декабря") + sheet.put(5, 4, "На 31 декабря") + sheet.put(5, 5, "Изменение, %") + sheet.put(6, 3, "2024 г.") + sheet.put(6, 4, "2023 г.") + for index, (name, code, current, previous) in enumerate(BALANCE_LINES, start=7): + sheet.put(index, 1, name) + sheet.put(index, 2, code) + sheet.put(index, 3, current if code != "1210" else "(50)") + sheet.put(index, 4, previous) + sheet.put(index, 5, 5) + return [sheet] + + +def _st03() -> list[SheetSpec]: + sheet = SheetSpec("ББ") + row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + sheet.put(row, 2, "Показатель") + sheet.put(row, 5, "На 31 декабря 2024 г.") + for index, (name, _, current, _) in enumerate(BALANCE_LINES, start=row + 1): + sheet.put(index, 2, name) + sheet.put(index, 5, current) + return [sheet] + + +def _st04() -> list[SheetSpec]: + balance = SheetSpec("Баланс") + row = _head(balance, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001") + _table(balance, row, ["На 31 декабря 2024 г."], [(name, code, current) for name, code, current, _ in BALANCE_LINES]) + return [build_osv(OsvSpec(), base_tree()), balance] + + +def _st05() -> list[SheetSpec]: + sheet = SheetSpec("Лист_1") + sheet.put(1, 8, "Форма 0710001 с.2") + _table(sheet, 2, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES[5:]) + return [sheet] + + +FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = { + "st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx), + "st02_csv_like.xlsx": (_st02, to_xlsx), + "st03_no_codes.xlsx": (_st03, to_xlsx), + "st04_mixed_book.xlsx": (_st04, to_xlsx), + "st05_continuation_only.xlsx": (_st05, to_xlsx), +} +FILE_NAMES = tuple(FILES) + + +def generate_all(directory: Path) -> list[Path]: + directory.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for name, (build, serialize) in FILES.items(): + path = directory / name + path.write_bytes(serialize(build())) + written.append(path) + return written + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st05.") + parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic") + args = parser.parse_args(argv) + for path in generate_all(args.directory): + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Проверить имена экспортов `generate.py` (`build_osv`, `OsvSpec`, `base_tree`, `to_xlsx`, `ORG`, `SheetSpec`) — они есть в ветке #9/#11; при необходимости добавить в `__all__`. + +- [ ] **Step 4: Хелперы и conftest** + +`fixtures_helpers.py`: + +```python +STATEMENTS_DIR = "statements-xlsx" + + +def statement_files(root: Path) -> list[Path]: + """Книги форм `root/statements-xlsx/**` (включая `templates/`), кроме `synthetic/`; отсортированы.""" + directory = root / STATEMENTS_DIR + if not directory.is_dir(): + return [] + synthetic = directory / _SYNTHETIC + return sorted(path for path in directory.rglob("*") if _is_workbook(path, directory) and synthetic not in path.parents) + + +def statement_synthetic_files(root: Path) -> list[Path]: + directory = root / STATEMENTS_DIR / _SYNTHETIC + if not directory.is_dir(): + return [] + return sorted(path for path in directory.iterdir() if _is_workbook(path, directory)) +``` + +`conftest.py::pytest_generate_tests`: `"statement_file": (statement_files, fixtures_skip_reason)`, `"statement_synthetic_file": (statement_synthetic_files, lambda: fixtures_skip_reason() or statement_synthetic_skip_reason())` (добавить `statement_synthetic_skip_reason` по образцу `synthetic_skip_reason`). + +- [ ] **Step 5: Сгенерировать синтетику в fixtures, тест сценариев** + +```bash +cd API && .venv/bin/python -m tests.synthetic.generate_statements ../../fixtures/statements-xlsx/synthetic +``` + +`API/tests/synthetic/test_statement_synthetic_files.py` (маркер `fixtures`): для каждого файла по имени — ожидания из сценариев спеки: st01 → два документа BALANCE и INCOME с одним `locator`, строк ОФР нет в балансе; st02 → колонки `2024-12-31/2023-12-31`, предупреждение «колонка без периода пропущена: Изменение, %», `1210` = `-50.00`, организация и ИНН найдены, единицы `None` с предупреждением; статус книги `done`, потому что единицы в критерий `ok` не входят, а `1600 = 1700` сходится; st03 → `needs_check`, предупреждение «колонка кодов не найдена», `lines == []`; st04 → `OsvDocument` + `StatementDocument`, книга `done`; st05 → `needs_check`, `CONTINUATION_WARNING`, строки 1310/1300/1700. Писать ожидания из содержимого генератора, не из вывода парсера. + +- [ ] **Step 6: `make check`, commit в tabular-parser и fixtures** + +```bash +git -C T add api/tests +git -C T commit -m "test(statements): синтетика st01…st05 и сценарии спеки на ней; параметры statement_file + +Refs #14" +git -C $W/.wt/slice-2b/fixtures-wt add statements-xlsx/synthetic +git -C $W/.wt/slice-2b/fixtures-wt commit -m "test(statements): синтетика форм st01…st05 (arm-tabular-parser#14)" +``` + +`statements-xlsx/synthetic/README.md` — по образцу `osv/synthetic/README.md`: таблица «файл → что проверяет». + +--- + +### Task 14: Реальные файлы — манифест, property-тест, golden `expected_statement_parse/`, покрытие словарей + +**Files:** +- Create: `API/tests/statements_manifest.yaml`, `API/tests/statement_helpers.py`, `API/tests/fixtures/test_statements_property.py` +- Modify: `API/tests/golden_helpers.py` (снимки форм), `API/scripts/golden_update.py` (файлы форм), `API/tests/fixtures/test_golden.py` (+2 теста), `API/tests/rules_coverage.py` (`DICTIONARIES` + 3), `API/tests/fixtures/test_rules_coverage.py` (файлы форм в прогон), `API/tests/unit/test_golden_helpers.py` +- Fixtures: `golden/expected_statement_parse/**` (новые), `golden/expected_osv_parse/**` (поля `period_start/period_end/is_from_year_start`, текст предупреждения s06, s12/s14 — документы форм) + +**Interfaces:** +- Produces: `statement_helpers.load_manifest() -> dict[str, Expectation]` (ключ — путь относительно `statements-xlsx/`), `Expectation(organization: str | None, inn: str | None, documents: list[{type, status: done|needs_check, complete: bool}])`; `golden_helpers.golden_path` для файлов `statements-xlsx/**` → `golden/expected_statement_parse/<путь>.json`. + +- [ ] **Step 1: Манифест** + +```yaml +# API/tests/statements_manifest.yaml +# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. +# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). +# Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. +files: + "unagrande-co/2024-FY/Б.Б Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится"}] + "unagrande-co/2024-FY/Б.Б. Лист 2.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-co/2024-FY/ОФР. Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: [{type: INCOME, status: done, complete: true}] + "unagrande-co/2024-FY/ОФР. Лист 2 .xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2024-FY/ББ. Лист 1 .xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}] + "unagrande-group/2024-FY/ББ. Лист 2 (2).xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2024-FY/ОФР. Лист 2.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет"}] + "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}] + "unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: INCOME, status: done, complete: true}] + "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}] + "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": + organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + inn: "2315995868" + documents: + - {type: BALANCE, status: done, complete: true, sheets: 5} + - {type: INCOME, status: done, complete: true, sheets: 3} + "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: BALANCE, status: done, complete: true}] + "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: INCOME, status: done, complete: true}] + "templates/ББ до 01.01.2025.xlsx": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки"}] + "templates/ББ после 01.01.2025.xlsx": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон"}] + "templates/ОФР_до 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}] + "templates/ОФР_после 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}] +``` + +Значения `organization`/`inn` ОФР Унагранде и типы шаблонов проверить по файлам при первом прогоне: расхождение — повод разобраться (CLI, `systematic-debugging`), а не переписать манифест. У ОФР НПЗ ИНН в блоке нет: манифест задаёт `inn` на файл и применяется к документам, у которых ИНН найден; для `INCOME` НПЗ допускается `None` — поле `inn_optional_for: [INCOME]` в записи НПЗ. + +- [ ] **Step 2: Property-тест** + +```python +# API/tests/fixtures/test_statements_property.py +"""Инварианты форм на всех `../fixtures/statements-xlsx/**` по манифесту (spec «Словари форм, координаты, golden»).""" + +import time +from pathlib import Path +from typing import cast + +import pytest +from fixtures_helpers import statement_files +from result_contract import assert_valid_result +from statement_helpers import load_manifest, relative_key + +from src.contract import FileType, ResultStatus, StatementDocument +from src.parser import get_parser + +pytestmark = pytest.mark.fixtures +EXPECTED_FILES = 19 +MAX_PARSE_SECONDS = 5 + + +def test_manifest_covers_every_file(fixtures_dir: Path) -> None: + files = statement_files(fixtures_dir) + assert len(files) == EXPECTED_FILES + assert {relative_key(fixtures_dir, path) for path in files} == set(load_manifest()) + + +def test_statement_file_matches_manifest(fixtures_dir: Path, statement_file: Path) -> None: + expectation = load_manifest()[relative_key(fixtures_dir, statement_file)] + started = time.monotonic() + outcome = get_parser().parse(statement_file.read_bytes(), cast(FileType, statement_file.suffix.lstrip(".").upper())) + assert time.monotonic() - started < MAX_PARSE_SECONDS + assert outcome.error is None, outcome.error + documents = [document for document in outcome.documents if isinstance(document, StatementDocument)] + assert [document.document_type for document in documents] == [item.type for item in expectation.documents] + assert outcome.status is (ResultStatus.DONE if all(item.status == "done" for item in expectation.documents) else ResultStatus.NEEDS_CHECK) + for document, item in zip(documents, expectation.documents, strict=True): + codes = [line.line_code for line in document.lines] + assert len(codes) == len(set(codes)), "коды уникальны" + assert all(value.column_index < len(document.columns) for line in document.lines for value in line.values) + if item.sheets: + assert document.locator.count(",") == item.sheets - 1 + if item.complete: + assert document.checks is not None and document.checks.relations_failed == [], document.checks + assert document.columns and all(column.period_date is not None for column in document.columns) + if expectation.organization and document.header.organization_name is not None: + assert document.header.organization_name == expectation.organization + if expectation.inn and document.document_type not in expectation.inn_optional_for: + assert document.header.inn == expectation.inn + assert_valid_result(outcome) # проверить сигнатуру хелпера в tests/result_contract.py +``` + +`statement_helpers.py`: pydantic-модели `DocumentExpectation(type, status, complete, reason=None, sheets=None)`, `Expectation(organization=None, inn=None, inn_optional_for=(), documents)`, `load_manifest()` (кэш через `functools.cache`), `relative_key(fixtures_dir, path) -> str`. + +- [ ] **Step 3: Golden для форм** + +`golden_helpers.py`: константы `STATEMENT_SNAPSHOT_DIR = "expected_statement_parse"`, `STATEMENTS_DIR = "statements-xlsx"`; `golden_path`: если файл под `statements-xlsx` → `golden/expected_statement_parse/<путь относительно statements-xlsx без расширения>.json`; `_mirrored`/`_source_part`: части `statements` и `statements-synthetic`; `sync_snapshots` обходит оба каталога снимков (`expected_osv_parse`, `expected_statement_parse`) при поиске лишних. `scripts/golden_update.py`: `files = [..., *statement_files(root), *statement_synthetic_files(root)]`. `test_golden.py`: `test_statement_file_matches_golden_snapshot(statement_file)`, `test_statement_synthetic_matches_golden_snapshot(statement_synthetic_file)`, `test_every_snapshot_has_a_source_file` — учесть второй каталог. `tests/unit/test_golden_helpers.py`: путь снимка для файла форм. + +- [ ] **Step 4: Покрытие словарей и линтер** + +`tests/rules_coverage.py::DICTIONARIES` + `"statement_headers": "statement_headers.yaml"`, `"statement_periods": "statement_periods.yaml"`, `"statement_checks": "statement_checks.yaml"`. `test_rules_coverage.py`: в `files` добавить `statement_files` и `statement_synthetic_files`; `BookParser(rules, osv=OsvParser(rules), statements=StatementParser(rules), …)`. Мёртвые записи (например, `^расшифровк`, `отчет о целевом использовании`, `for_year` без «за») — удалить из YAML либо покрыть синтетикой (добавить лист в st01/st04); решение фиксировать в ledger. `test_no_coordinates.py` — убедиться, что обходит `statements/` (PARSER_ROOT рекурсивно): добавить тест «в списке проверенных файлов есть `statements/columns.py` и `book.py`». + +- [ ] **Step 5: Обновить golden одним прогоном и просмотреть diff** + +```bash +make -C T golden-update +git -C $W/.wt/slice-2b/fixtures-wt status --short | head -40 +git -C $W/.wt/slice-2b/fixtures-wt diff --stat | tail -3 +git -C $W/.wt/slice-2b/fixtures-wt diff -- 'golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json' | head -30 +``` + +Ожидаемый diff ОСВ: только три новых поля шапки на снимок (+ текст предупреждения у s06, документы форм у s12/s14). Любая другая строка в diff ОСВ — дефект, остановиться. Новые снимки `expected_statement_parse/**` — 19 реальных + 5 синтетики: просмотреть НПЗ и Межениновскую вручную по контрольным цифрам (1600/1700, 2400). + +- [ ] **Step 6: `make check` и `make test-fixtures`, замер времени** + +Run: `make -C T check && time make -C T test-fixtures` +Expected: зелёный; время `test-fixtures` — записать в ledger (порог — не более ×2 от 144 с). + +- [ ] **Step 7: Commit в обоих репозиториях** + +```bash +git -C T add api/tests api/scripts +git -C T commit -m "test(statements): манифест реальных форм, property-тест, golden expected_statement_parse, покрытие словарей форм + +Refs #14" +git -C $W/.wt/slice-2b/fixtures-wt add golden statements-xlsx +git -C $W/.wt/slice-2b/fixtures-wt commit -m "test(golden): снимки форм expected_statement_parse (19 + 5), границы периода в снимках ОСВ (arm-tabular-parser#14)" +``` + +--- + +### Task 15: Интеграция через воркер, CLI, README и CLAUDE.md сервиса + +**Files:** +- Modify: `API/tests/integration/test_roundtrip.py` (+ тест книги НПЗ), `T/README.md` (раздел «Парсер форм» после «Парсер ОСВ»; «Запуск на файле» — пример с формой; «Тесты на реальных данных» — манифест и `expected_statement_parse`), `T/CLAUDE.md` (раздел «Добавлено в Срезе 2b»), `API/rules/README.md` (если не дописан в задаче 4) +- Test: интеграционный + +- [ ] **Step 1: Интеграционный тест** + +```python +REAL_NPZ = Path("statements-xlsx") / "npz" / "2025-Q3" / "Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" + + +async def test_real_statements_through_worker_give_done_with_two_documents(core: Core, uploads: Uploads, fixtures_dir: Path) -> None: + """«Реальные формы через воркер»: книга НПЗ → ack → `done`, `result_schema_version 1.2`, BALANCE и INCOME.""" + request = make_request(file_type="XLSX", name=REAL_NPZ.name) # по образцу теста ОСВ выше + await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE) + await core.publish(request) + ack, body = await core.wait_ack_and_result(request) + assert body["status"] == "done" and body["result_schema_version"] == "1.2" + stored = json.loads(await uploads.read(body["result_s3"]["bucket"], body["result_s3"]["key"])) + assert [document["document_type"] for document in stored["documents"]] == ["BALANCE", "INCOME"] + assert all(document["checks"]["relations_failed"] == [] for document in stored["documents"]) +``` + +Имена хелперов (`make_request`, `wait_ack_and_result`, `XLSX_CONTENT_TYPE`) взять из существующего теста `test_real_osv_through_worker_gives_done_and_valid_result_file`. + +Run: `make -C T test-integration` (нужен compose парсера `make -C T up`; compose `arm-ka` ядра не трогать). + +- [ ] **Step 2: README — раздел «Парсер форм (ББ/ОФР)»** + +Содержание: конвейер (`book.py` → `statements/`: columns, detect, header, lines, checks, merge, statement), что считается формой/продолжением, критерий `ok`, словари и как добавить синоним/соотношение, таблица «файл → статус» по манифесту, как запустить CLI на форме, где golden. В «Статусы и коды»: `INVALID_DOCUMENT` — «нет листов ОСВ или форм». CLAUDE.md: раздел «Добавлено в Срезе 2b» (модули, правила расширения форм, запрет координат, где манифест, golden 2 каталога), `get_parser` → `BookParser`. + +- [ ] **Step 3: Commit** + +```bash +git -C T add api/tests/integration README.md CLAUDE.md api/rules/README.md +git -C T commit -m "docs(statements): README и CLAUDE.md — парсер форм; интеграционный тест книги НПЗ через воркер + +Refs #14" +``` + +--- + +### Task 16: Документы docs — §12, §7, 06-test-data, 03-architecture, 09-delivery-plan, OQ, qa-guide + +**Files (DOCS):** +- Modify: `docs/context/05-domain-rules.md` (§12 целиком, пометки в §7), `docs/context/06-test-data.md` (новый подраздел «4б. Формы ББ/ОФР в Excel: манифест, golden, синтетика» после 4а и строка в §7 про `expected_statement_parse`), `docs/context/03-architecture.md` (контракт 1.2, `BookParser`), `docs/context/09-delivery-plan.md` (Срез 2b — статус и объём), `docs/context/07-open-questions.md` (OQ-137…OQ-141, статус OQ-131), `docs/dev/qa-guide.md` §6.7 (абзац про формы) + +- [ ] **Step 1: §12 — заменить текст пунктов 1–8 и добавить 9–11** + +Структура нового §12 (каждый пункт — решение из design D-2…D-8 и brainstorming 06.10.2026): +1. Классификация листов: ОСВ → форма (заголовок/ОКУД в любой строке до таблицы; титул «Бухгалтерская отчетность» не тип) → продолжение (маркер «с.2», подпись «Код» с периодами, колонка кодов) → прочее. Типы: 0710001 → BALANCE, 0710002 → INCOME, прочие 0710xxx и пояснения → DISCLOSURE. Две формы на листе — по второму заголовку. +2. Колонка кодов: ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, пятизначные по первым четырём цифрам), ≥ 3 кодов. Нет колонки — `needs_check`, «колонка кодов не найдена»; сопоставление по именам отложено (OQ-138). +3. Колонка наименований и «Пояснения» — как было. +4. Колонки-периоды: подпись «Код» и ячейки-периоды правее; двухстрочный заголовок склеивается; строка нумерации пропускается; `period_date` — дата или конец периода; колонки без периода не читаются (предупреждение; `extra` у форм нет). +5. Значения: Decimal, скобки → минус, прочерк → null; **единицы как в файле, не нормализуются**; при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение (решение владельца 06.10.2026, OQ-137). +6. Шапка: метки, ИНН не аудитора, организация над меткой, дата из подзаголовка или тройки. +7. Самопроверка: соотношения §7 — данные `statement_checks.yaml`, жёсткие/мягкие, допуск ±1, двойной расчёт знаков (`abs_terms`), подстроки «в том числе» как мягкая проверка; результат — `statementDocument.checks` (v1.2) и предупреждения. +8. Расшифровки — DISCLOSURE без строк (OQ-23). +9. Склейка: листы-продолжения внутри книги присоединяются к последнему открытому документу при равном числе колонок; `locator` перечисляет листы; **страницы в разных файлах не склеиваются**: страница без шапки — документ с пустой шапкой и `needs_check` (решение владельца, OQ-139). +10. Статус: критерий `ok` (D-8); книга `done`/`needs_check`; `INVALID_DOCUMENT` без ОСВ и форм; порядок документов — ОСВ, затем формы. +11. Отложено: запасной путь по именам, нормализация единиц, признак продолжения в контракте, склейка в ядре (Срез 3). + +§7: после формул добавить абзац «Парсер tabular-parser (§12 п. 7) считает жёсткими … и мягкими … (`ASSUMPTION(OQ-12)`); ядро — Срез 3». + +- [ ] **Step 2: OQ** + +Добавить строки (формат таблицы как у OQ-136): +- OQ-137 — единицы «тыс. руб.» в шапке против значений в рублях с копейками в 1С-выгрузках (Унагранде, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит; парсер отдаёт как есть + предупреждение; ядро/аналитик — Срез 3. Откуда: `statements-xlsx/README.md`, design 2b D-6. Влияет: Срез 3, §8 (КФО в тыс. руб.). Допущение: не нормализовать. +- OQ-138 — форма без колонки кодов: словарь имён строк из шаблонов 66н/ФСБУ (`statement_lines.yaml`), `line_code_confidence < 100`; сейчас `needs_check`. Нужен реальный файл. +- OQ-139 — страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично): склейка в ядре по ЮЛ+период+тип, признак `is_continuation`/`form_page` в контракте (MINOR). Сейчас `needs_check`. +- OQ-140 — строка без кода с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется на суммы). +- OQ-141 — образец ФСБУ 4/2023 (шаблон): «АКТИВ» и код 1110 в одной строке из-за объединений; шаблоны не являются данными, парсер даёт `needs_check`; нужен ли их разбор вообще. +- OQ-131 — статус: «границы периода `period_start/period_end/is_from_year_start` в контракте v1.2 (docs change 2b); парсер заполняет (PR #13 + 2b); ядро — привязка скользящего документа к версии остаётся открытой». + +- [ ] **Step 3: 03-architecture, 09-delivery-plan, 06-test-data, qa-guide** + +03: в абзаце о `schema_version` — «1.2» вместо «1.1» в примере раскатки; в описании tabular-parser — «`BookParser`: ОСВ и формы, конвейер форм `statements/`». 09: Срез 2b — «реализован (ветки feat/14…, PR #16/#27), объём: парсер + контракт v1.2, маппинг форм — Срез 3». 06: подраздел 4б (раскладки, манифест `tests/statements_manifest.yaml`, golden `expected_statement_parse/`, синтетика st01–st05 с таблицей). qa-guide §6.7: «Формы ББ/ОФР: та же команда CLI; что смотреть — `document_type`, `columns[].period_date`, `checks.relations_failed`, предупреждение об единицах; постраничные xls дают `needs_check`». + +- [ ] **Step 4: Commit (docs)** + +```bash +git -C DOCS add docs/context docs/dev +git -C DOCS commit -m "docs(2b): §12 по решениям владельца, §7 жёсткие/мягкие, 06-test-data формы, 03-architecture v1.2, OQ-137…141 + +Refs #26" +``` + +--- + +### Task 17: Завершение — rebase, `check-full`, PR + +- [ ] **Step 1: Rebase на main (ветки агента A уже слиты), повторный прогон** + +```bash +git -C T fetch origin && git -C T rebase origin/main +make -C T check-full +make -C T golden-check +``` +Конфликты в `osv.py`/`test_book_parser.py`/`conftest.py`/`golden_helpers.py`: логика ОСВ — из main, оркестрация книги и формы — из ветки. + +- [ ] **Step 2: openspec** + +`cd T && openspec validate slice-2b-statement-parser --strict`; в `tasks.md` change отметить `[x]`; в `design.md` раздел «Decisions made without discussion» дополнить решениями исполнения (нумерация продолжается с №12: `OTHER`-продолжение считается по правилам ББ; `_RELATION` допускает пятизначные коды справа; мёртвые записи словарей удалены/покрыты; порог `min_codes = 3`; манифест в `tests/`, не в fixtures). + +- [ ] **Step 3: PR** + +PR #16 tabular-parser уже открыт на ветку спеки — дополнить описание: что сделано по задачам, «как проверить» (команды задачи 12 шаг 4, `make test-fixtures`), таблица «файл → статус» из манифеста, «Rulings I made», находки с маркерами и статусами, deferred; порядок мержа: docs #27 → arm-core/scan-gateway (копии) → fixtures `feat/14-statements-golden` → tabular-parser #16. PR fixtures: `tea pulls create --login home --repo RSHB/arm-ka-fixtures --base main --head feat/14-statements-golden --title "test(golden): снимки форм и границы периода ОСВ (arm-tabular-parser#14)" …`. PR docs #27 — дополнить описанием задач 1 и 16.