diff --git a/.gitignore b/.gitignore index 8b6cf20..31f2366 100644 --- a/.gitignore +++ b/.gitignore @@ -329,3 +329,6 @@ my_project_env .codegraph/ .superpowers/ .bounded + +# Артефакт локальной установки зависимостей: lock не ведём +uv.lock diff --git a/CLAUDE.md b/CLAUDE.md index 7bd5de3..bf896f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,3 +92,26 @@ property, покрытие словарей); без каталога `../fixtur с compose и сборка образа). Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR. + +## Добавлено в Срезе 2b (парсер ББ/ОФР; `openspec/changes/slice-2b-statement-parser/`) + +**Модули:** `api/src/parser/book.py` (`BookParser`: лимиты, чтение, дедлайн, вид листа, статус и карта ошибок; документы — +ОСВ в порядке листов, затем формы) и `api/src/parser/statements/` (`detect` → `columns` → `header` → `lines` → `checks` → +`merge`/`statement`; подробности — `README.md`, раздел «Парсер форм (ББ/ОФР)»). `get_parser(settings)` теперь создаёт +`BookParser` (листы ОСВ по-прежнему разбирает `osv.py`). + +**Правила расширения форм:** +- Новая форма или написание = строка в `api/rules/statement_*.yaml` (+ синтетика `st0x` в + `tests/synthetic/generate_statements.py` или реальный файл в `../fixtures/statements-xlsx/`). Не `if` под файл, + организацию или лист. Соотношения §7 — данные `statement_checks.yaml`. +- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`): тот же AST-линтер `test_no_coordinates.py`; слов формы в + коде нет — только в YAML. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется. +- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`) под формы не правятся. + +**Тесты на реальных данных:** манифест ожиданий — `api/tests/statements_manifest.yaml` (18 файлов). Golden — два каталога +в `../fixtures/golden/`: `expected_osv_parse` и `expected_statement_parse`; правятся только `make golden-update` с +просмотром diff и отдельным коммитом в `fixtures`. + +**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`: +`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Интеграционный тест книги НПЗ ожидает +`needs_check` (рубли при шапке «тыс. руб.», OQ-138), а не `done`. diff --git a/README.md b/README.md index fb8b90b..52e96c7 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо |---|---| | `done` | контрольные суммы §1.4 сошлись на всех листах | | `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` | -| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) | +| `error` / `INVALID_DOCUMENT` | в книге нет листов ОСВ и форм (в сообщении — число просмотренных листов) | | `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг | | `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING | | `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») | @@ -147,7 +147,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») | В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы -(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое +(`skipped: нет признаков ОСВ или формы`), несовпадение `file_type` с содержимым, свёрнутое сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). ### Контрольные суммы (§1.4) @@ -195,7 +195,7 @@ groups: и положите файл в `../fixtures/osv/` (или синтетику в `tests/synthetic/generate.py`), затем `make golden-update`. Этот сценарий проверяют `test_default_dictionary_does_not_know_the_new_synonym` (до правки) и -`test_new_synonym_parses_without_code_changes` (после правки временной копии словарей) в `tests/unit/parser/test_osv_parser.py`. +`test_new_synonym_parses_without_code_changes` (после правки временной копии словарей) в `tests/unit/parser/test_book_parser.py`. Принцип: **новый формат = фикстура + синоним, не новый `if`**. ### Лимиты @@ -247,6 +247,71 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же — настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком. +## Парсер форм (ББ/ОФР) + +Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через `statements/`. Документы +результата: сначала ОСВ в порядке листов, затем формы в порядке листов. Структура ищется по содержимому, как в ОСВ; +синонимы и соотношения §7 — данные `api/rules/statement_*.yaml`, в коде нет ни слова из формы и ни одной координаты. + +| Модуль (`api/src/parser/statements/`) | Что делает | +|---|---| +| `detect.py` | блоки форм на листе и вид листа (форма / продолжение / прочее); заголовок формы или код ОКУД открывает блок | +| `columns.py` | заголовок таблицы, колонка кодов (не меньше 60 % кодов в блоке), колонка наименований, колонки-периоды | +| `header.py` | шапка: организация, ИНН (ИНН аудитора не берётся), ОКПО, ОКЕИ, единицы «как в файле», дата периода | +| `lines.py` | строки с кодом и значения в `Decimal`; пятизначные коды — по первым четырём цифрам; дубликаты кодов | +| `checks.py` | соотношения §7 по колонкам, допуск ±1 в единицах формы, отсутствующий код = 0, `abs_terms` для знака расходов | +| `merge.py` | черновик документа и присоединение листов-продолжений к последнему открытому документу | +| `statement.py` | конвейер и итоговый `statementDocument`, критерий `ok` | + +Форма и продолжение: лист с заголовком формы или ОКУД — новая форма; лист без заголовка, но с маркером «Форма … с.2», +подписью «Код» с периодами или колонкой кодов — продолжение, присоединяется к последнему открытому документу. +Книга только из листов пояснений даёт `done` с документами `DISCLOSURE` без строк. + +Критерий `ok` документа: жёсткие соотношения сошлись, дубликатов кодов нет, колонка кодов найдена, есть колонка-период, +в шапке есть организация или ИНН, документ не одинокое продолжение. Статус книги — `done`, если `ok` у всех документов, +иначе `needs_check` (список нарушений — `documents[].checks`; `soft_relations_failed` на статус не влияют). +Новый синоним или соотношение — строка в YAML (`api/rules/README.md`), плюс файл-фикстура или синтетика, на которой она +срабатывает (`test_rules_coverage.py`). + +### Ограничения + +- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и доле значений с дробной + частью не меньше `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — + `needs_check` задают нарушенные соотношения (при рублях с копейками итоги расходятся, OQ-138). +- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся + одиноким продолжением и даёт `needs_check`. +- Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся + как продолжение (`min_codes_continuation` словаря) и присоединяется, если совпало число колонок сумм и тип формы; + иначе не присоединён, книга `needs_check`, предупреждение «лист с кодами форм (N < min_codes) не присоединён: …». + +### Реальные формы: файл → статус + +Источник — `api/tests/statements_manifest.yaml` (18 файлов `fixtures/statements-xlsx/**`). Статусы сверены прогоном. + +| Файл | Документы и статус | Причина | +|---|---|---| +| `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится | +| `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки | +| `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `needs_check` | рубли с копейками при шапке «тыс. руб.»: 2100/2200/2300 расходятся с суммой частей (OQ-138) | +| `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки | +| `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив | +| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки | +| `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет | +| `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив | +| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки | +| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-138) | +| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки | +| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) | +| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся | +| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся | +| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE | +| `templates/ББ после 01.01.2025.xlsx` | BALANCE `needs_check` | шаблон | +| `templates/ОФР_до 01.01.2025.xlsx` | INCOME `needs_check` | шаблон | +| `templates/ОФР_после 01.01.2025.xlsx` | INCOME `needs_check` | шаблон | + +Запуск на форме: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "<путь к файлу>"` из `api/` (см. «Запуск на файле»); +в stdout — файл результата v1.2, у форм в `documents[]` — `header`, `lines[]`, `checks`. + ## Тесты на реальных данных и golden-снимки Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию @@ -264,6 +329,10 @@ git -C ../fixtures diff # просмотреть каждое из git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures ``` +Для форм — второй каталог: `fixtures/golden/expected_statement_parse/` (файлы `fixtures/statements-xlsx/**`, включая +`synthetic/` — синтетика `st01`–`st06`, генератор `tests/synthetic/generate_statements.py`); ожидания по реальным формам — +манифест `api/tests/statements_manifest.yaml`. Снимки обоих каталогов обновляет один `make golden-update`. + Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update». Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff), для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, @@ -316,7 +385,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше). Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`. -В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под +В stdout печатается JSON файла результата контракта v1.2 — тот же, что воркер кладёт в S3 под `results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` — фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`, diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index f883a8d..b0a9e97 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.1", + "schema_version": "1.2", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "service": { "name": "tabular-parser", "version": "0.1.0" }, @@ -16,6 +16,9 @@ "period_year": 2025, "period_quarter": "Q1", "is_cumulative": false, + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "is_from_year_start": true, "data_type": "BU", "currency": "RUB", "header_variant": 1 diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json new file mode 100644 index 0000000..fcee529 --- /dev/null +++ b/api/contracts/examples/result-statement-excel.json @@ -0,0 +1,46 @@ +{ + "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": [] + } + ] +} diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 484f222..51e3b17 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.0", + "schema_version": "1.2", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "service": { "name": "scan-gateway", "version": "0.1.0" }, @@ -44,7 +44,8 @@ { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } ] } - ] + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } } ] } diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index 5d329a8..9a168cf 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.1 пишет \"1.1\"" }, + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -53,6 +53,16 @@ }, "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в 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": "правая часть, вычисленная по строкам" } + } + }, "osvDocument": { "type": "object", "required": ["document_type", "locator", "header", "group_schema", "rows"], @@ -70,6 +80,9 @@ "period_year": { "type": ["integer", "null"] }, "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "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 не переопределяет" }, "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, "header_variant": { "enum": [1, 2] } @@ -133,7 +146,7 @@ "type": "object", "required": ["document_type", "locator", "header", "columns", "lines"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "OTHER"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, "locator": { "type": "string", "examples": ["pages:1-2"] }, "header": { "type": "object", @@ -191,6 +204,15 @@ } } } + }, + "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": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } } } } diff --git a/api/rules/README.md b/api/rules/README.md index 467871a..337e903 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -10,10 +10,13 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | Файл | Ключи | Что описывает | |---|---|---| -| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | +| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | -| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | +| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | +| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке `min_codes`, ослабленный минимум для листа-продолжения после формы `min_codes_continuation`), `okud_code` (код ОКУД внутри ячейки), `units_thousands` (единицы в тысячах), `fractional_share_percent` (порог доли значений с дробной частью при «тыс. руб.», процент 1..100: при превышении — одно предупреждение на документ; на статус не влияет) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) | +| `statement_periods.yaml` | `months` (названия месяцев, группы `m01…m12`), `on_date` (дата вида «на 31 декабря 2024 г.», группы `d`, `m`, `y`), `numeric_date` (дата вида «31.12.2024», группы `d`, `m`, `y`), `for_months` (период вида «за 9 месяцев 2025 г.», группы `n`, `y`), `for_range` (диапазон месяцев вида «за январь–сентябрь 2025 г.», группы `m`, `y`), `for_year` (год вида «за 2024 г.» или «2024») | текст заголовка колонки или подзаголовка формы ББ/ОФР -> дата конца периода (§12 п. 4) | +| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) | ## Как писать паттерны @@ -34,7 +37,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. ## Покрытие словарей -Каждая запись распознавания (один `Pattern` в любом из четырёх словарей: `sheet_markers`, `statement_markers`, +Каждая запись распознавания (один `Pattern` в любом из словарей: `sheet_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end` периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике: это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 05216b3..a5ac32c 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -11,13 +11,6 @@ sheet_markers: - 'оборотно\s*сальдовая\s+ведомость' -# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением -statement_markers: - - 'бухгалтерский\s+баланс' - - '0710001' - - 'отчет\s+о\s+финансовых\s+результатах' - - '0710002' - # Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover: # отсюда границы слова `\bоборот(ы)?\b`. groups: diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index 5227689..33480ee 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -38,3 +38,8 @@ range: '(?P(январ|феврал|март|апрел|ма[йя]|июн|и # ячейка целиком — период с годом; ищется, только если периода по меткам нет standalone: - '^(\d{1,2}|[ivx]+)\s*(й|я|ый|ой)?\s*(квартал\w*|кв\b\.?|мес\w*)\s*(19|20)\d{2}\s*(г\b\.?|года?)?$' + +# Один месяц с годом («Февраль 2024 г.») — период этого месяца, но не когда это дата на число («на 31 декабря 2024 г.», +# «по состоянию на 31 марта») или названа длительность («1 квартал», «полугодие», «9 месяцев», «год»): тогда границы — +# по кварталу словаря +not_single_month: '\b\d{1,2}\s*(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)|кв\b|квартал|полугоди|\d\s*мес|\bгод\b|годов' diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml new file mode 100644 index 0000000..7ab146c --- /dev/null +++ b/api/rules/statement_checks.yaml @@ -0,0 +1,22 @@ +# Соотношения §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} diff --git a/api/rules/statement_headers.yaml b/api/rules/statement_headers.yaml new file mode 100644 index 0000000..c9bb152 --- /dev/null +++ b/api/rules/statement_headers.yaml @@ -0,0 +1,95 @@ +# Словарь форм ББ/ОФР (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|4|5|6|7|8|9)|(1|2|3|4|5|6|7|8|9)\d)\b' + +# Сам код ОКУД формы внутри ячейки («Форма по ОКУД 0710001» -> «0710001») +okud_code: '0710\d{3}' + +# Единицы измерения в тысячах: «тыс. руб.», «в тыс. рублей» (для предупреждения о дробных значениях) +units_thousands: 'тыс' + +# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2» +continuation: + - '\bформа\s+0710\d{3}\s+с\.?\s*\d+' + +# Подписи колонок заголовка таблицы: ячейка целиком +code_caption: + - '^код(ы)?$' +explanations_caption: + - '^пояснени' +# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число +column_numbering: '^\d{1,2}$' + +# Метки шапки документа (значение — правее в той же строке либо в следующей строке) +labels: + organization: + - '^организация\b' + inn: + - '^инн\b' + - 'идентификационный\s+номер\s+налогоплательщика' + okpo: + - 'по\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' + +# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации +auditor: + - 'аудитор' + +# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ) +legal_form: + - '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b' + - '"' + - '«' + +# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в колонке, +# минимум кодов, чтобы колонка считалась колонкой кодов +line_codes: + ranges: + BALANCE: [1100, 1799] + INCOME: [2100, 2999] + DISCLOSURE: [5000, 5999] + min_share: 0.6 + min_codes: 3 + # лист, идущий после листа формы: хватает одного кода (лист-продолжение с единственной строкой, напр. 2400 у НПЗ) + min_codes_continuation: 1 + +# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100) +fractional_share_percent: 10 diff --git a/api/rules/statement_periods.yaml b/api/rules/statement_periods.yaml new file mode 100644 index 0000000..e487f59 --- /dev/null +++ b/api/rules/statement_periods.yaml @@ -0,0 +1,13 @@ +# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §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*$)' diff --git a/api/scripts/golden_update.py b/api/scripts/golden_update.py index 67658a1..30449a0 100644 --- a/api/scripts/golden_update.py +++ b/api/scripts/golden_update.py @@ -1,10 +1,12 @@ -"""Перезапись golden-снимков разбора ОСВ и diff изменившихся (design D-10). +"""Перезапись golden-снимков разбора ОСВ и форм и diff изменившихся (design D-10). Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в -`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается -unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для -которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются. +`fixtures/golden/expected_osv_parse/` (зеркало `osv/`); файлы форм — `fixtures/statements-xlsx/**` (включая +`synthetic/`), снимки — в `fixtures/golden/expected_statement_parse/`. Без флага снимки перезаписываются, для +изменившихся печатается unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с +разбором). Снимки части, для которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных +файлов), не удаляются. Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`) и убедитесь, что каждое изменение — следствие намеренной правки парсера. @@ -23,6 +25,8 @@ from fixtures_helpers import ( # noqa: E402 osv_dkb_golden_files, osv_fixture_files, resolve_fixtures_dir, + statement_files, + statement_synthetic_files, synthetic_files, ) from golden_helpers import sync_snapshots # noqa: E402 @@ -44,7 +48,13 @@ def main(argv: Sequence[str] | None = None) -> int: print(reason, file=sys.stderr) return EXIT_NO_FIXTURES root = resolve_fixtures_dir() - files = [*osv_fixture_files(root), *osv_dkb_golden_files(root), *synthetic_files(root)] + files = [ + *osv_fixture_files(root), + *osv_dkb_golden_files(root), + *synthetic_files(root), + *statement_files(root), + *statement_synthetic_files(root), + ] report = sync_snapshots(root, files, get_parser(), write=not options.check) for diff in report.diffs: diff --git a/api/src/contract/common.py b/api/src/contract/common.py index 8fa3581..dd6b4a1 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -27,7 +27,7 @@ from pydantic import ( model_validator, ) -CONTRACT_VERSION = "1.1" +CONTRACT_VERSION = "1.2" # Шаблоны версий — буквально из схем SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 4a89716..c7948c0 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -1,4 +1,4 @@ -"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.1. +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.2. tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор — @@ -29,6 +29,7 @@ from src.contract.common import ( ) __all__ = [ + "FailedRelation", "OsvChecks", "OsvDocument", "OsvGroup", @@ -37,6 +38,7 @@ __all__ = [ "RecognitionResult", "ResultError", "ResultStatus", + "StatementChecks", "StatementColumn", "StatementDocument", "StatementHeader", @@ -72,6 +74,9 @@ class OsvHeader(ContractModel): period_year: IntLike | None = None period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) + 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 не переопределяет data_type: DataType | None = None currency: str | None = None header_variant: OptionalNotNull[HeaderVariant] = None @@ -162,12 +167,33 @@ class StatementLine(ContractModel): values: list[StatementValue] +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) + + class StatementDocument(ContractModel): - document_type: Literal["BALANCE", "INCOME", "OTHER"] + document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] locator: str # «pages:1-2» header: StatementHeader columns: list[StatementColumn] lines: list[StatementLine] + checks: OptionalNotNull[StatementChecks] = None ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py index 68d8c05..cddbf2c 100644 --- a/api/src/parser/__init__.py +++ b/api/src/parser/__init__.py @@ -1,7 +1,8 @@ """Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка». Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат. -Реализация по умолчанию — `OsvParser` (`osv.py`, Срез 2a); `NullParser` (заглушка Среза 0) остаётся для тестов воркера. +Реализация по умолчанию — `BookParser` (`book.py`; листы ОСВ — `osv.py`); `NullParser` (заглушка Среза 0) +остаётся для тестов воркера. `parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий. @@ -61,16 +62,25 @@ class NullParser: def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser: - """Фабрика парсера для воркера: `OsvParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` / + """Фабрика парсера для воркера: `BookParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` / `PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`). Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом и ключом, сервис не стартует, а не падает на первом файле. """ - # `osv.py` импортирует `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим + # `book.py` и `osv.py` импортируют `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим + from src.parser.book import BookParser # noqa: PLC0415 - цикл импорта book → src.parser from src.parser.osv import OsvParser # noqa: PLC0415 - цикл импорта osv → src.parser + from src.parser.statements.statement import StatementParser # noqa: PLC0415 - цикл импорта fields = Settings.model_fields max_bytes = fields["PARSE_MAX_BYTES"].default if settings is None else settings.PARSE_MAX_BYTES timeout_seconds = fields["PARSE_TIMEOUT_SECONDS"].default if settings is None else settings.PARSE_TIMEOUT_SECONDS - return OsvParser(load_rules(rules_dir), max_bytes=max_bytes, timeout_seconds=timeout_seconds) + rules = load_rules(rules_dir) + return BookParser( + rules, + osv=OsvParser(rules), + statements=StatementParser(rules), + max_bytes=max_bytes, + timeout_seconds=timeout_seconds, + ) diff --git a/api/src/parser/book.py b/api/src/parser/book.py new file mode 100644 index 0000000..ca308f9 --- /dev/null +++ b/api/src/parser/book.py @@ -0,0 +1,140 @@ +"""Оркестратор книги (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 +from src.parser.statements.detect import is_weak_continuation + +__all__ = ["BookParser", "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 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 and statement_sheets and is_weak_continuation(sheet, self._rules): + kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами после листа формы + 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), + ) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 201092c..46b5bda 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -18,6 +18,7 @@ from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError from src.parser.grid import Cell, Sheet, column_letter from src.parser.rules import Pattern, Rules, SumGroup, normalize_text +from src.parser.statements.detect import classify_statement_sheet __all__ = [ "DEADLINE_CHECK_EVERY_ROWS", @@ -73,6 +74,7 @@ SIDE_TITLES: dict[Side, str] = {"debit": "Дебет", "credit": "Кредит"} class SheetKind(StrEnum): OSV = "osv" STATEMENT = "statement" + STATEMENT_CONTINUATION = "statement_continuation" OTHER = "other" @@ -145,14 +147,20 @@ def _texts(sheet: Sheet, row: int) -> Iterator[tuple[int, str]]: # --- Вид листа --- +_STATEMENT_KINDS = { + "statement": SheetKind.STATEMENT, + "continuation": SheetKind.STATEMENT_CONTINUATION, + "other": SheetKind.OTHER, +} + + 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 - if any(_search(rules.headers.statement_markers, text) for text in head): - return SheetKind.STATEMENT - return SheetKind.OTHER + return _STATEMENT_KINDS[classify_statement_sheet(sheet, rules)] # --- Заголовок таблицы --- diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 2c9c03a..87e0c7e 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -6,6 +6,8 @@ пустая строка (для полей контракта-строк) или `None` и предупреждение на русском. """ +import calendar +import datetime import re from collections.abc import Iterable from dataclasses import dataclass @@ -43,6 +45,8 @@ class DocHeader: period_year: int | None period_quarter: Quarter | None is_cumulative: bool | None + period_start: datetime.date | None + period_end: datetime.date | None data_type: DataType | None currency: str | None header_variant: HeaderVariant @@ -303,11 +307,29 @@ class _Period: year: int | None quarter: Quarter | None is_cumulative: bool | None + start: datetime.date | None = None + end: datetime.date | None = None _MONTHS_IN_QUARTER = 3 +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) + 1) * _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) + + def _quarter_of_end(month: int) -> Quarter | None: """Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц).""" if month % _MONTHS_IN_QUARTER: @@ -323,11 +345,13 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe документ. `null` + предупреждение оставляют решение аналитику (OQ-131). """ quarter = _quarter_of_end(span.end_month) + start = datetime.date(span.start_year, span.start_month, 1) + end = _month_end(span.end_year, span.end_month) same_year = span.start_year == span.end_year if quarter is not None and same_year and span.start_month == 1: - return _Period(span.end_year, quarter, True) + return _Period(span.end_year, quarter, True, start, end) if quarter is not None and same_year and span.months == _MONTHS_IN_QUARTER: - return _Period(span.end_year, quarter, False) + return _Period(span.end_year, quarter, False, start, end) if span.start_month != 1 or not same_year: warnings.append( f"период «{text}»: скользящий период {span.months} мес. " @@ -339,7 +363,7 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe f"период «{text}»: конец диапазона не совпадает с концом квартала: " "квартал и признак нарастающего итога не определены" ) - return _Period(span.end_year, None, None) + return _Period(span.end_year, None, None, start, end) def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: @@ -354,11 +378,15 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: warnings.append(f"период «{text}»: год не найден") # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить # по данным (opening Q3 == opening Q1 -> нарастающая) - return _Period( - year=None if year is None else int(year.group(0)), - quarter=None if rule is None else rule.quarter, - is_cumulative=None if rule is None else rule.is_cumulative, - ) + year_value = None if year is None else int(year.group(0)) + quarter = None if rule is None else rule.quarter + is_cumulative = None if rule is None else rule.is_cumulative + start, end = _bounds(year_value, quarter, is_cumulative) + named = rules.periods.bounds_span(text) + if named is not None: + # Месяцы названы в тексте — границы берём из них, а не из квартала словаря («Январь - Май 2025 г.» — не год) + start, end = datetime.date(named.start_year, named.start_month, 1), _month_end(named.end_year, named.end_month) + return _Period(year_value, quarter, is_cumulative, start, end) def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None: @@ -419,6 +447,8 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead period_year=period.year, period_quarter=period.quarter, is_cumulative=period.is_cumulative, + period_start=period.start, + period_end=period.end, data_type=data_type, currency=currency, header_variant=variant, diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index bac75ca..b5d8191 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -1,54 +1,38 @@ -"""Оркестратор разбора ОСВ: байты книги → документы, метрики, статус (design D-6, D-8; spec «Листы ОСВ находятся по -содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время»). +"""Конвейер листа ОСВ: лист → документ `OsvDocument`, предупреждения, признак «контрольные суммы сошлись». -Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее) -→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные -суммы §1.4. Листы форм и прочие пропускаются с предупреждением; имя листа только подставляется в `locator` и -сообщения, поиск по нему не ведётся. - -Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT` -(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB, размер больше лимита или превышены -лимиты чтения (распакованный zip > `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`, площадь объединённых ячеек, R-23) → -`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой -`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на -одном листе → `needs_check` всего результата. +Книгу (лимиты, чтение, дедлайн, вид листа, статус и карта ошибок) разбирает `book.BookParser`. Здесь: заголовок +таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4. Имя листа +только подставляется в `locator` и сообщения, поиск по нему не ведётся. """ -import time -from collections.abc import Callable, Iterable, Sequence +import datetime +from collections.abc import Iterable, Sequence from dataclasses import replace -from loguru import logger - -from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus +from src.contract import OsvDocument from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow -from src.parser import ParseOutcome from src.parser.checks import run_checks from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows from src.parser.deadline import Deadline -from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header -from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError +from src.parser.detect import TableHeader, find_table_header +from src.parser.errors import InvalidFormatError from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header from src.parser.hierarchy import resolve_levels -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__ = ["OsvParser"] +__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"] LOCATOR_PREFIX = "sheet:" INN_COLUMN_KEY = "inn" NO_DATA_ROWS_WARNING = "нет строк данных" -_MS_IN_SECOND = 1000 -def _sheet_line(sheet: Sheet, text: str) -> str: +def sheet_line(sheet: Sheet, text: str) -> str: return f"sheet={sheet.name} {text}" -def _unique(items: Iterable[str]) -> list[str]: +def unique(items: Iterable[str]) -> list[str]: """Без повторов, в порядке первого появления.""" return list(dict.fromkeys(items)) @@ -61,6 +45,10 @@ def _columns(header: TableHeader) -> dict[str, str]: return columns +def _from_year_start(start: datetime.date | None) -> bool | None: + return None if start is None else (start.month, start.day) == (1, 1) + + def _header(doc: DocHeader) -> OsvHeader: return OsvHeader( organization_name=doc.organization_name, @@ -70,6 +58,9 @@ def _header(doc: DocHeader) -> OsvHeader: period_year=doc.period_year, period_quarter=doc.period_quarter, is_cumulative=doc.is_cumulative, + period_start=doc.period_start, + period_end=doc.period_end, + is_from_year_start=_from_year_start(doc.period_start), data_type=doc.data_type, currency=doc.currency, header_variant=doc.header_variant, @@ -124,7 +115,7 @@ def _is_ok(checks: OsvChecks) -> bool: ) -def _data_rows(document: OsvDocument) -> int: +def data_rows(document: OsvDocument) -> int: return sum(1 for row in document.rows if row.group_type != "TOTAL") @@ -135,53 +126,10 @@ def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> Invalid class OsvParser: - """Парсер ОСВ по умолчанию: словари `rules`, лимит размера `max_bytes`, таймаут `timeout_seconds` (D-8). + """Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`.""" - `parse` не бросает исключений: любой сбой — `ParseOutcome(status=error)` с кодом контракта. - """ - - def __init__( - self, - rules: Rules, - *, - max_bytes: int, - timeout_seconds: float, - clock: Callable[[], float] = time.monotonic, - ) -> None: + def __init__(self, rules: Rules) -> None: self._rules = rules - 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: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер - reason = one_line_reason(exc) - # Ответ — одна строка; стек нужен разработчику, а воркер логирует только код ошибки (I-2) - logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason) - return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started) - metrics = Metrics( - duration_ms=self._elapsed_ms(started), - rows=sum(_data_rows(document) for document in documents), - warnings=warnings, - ) - status = ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK - return ParseOutcome(status=status, documents=tuple(documents), metrics=metrics) def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]: """Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак @@ -207,44 +155,14 @@ class OsvParser: deadline.check(f"лист «{sheet.name}»: контрольные суммы") checks, check_warnings = run_checks(classified) document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks) - warnings = [_sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings] + warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings] if not any(item.group_type != "TOTAL" for item in classified): warnings.append(NO_DATA_ROWS_WARNING) warnings.extend(check_warnings) - return document, _unique(warnings), _is_ok(checks) + return document, unique(warnings), _is_ok(checks) # --- внутреннее --- - def _parse_book(self, data: bytes, file_type: FileType, warnings: list[str]) -> tuple[list[OsvDocument], 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)) - documents: list[OsvDocument] = [] - ok = True - for sheet in sheets: - deadline.check(f"лист «{sheet.name}»") - kind = classify_sheet(sheet, self._rules) - if kind is SheetKind.STATEMENT: - warnings.append(_sheet_line(sheet, "skipped: statement (slice 2b)")) - continue - if kind is SheetKind.OTHER: - warnings.append(_sheet_line(sheet, "skipped: нет признаков ОСВ")) - continue - document, sheet_warnings, sheet_ok = self.parse_sheet(sheet, deadline) - documents.append(document) - warnings.extend(sheet_warnings) - ok = ok and sheet_ok - if not documents: - raise InvalidDocumentError(f"в книге нет листов ОСВ: листов просмотрено {len(sheets)}") - return documents, ok - @staticmethod def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно *, @@ -264,16 +182,3 @@ class OsvParser: rows=[_row(item) for item in rows], checks=checks, ) - - 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: - metrics_fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)} - if warnings: - metrics_fields["warnings"] = warnings - return ParseOutcome( - status=ResultStatus.ERROR, - error=MessageError(code=code, message=" ".join(message.split())), - metrics=Metrics.model_validate(metrics_fields), - ) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 2017e7c..d46e3e4 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -10,9 +10,12 @@ """ import bisect +import calendar +import datetime import re from collections.abc import Iterable from dataclasses import dataclass +from decimal import Decimal from pathlib import Path from typing import Any, Literal, TypeVar, get_args @@ -31,7 +34,11 @@ __all__ = [ "Pattern", "PeriodRule", "PeriodRules", + "RelationRule", "Rules", + "StatementCheckRules", + "StatementHeaderRules", + "StatementPeriodRules", "first_match", "load_rules", "normalize_text", @@ -44,12 +51,28 @@ HEADERS_FILE = "osv_headers.yaml" GROUPS_FILE = "osv_groups.yaml" PERIODS_FILE = "periods.yaml" DATA_TYPES_FILE = "data_types.yaml" +STATEMENT_HEADERS_FILE = "statement_headers.yaml" +STATEMENT_PERIODS_FILE = "statement_periods.yaml" +STATEMENT_CHECKS_FILE = "statement_checks.yaml" GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] SumGroup = Literal["opening", "turnover", "closing"] HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"] RowDataType = Literal["CURRENCY", "QUANTITY"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] +FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"] +StatementLabel = Literal[ + "organization", + "inn", + "okpo", + "okopf", + "okfs", + "okopf_okfs", + "okei", + "units", + "address", + "date", +] _ISO_CODE_LENGTH = 3 # Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире @@ -59,6 +82,9 @@ _SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+") _INVISIBLE = re.compile("[\u00ad\u200b\ufeff]") # Что нормализация убирает из текста: таких символов в паттерне быть не может _UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]") +# Регулярные выражения для парсинга соотношений форм +_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$") +_TERM = re.compile(r"([+\-])?\s*(\d{4,5})") Value = TypeVar("Value") @@ -144,7 +170,6 @@ class _Model(BaseModel): class OsvHeaderRules(_Model): sheet_markers: list[Pattern] - statement_markers: list[Pattern] groups: dict[SumGroup, list[Pattern]] debit: list[Pattern] credit: list[Pattern] @@ -224,6 +249,8 @@ class PeriodRules(_Model): months: Pattern range: Pattern standalone: list[Pattern] + # Текст, в котором один месяц не задаёт границы периода: дата на число, квартал, полугодие, «N месяцев» + not_single_month: Pattern @model_validator(mode="after") def _check_month_patterns(self) -> "PeriodRules": @@ -259,6 +286,28 @@ class PeriodRules(_Model): start_year = int(year_text) if year_text is not None else end_year - (1 if start > end else 0) return MonthRange(start, start_year, end, end_year) + def bounds_span(self, text: str) -> MonthRange | None: + """Месяцы, которые период называет прямо: диапазон (в т. ч. от января без года начала — «Январь - Май 2025 г.») + или один месяц («Февраль 2024 г.»); иначе `None`. Нужен только для границ периода: квартал и признак + нарастающего итога по-прежнему берутся из `match`. + """ + span = self.month_range(text) + if span is not None: + return span + found = self.range.search(text) + if found is not None: + start, end = self.month_number(found.group("m1")), self.month_number(found.group("m2")) + if start == 1 and end is not None and found.group("y1") is None: + end_year = int(found.group("y2")) + return MonthRange(1, end_year, end, end_year) + return None + if self.not_single_month.search(text): + return None + month, year = self.month_number(text), self.year.search(text) + if month is None or year is None: + return None + return MonthRange(month, int(year.group(0)), month, int(year.group(0))) + def match(self, text: str) -> PeriodRule | None: """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим.""" for rule in self.rules: @@ -295,21 +344,255 @@ class DataTypeRules(_Model): return None +class _LineCodeRules(_Model): + ranges: dict[FormType, tuple[int, int]] + min_share: float + min_codes: int + min_codes_continuation: 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") + if not 1 <= self.min_codes_continuation <= self.min_codes: + raise ValueError("line_codes: min_codes_continuation в [1, min_codes]") + return self + + +class StatementHeaderRules(_Model): + form_titles: dict[FormType, list[Pattern]] + okud: dict[FormType, list[Pattern]] + okud_code: Pattern + units_thousands: Pattern + continuation: list[Pattern] + code_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 + fractional_share_percent: int + + @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)}") + if not 1 <= self.fractional_share_percent <= 100: # noqa: PLR2004 + raise ValueError( + f"fractional_share_percent: должна быть в диапазоне [1, 100], получено {self.fractional_share_percent}" + ) + return self + + def form_type(self, text: str) -> FormType | None: + entries = ((p, k) for k, patterns in self.form_titles.items() for p in patterns) + return first_match(entries, text) + + def okud_type(self, text: str) -> FormType | None: + entries = ((p, k) for k, patterns in self.okud.items() for p in patterns) + return first_match(entries, text) + + def is_thousands(self, text: str) -> bool: + return self.units_thousands.search(text) is not None + + 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_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 + + @property + def min_codes_continuation(self) -> int: + return self.line_codes.min_codes_continuation + + +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: + msg = ( + f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных или пятизначных кодов" + ) + raise ValueError(msg) + 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] + + @model_validator(mode="after") + def _check_explicit_rules_have_four_digit_codes(self) -> "StatementCheckRules": + """Явные правила из yaml содержат только четырёхзначные коды; пятизначные — только в подстроках.""" + parent_len = 4 # четырёхзначные коды для явных правил + for rule in self.relations: + target, terms = _parse_relation(rule.relation) + # Цель всегда четырёхзначная (проверяется в _RELATION) + if len(target) != parent_len: + raise ValueError(f"явное правило «{rule.relation}»: цель должна быть четырёхзначной") + # Члены правой части должны быть четырёхзначными + for _, code in terms: + if len(code) != parent_len: + raise ValueError( + f"явное правило «{rule.relation}»: код «{code}» должен быть четырёхзначным; " + f"пятизначные коды допустимы только в сгенерированных подстроках" + ) + return self + + @dataclass(frozen=True) class Rules: headers: OsvHeaderRules groups: OsvGroupRules periods: PeriodRules data_types: DataTypeRules + statement_headers: StatementHeaderRules + statement_periods: StatementPeriodRules + statement_checks: StatementCheckRules def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules: - """Читает четыре словаря каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом.""" + """Читает семь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом.""" return Rules( headers=_load(path, HEADERS_FILE, OsvHeaderRules), groups=_load(path, GROUPS_FILE, OsvGroupRules), periods=_load(path, PERIODS_FILE, PeriodRules), data_types=_load(path, DATA_TYPES_FILE, DataTypeRules), + statement_headers=_load(path, STATEMENT_HEADERS_FILE, StatementHeaderRules), + statement_periods=_load(path, STATEMENT_PERIODS_FILE, StatementPeriodRules), + statement_checks=_load(path, STATEMENT_CHECKS_FILE, StatementCheckRules), ) diff --git a/api/src/parser/statements/__init__.py b/api/src/parser/statements/__init__.py new file mode 100644 index 0000000..5ac937c --- /dev/null +++ b/api/src/parser/statements/__init__.py @@ -0,0 +1,4 @@ +"""Конвейер форм ББ/ОФР (Срез 2b): columns → detect → header → lines → checks → merge/statement. + +Как и в ОСВ, структура ищется по содержимому: синонимы и соотношения — `rules/statement_*.yaml`. +""" diff --git a/api/src/parser/statements/checks.py b/api/src/parser/statements/checks.py new file mode 100644 index 0000000..3daa136 --- /dev/null +++ b/api/src/parser/statements/checks.py @@ -0,0 +1,108 @@ +"""Проверка соотношений §7 по колонкам (design D-7, спека D-7). + +Правила из `statement_checks.yaml`; отсутствующий код = 0. Правая часть вычисляется как записано +и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `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]: + """Проверка соотношения в пределах допуска. + + Вычисляет правую часть два раза: как записано и с abs() для членов в abs_terms. + Правило выполняется, если хотя бы один вариант в пределах tolerance. При нарушении возвращается вариант, ближайший + к левой части (при равенстве — «как записано»): аналитик видит реальную величину расхождения. + """ + 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 + if abs(expected - with_abs) < abs(expected - plain): + return False, 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)) + subline_rules = _subline_rules(lines, form, rules) if config.subline_check else [] + relations.extend(subline_rules) + hard: list[FailedRelation] = [] + soft: list[FailedRelation] = [] + warnings: list[str] = [] + for column_index, amounts in enumerate(_amounts(lines, column_count)): + for rule in relations: + # Явные правила из конфига пропускаются, если цель отсутствует в данных колонки. + # Сгенерированные подстроки всегда проверяются (если они сгенерированы, то цель есть). + is_subline = rule in subline_rules + if not is_subline and rule.target not in amounts: + continue + 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: + hard.append(failed) + warning_msg = f"соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}" + warnings.append(warning_msg) + else: + soft.append(failed) + warning_msg = ( + f"мягкое соотношение «{rule.relation}» нарушено (колонка {column_index}): {expected} ≠ {actual}" + ) + warnings.append(warning_msg) + # списки задаются явно: `to_wire` сериализует только заданные поля, пустой список = «проверено, сошлось» + return StatementChecks(relations_failed=hard, soft_relations_failed=soft), warnings diff --git a/api/src/parser/statements/columns.py b/api/src/parser/statements/columns.py new file mode 100644 index 0000000..a9d5c64 --- /dev/null +++ b/api/src/parser/statements/columns.py @@ -0,0 +1,293 @@ +"""Заголовок таблицы формы, колонка кодов, колонка наименований, колонки-периоды (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 + note_cols: tuple[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, *, relaxed: bool = False) -> int | None: + """Колонка с наибольшим числом кодов, если кодов ≥ `min_codes` и их доля среди непустых ≥ `min_share`. + + `relaxed` — порог листа-продолжения (`min_codes_continuation`): лист после листа формы может нести одну строку. + """ + 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 + min_codes = headers.min_codes_continuation if relaxed else headers.min_codes + best: int | None = None + for col in sorted(counts): + codes, nonempty = counts[col] + if codes < 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 + patterns = ( + rules.statement_periods.on_date, + rules.statement_periods.numeric_date, + rules.statement_periods.for_months, + rules.statement_periods.for_range, + ) + return ( + allow_bare_year + or not rules.statement_periods.for_year.search(label) + or any(pattern.search(label) for pattern in patterns) + ) + + +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 _upper_header_row(sheet, rules, row, code_col, first_row) + return None + + +def _upper_header_row(sheet: Sheet, rules: Rules, row: int, code_col: int, first_row: int) -> int: + """Без подписи «Код» найденная строка — нижняя в двухстрочном заголовке: строка выше с подписями только правее кодов + и без чисел («На 31 декабря» / «Изменение, %») — его первая строка.""" + above = row - 1 + if above < first_row or is_numbering_row(sheet, above, rules) or sheet.text_at(above, code_col) != "": + return row + cells = sheet.row_cells(above) + if any(cell.kind == "number" for _, cell in cells) or not any(col > code_col for col, _ in cells): + return row + glued, foreign = False, False + for col, _ in sheet.row_cells(row): + upper, lower = cell_label(sheet, above, col), cell_label(sheet, row, col) + if not upper or col <= code_col: + continue + # подпись над датой склеивается с ней в период («На 31 декабря» + «2024 г.»); дата, которая уже полна без + # подписи, и подпись шапки документа над ней («Единица измерения: тыс. руб.») — не заголовок колонки + joined = rules.statement_periods.period_end(f"{upper} {lower}") is not None + complete = _is_period_label(lower, rules, allow_bare_year=False) + if joined and not complete: + glued = True + else: + foreign = True + return above if glued or not foreign else row + + +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 + is_following_numbering = is_numbering_row(sheet, following, rules) + is_following_empty = sheet.is_row_empty(following) + if following < first_code_row and not is_following_numbering and not is_following_empty: + # Проверяем, является ли следующая строка продолжением заголовка: + # 1. Все непустые ячейки находятся правее кодов, ИЛИ + # 2. В колонке кодов пусто, нет чисел, и есть ячейки правее кодов + all_cells_right = all(col > code_col for col, _ in sheet.row_cells(following)) + code_col_empty = sheet.text_at(following, code_col) == "" + no_numbers = not any(cell.kind == "number" for _, cell in sheet.row_cells(following)) + has_cells_right = any(col > code_col for col, _ in sheet.row_cells(following)) + + if all_cells_right or (code_col_empty and no_numbers and has_cells_right): + 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( # noqa: PLR0913, PLR0917 - сигнатура соответствует вызовам + 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 _note_columns(sheet: Sheet, rules: Rules, code_col: int, header_row: int, header_last_row: int) -> tuple[int, ...]: + return tuple( + col + for row in range(header_row, header_last_row + 1) + for col, _ in sheet.row_cells(row) + if col < code_col and rules.statement_headers.is_explanations_caption(cell_label(sheet, row, col)) + ) + + +def find_layout(sheet: Sheet, rules: Rules, first_row: int, last_row: int, *, relaxed: bool = False) -> TableLayout: + code_col = find_code_column(sheet, rules, first_row, last_row, relaxed=relaxed) + 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) + notes = _note_columns(sheet, rules, code_col, header_row, header_last_row) + return TableLayout(header_row, header_last_row, code_col, name_col, periods, skipped, header_last_row + 1, notes) diff --git a/api/src/parser/statements/detect.py b/api/src/parser/statements/detect.py new file mode 100644 index 0000000..d91c488 --- /dev/null +++ b/api/src/parser/statements/detect.py @@ -0,0 +1,144 @@ +"""Блоки форм на листе и вид листа форм (design D-2, D-3). + +Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были +строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская +отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или +колонкой кодов — продолжение. +""" + +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"] + + +@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, _ 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 + # маркер продолжения («Форма 0710001 с.2») — не заголовок нового блока по ОКУД + if not headers.is_continuation(label): + by_okud = headers.okud_type(label) + if by_okud is not None and okud is None: + code = headers.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]: + found: list[int] = [] + for row in range(1, sheet.n_rows + 1): + if any(line_code(cell, rules) for _, cell in sheet.row_cells(row)): + found.append(row) + return found + + +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 None) or (title.title_kind if title is not None else None) + if kind is None: # группа всегда несёт заголовок или ОКУД с типом; защита от пустой группы + continue + 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, + 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 is_weak_continuation(sheet: Sheet, rules: Rules) -> bool: + """Лист с кодами форм, но меньше `min_codes` (одна строка-продолжение). Годится лишь после листа формы: решает + вызывающий (`BookParser`), первая страница формы с таким числом кодов формой не считается.""" + return find_code_column(sheet, rules, 1, sheet.n_rows, relaxed=True) is not None + + +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" diff --git a/api/src/parser/statements/header.py b/api/src/parser/statements/header.py new file mode 100644 index 0000000..70579cc --- /dev/null +++ b/api/src/parser/statements/header.py @@ -0,0 +1,175 @@ +"""Шапка формы по меткам словаря (design D-5): значение — правее метки в той же строке, иначе в следующей строке. + +Особые правила: ИНН ниже метки «аудитор» не берётся; организация — после «Организация», а при пустом значении — +ближайшая строка выше с признаком ОПФ (НПЗ); дата — из подзаголовка формы («на <дата>», «за <период>») либо из тройки +«число | месяц | год»; единицы — текст как в файле. Найденные поля получают `*_confidence = 100`. +""" + +import datetime +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) if marker is not None else StatementHeader()), [] + 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( + **({} if block.form_name is None else {"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 diff --git a/api/src/parser/statements/lines.py b/api/src/parser/statements/lines.py new file mode 100644 index 0000000..15bfbf7 --- /dev/null +++ b/api/src/parser/statements/lines.py @@ -0,0 +1,129 @@ +"""Строки формы с кодом и их значения (design D-6). + +Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них +есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money` +(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не +нормализуются; счётчики дробных значений нужны предупреждению документа (`statement.py`: одно на документ). +""" + +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 +from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code + +__all__ = ["LinesResult", "collect_lines"] + +_FULL_CONFIDENCE = 100 + + +@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: + has_fractional = parsed.value % 1 != 0 + value = StatementValue( + column_index=column_index, + amount=parsed.value, + confidence=_FULL_CONFIDENCE, + is_recognized=True, + ) + return value, has_fractional + if parsed.warning is None: # пусто или прочерк + value = StatementValue( + column_index=column_index, + amount=None, + confidence=_FULL_CONFIDENCE, + is_recognized=True, + ) + return value, False + value = StatementValue( + column_index=column_index, + amount=None, + string_value=cell.text, + confidence=_FULL_CONFIDENCE, + is_recognized=False, + ) + return value, False + + +def _is_sum(value: Decimal | None) -> bool: + """Сумма — ненулевое число: нули в строках-заголовках разделов суммами не считаются.""" + return value is not None and value != 0 + + +def _line_name(sheet: Sheet, row: int, layout: TableLayout) -> str: + """Название строки: ячейка колонки названий, а если она пуста («в том числе», итоги с отступом) — ближайшая + непустая текстовая ячейка левее колонки кодов, кроме колонки «Пояснения».""" + if layout.name_col is not None: + name = cell_label(sheet, row, layout.name_col) + if name: + return name + for col in range(layout.code_col - 1, 0, -1): + if col in layout.note_cols or sheet.cell(row, col).kind != "text": + continue + name = cell_label(sheet, row, col) + if name: + return name + return "" + + +def collect_lines( # noqa: PLR0913 + 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 is_numbering_row(sheet, row, rules): # «1 2 3 4 5», повторённая на разрыве страницы + continue + if any(_is_sum(to_money(sheet.cell(row, column.col)).value) 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 = _line_name(sheet, row, layout) + result.lines.append( + StatementLine( + line_code=code, + line_code_confidence=_FULL_CONFIDENCE, + line_name=name, + line_name_confidence=_FULL_CONFIDENCE if name else None, + values=values, + ) + ) + if result.skipped_with_sums: + result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}") + return result diff --git a/api/src/parser/statements/merge.py b/api/src/parser/statements/merge.py new file mode 100644 index 0000000..4cfd6ae --- /dev/null +++ b/api/src/parser/statements/merge.py @@ -0,0 +1,57 @@ +"""Черновик документа формы и присоединение листов-продолжений (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", "join_blocker"] + + +@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 join_blocker(draft: Draft, layout: TableLayout, kind: FormType | None = None) -> str | None: + """Почему продолжение нельзя присоединить к открытому документу (`None` — можно): у документа нет колонки кодов, + тип формы в маркере «Форма 0710002 с.2» отличается от типа документа, число колонок-сумм не совпало.""" + if not draft.has_code_column: + return "у открытого документа нет колонки кодов" + if kind is not None and draft.kind is not None and kind != draft.kind: + return f"тип формы продолжения {kind} не совпадает с типом открытого документа {draft.kind}" + if len(layout.periods) != len(draft.periods): + return f"число колонок сумм {len(layout.periods)} не совпадает с открытым документом ({len(draft.periods)})" + return None + + +def can_join(draft: Draft, layout: TableLayout, kind: FormType | None = None) -> bool: + """Продолжение присоединяется, если число колонок-сумм совпало с открытым документом, а тип формы из маркера (если + он есть) совпал с типом документа.""" + return join_blocker(draft, layout, kind) is None + + +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) diff --git a/api/src/parser/statements/statement.py b/api/src/parser/statements/statement.py new file mode 100644 index 0000000..ee07bf8 --- /dev/null +++ b/api/src/parser/statements/statement.py @@ -0,0 +1,192 @@ +"""Конвейер форм (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, line_code +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, join_blocker + +__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] = [] + dropped = False + 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: + dropped = not self._continuation(sheet, drafts, warnings) or dropped + documents = [self._document(draft) for draft in drafts] + for draft in drafts: + warnings.extend(draft.warnings) + ok = not dropped and 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]) -> bool: + """`False` — лист потерян (нет колонки кодов): строки не попали ни в один документ, статус не `done`.""" + weak = False + try: + layout = find_layout(sheet, self._rules, 1, sheet.n_rows) + except LayoutNotFoundError: + try: # лист с 1–2 кодами (O-9): допустим только как присоединяемое продолжение + layout = find_layout(sheet, self._rules, 1, sheet.n_rows, relaxed=True) + weak = True + except LayoutNotFoundError: + warnings.append(_line(sheet, NO_CODE_COLUMN)) + return False + marker_kind, _ = continuation_form(sheet, self._rules) + open_draft = next((draft for draft in reversed(drafts) if draft.kind != "DISCLOSURE"), None) + if weak and (open_draft is None or not can_join(open_draft, layout, marker_kind)): + blocker = ( + "нет открытого документа формы" if open_draft is None else join_blocker(open_draft, layout, marker_kind) + ) + codes = sum( + 1 for row in range(1, sheet.n_rows + 1) if line_code(sheet.cell(row, layout.code_col), self._rules) + ) + minimum = self._rules.statement_headers.min_codes + warnings.append(_line(sheet, f"лист с кодами форм ({codes} < {minimum}) не присоединён: {blocker}")) + return False + if open_draft is not None and can_join(open_draft, layout, marker_kind): + 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 True + kind = marker_kind + 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) + return True + + def _fill( # noqa: PLR0913, PLR0917 - все части документа передаются явно + 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=[], + ) + draft.warnings.extend(self._fractional_warning(draft)) + checks, check_warnings = run_statement_checks( + draft.lines, len(columns), kind if kind != "OTHER" else "BALANCE", self._rules + ) + checks = checks.model_copy(update={"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 + ) + + def _fractional_warning(self, draft: Draft) -> list[str]: + """Одно предупреждение на документ (все его листы): при «тыс.» в единицах доля значений с дробной частью ≥ + порога словаря — вероятно, значения в рублях (OQ-137).""" + headers = self._rules.statement_headers + units = draft.header.units + if not (units and headers.is_thousands(units) and draft.numeric_values): + return [] + if draft.fractional_values * 100 < draft.numeric_values * headers.fractional_share_percent: + return [] + text = f"значений с дробной частью при единице тыс. руб.: {draft.fractional_values} из {draft.numeric_values}" + return [f"sheet={draft.sheet_names[0]} {text}"] + + @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 + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2344d5b..b11a048 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -11,6 +11,9 @@ from fixtures_helpers import ( osv_dkb_golden_files, osv_fixture_files, resolve_fixtures_dir, + statement_files, + statement_synthetic_files, + statement_synthetic_skip_reason, synthetic_files, synthetic_skip_reason, ) @@ -78,13 +81,19 @@ def fixtures_dir() -> Path: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и - `synthetic_file` (синтетика); без каталога — один параметр со skip.""" + """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка), + `synthetic_file` (синтетика ОСВ), `statement_file` (реальные формы) и `statement_synthetic_file` (синтетика форм); + без каталога — один параметр со skip.""" sources = { "osv_file": (osv_fixture_files, fixtures_skip_reason), "dkb_file": (osv_dkb_files, fixtures_skip_reason), "dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason), "synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()), + "statement_file": (statement_files, fixtures_skip_reason), + "statement_synthetic_file": ( + statement_synthetic_files, + lambda: fixtures_skip_reason() or statement_synthetic_skip_reason(), + ), } for argname, (listing, skip_reason) in sources.items(): if argname not in metafunc.fixturenames: diff --git a/api/tests/fixtures/test_golden.py b/api/tests/fixtures/test_golden.py index 937742e..ce3d286 100644 --- a/api/tests/fixtures/test_golden.py +++ b/api/tests/fixtures/test_golden.py @@ -34,13 +34,39 @@ def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_fi check_snapshot(fixtures_dir, synthetic_file, parser) +def test_statement_file_matches_golden_snapshot(fixtures_dir: Path, statement_file: Path, parser: Parser) -> None: + """Реальные формы `statements-xlsx/**`: снимки в `golden/expected_statement_parse/`.""" + check_snapshot(fixtures_dir, statement_file, parser) + + +def test_statement_synthetic_matches_golden_snapshot( + fixtures_dir: Path, statement_synthetic_file: Path, parser: Parser +) -> None: + check_snapshot(fixtures_dir, statement_synthetic_file, parser) + + def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None: """Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет.""" - from fixtures_helpers import osv_dkb_golden_files, osv_fixture_files, synthetic_files # noqa: PLC0415 - один тест + from fixtures_helpers import ( # noqa: PLC0415 - один тест + osv_dkb_golden_files, + osv_fixture_files, + statement_files, + statement_synthetic_files, + synthetic_files, + ) - sources = [*osv_fixture_files(fixtures_dir), *osv_dkb_golden_files(fixtures_dir), *synthetic_files(fixtures_dir)] + sources = [ + *osv_fixture_files(fixtures_dir), + *osv_dkb_golden_files(fixtures_dir), + *synthetic_files(fixtures_dir), + *statement_files(fixtures_dir), + *statement_synthetic_files(fixtures_dir), + ] expected = {golden_path(fixtures_dir, file) for file in sources} - directory = fixtures_dir / "golden" / "expected_osv_parse" - present = set(directory.rglob("*.json")) if directory.is_dir() else set() + present: set[Path] = set() + for name in ("expected_osv_parse", "expected_statement_parse"): + directory = fixtures_dir / "golden" / name + if directory.is_dir(): + present |= set(directory.rglob("*.json")) assert sorted(path.relative_to(fixtures_dir).as_posix() for path in present - expected) == [] diff --git a/api/tests/fixtures/test_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py index 9c71680..b200bf5 100644 --- a/api/tests/fixtures/test_rules_coverage.py +++ b/api/tests/fixtures/test_rules_coverage.py @@ -1,7 +1,8 @@ """Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`. -Запись — один `Pattern` словаря (`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`, +Запись — один `Pattern` словаря (`sheet_markers`, `form_titles`/`okud` форм, `groups`, `debit`, `credit`, +`service_columns`, `total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных, `currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически. @@ -16,12 +17,20 @@ from pathlib import Path import pytest -from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files -from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered +from fixtures_helpers import ( + osv_dkb_files, + osv_fixture_files, + statement_files, + statement_synthetic_files, + synthetic_files, +) +from rules_coverage import DICTIONARIES, PATTERNLESS, MatchCounter, iter_patterns, uncovered from src.parser import get_parser +from src.parser.book import BookParser from src.parser.osv import OsvParser from src.parser.rules import DEFAULT_RULES_DIR, Rules, load_rules +from src.parser.statements.statement import StatementParser pytestmark = pytest.mark.fixtures @@ -34,9 +43,21 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]: counter.install(monkeypatch) rules = load_rules(DEFAULT_RULES_DIR) base = get_parser() - assert isinstance(base, OsvParser) - parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds) - files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)] + assert isinstance(base, BookParser) + parser = BookParser( + rules, + osv=OsvParser(rules), + statements=StatementParser(rules), + max_bytes=base.max_bytes, + timeout_seconds=base.timeout_seconds, + ) + files = [ + *osv_fixture_files(fixtures_dir), + *osv_dkb_files(fixtures_dir), + *synthetic_files(fixtures_dir), + *statement_files(fixtures_dir), + *statement_synthetic_files(fixtures_dir), + ] for file in files: parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type] return rules, counter, len(files) @@ -56,11 +77,11 @@ def test_every_dictionary_entry_fires_on_fixtures_and_synthetic(coverage: tuple[ def test_coverage_sees_every_dictionary_entry_and_all_files(coverage: tuple[Rules, MatchCounter, int]) -> None: - """Счётчик не пуст и обход видит записи всех четырёх словарей (защита от «покрытия» на пустом месте).""" + """Счётчик не пуст и обход видит записи всех семи словарей (защита от «покрытия» на пустом месте).""" rules, counter, files = coverage entries = list(iter_patterns(rules)) - assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) + assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) - PATTERNLESS assert sum(counter.hits.values()) > len(entries) - assert files >= 72 # noqa: PLR2004 - реальные ОСВ из fixtures + assert files >= 95 # noqa: PLR2004 - реальные ОСВ, формы и синтетика из fixtures assert not any(path.startswith("currency_codes") for _, path, _ in entries) diff --git a/api/tests/fixtures/test_rules_real.py b/api/tests/fixtures/test_rules_real.py index d9bfc1f..8d1d6b7 100644 --- a/api/tests/fixtures/test_rules_real.py +++ b/api/tests/fixtures/test_rules_real.py @@ -41,7 +41,6 @@ def test_real_osv_phrases_are_covered_by_dictionaries(osv_file: Path, rules: Rul return any(pattern.search(text) for text in texts for pattern in patterns) assert any_cell(headers.sheet_markers), "нет заголовка ведомости" - assert not any_cell(headers.statement_markers), "ОСВ не должна выглядеть как форма ББ/ОФР" for group in headers.groups: assert any_cell(headers.groups[group]), f"нет блока сумм {group}" assert any_cell(headers.debit) diff --git a/api/tests/fixtures/test_statements_property.py b/api/tests/fixtures/test_statements_property.py new file mode 100644 index 0000000..f82553f --- /dev/null +++ b/api/tests/fixtures/test_statements_property.py @@ -0,0 +1,89 @@ +"""Инварианты форм на всех `../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 = 18 +MAX_PARSE_SECONDS = 5 + + +def document_ok(document: StatementDocument) -> bool: + """Документ пригоден (done): критерий `StatementParser._is_ok`, выраженный полями результата.""" + if document.document_type == "DISCLOSURE": + return True + checks = document.checks + return ( + checks is not None + and bool(document.columns) + and bool(document.header.organization_name or document.header.inn) + and not checks.relations_failed + and not checks.duplicate_line_codes + ) + + +def explained_needs_check(document: StatementDocument) -> bool: + """Причина needs_check видна в результате: нарушенное соотношение, дубликаты кодов, нет колонок или шапки.""" + checks = document.checks + return bool( + checks is None + or checks.relations_failed + or checks.duplicate_line_codes + or not document.columns + or not (document.header.organization_name or document.header.inn) + ) + + +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() + file_type = cast(FileType, statement_file.suffix.lstrip(".").upper()) + outcome = get_parser().parse(statement_file.read_bytes(), file_type) + 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] + all_done = all(item.status == "done" for item in expectation.documents) + assert outcome.status is (ResultStatus.DONE if all_done 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 + failed = {relation.relation for relation in document.checks.relations_failed} if document.checks else set() + if item.failed_relations is not None: + assert failed == set(item.failed_relations) + if item.complete: + assert document.checks is not None + assert document.checks.relations_failed == [], document.checks + assert document.columns + assert all(column.period_date is not None for column in document.columns) + # статус документа — по критерию парсера (statement.py::_is_ok), независимо от манифеста + assert document_ok(document) == (item.status == "done"), (item, document.header) + if item.status == "needs_check": + assert not document_ok(document) + assert explained_needs_check(document), "needs_check должен объясняться полями результата" + if expectation.organization: + assert document.header.organization_name == expectation.organization + if expectation.inn: + if document.document_type in expectation.inn_optional_for: + assert document.header.inn in {None, expectation.inn} + else: + assert document.header.inn == expectation.inn + assert_valid_result(outcome) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index d991a4c..9a768df 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -5,6 +5,7 @@ from pathlib import Path FIXTURES_ENV = "ARM_KA_FIXTURES" OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) +STATEMENTS_DIR = "statements-xlsx" _SYNTHETIC = "synthetic" _OSV_DKB = "osv-dkb" # Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк) @@ -80,3 +81,32 @@ def synthetic_files(root: Path) -> list[Path]: if not directory.is_dir(): return [] return sorted(path for path in directory.iterdir() if _is_workbook(path, directory)) + + +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]: + """Файлы синтетических форм `root/statements-xlsx/synthetic/*.xls|xlsx|xlsb`; отсортированы.""" + directory = root / STATEMENTS_DIR / _SYNTHETIC + if not directory.is_dir(): + return [] + return sorted(path for path in directory.iterdir() if _is_workbook(path, directory)) + + +def statement_synthetic_skip_reason() -> str | None: + """Причина пропуска: каталог fixtures есть, но `statements-xlsx/synthetic` отсутствует или пуст. + `None` — файлы есть или нет самих fixtures (тогда причину даёт `fixtures_skip_reason`).""" + root = resolve_fixtures_dir() + if not (root / STATEMENTS_DIR).is_dir() or statement_synthetic_files(root): + return None + synth_path = root / STATEMENTS_DIR / _SYNTHETIC + return f"нет синтетики форм: {synth_path} отсутствует или пуст (сгенерируйте: см. README fixtures)" diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py index e08ff07..702df89 100644 --- a/api/tests/golden_helpers.py +++ b/api/tests/golden_helpers.py @@ -26,6 +26,7 @@ from src.parser.cli import STUB_ID, _error_payload, _file_type __all__ = [ "MISSING", "SNAPSHOT_DIR", + "STATEMENT_SNAPSHOT_DIR", "SyncReport", "VOLATILE", "check_snapshot", @@ -38,12 +39,16 @@ __all__ = [ ] SNAPSHOT_DIR = "expected_osv_parse" +STATEMENT_SNAPSHOT_DIR = "expected_statement_parse" +STATEMENTS_DIR = "statements-xlsx" GOLDEN_DIR = "golden" OSV_DIR = "osv" OSV_DKB_DIR = "osv-dkb" SYNTHETIC_DIR = "synthetic" REAL_PART = "real" DKB_PART = "dkb" +STATEMENTS_PART = "statements" +STATEMENTS_SYNTHETIC_PART = "statements-synthetic" JSON_INDENT = 2 # Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms")) @@ -65,8 +70,15 @@ def normalize_result(result: dict[str, Any]) -> dict[str, Any]: return normalized +def _is_statement(fixtures_root: Path, file: Path) -> bool: + return fixtures_root / STATEMENTS_DIR in file.parents + + def _mirrored(fixtures_root: Path, file: Path) -> Path: - """Путь файла в зеркале снимков: относительно `osv/`, а для пакета ДКБ — `osv-dkb/<путь внутри osv-dkb>`.""" + """Путь файла в зеркале снимков: для форм — относительно `statements-xlsx/`, для пакета ДКБ — + `osv-dkb/<путь внутри osv-dkb>`, иначе относительно `osv/`.""" + if _is_statement(fixtures_root, file): + return file.relative_to(fixtures_root / STATEMENTS_DIR) dkb = fixtures_root / OSV_DKB_DIR if dkb in file.parents: return Path(OSV_DKB_DIR) / file.relative_to(dkb) @@ -75,9 +87,10 @@ def _mirrored(fixtures_root: Path, file: Path) -> Path: def golden_path(fixtures_root: Path, file: Path) -> Path: """`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ — - в подкаталоге `osv-dkb/`.""" + в подкаталоге `osv-dkb/`; для форм `statements-xlsx/**` — `golden/expected_statement_parse/<путь>.json`.""" relative = _mirrored(fixtures_root, file) - return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json" + snapshot_dir = STATEMENT_SNAPSHOT_DIR if _is_statement(fixtures_root, file) else SNAPSHOT_DIR + return fixtures_root / GOLDEN_DIR / snapshot_dir / relative.parent / f"{relative.stem}.json" def render(parser: Parser, file: Path) -> dict[str, Any]: @@ -145,7 +158,7 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, * report = SyncReport() expected: set[Path] = set() files = list(files) - present_parts = {_source_part(_mirrored(fixtures_root, file)) for file in files} + present_parts = {_file_part(fixtures_root, file) for file in files} for file in files: target = golden_path(fixtures_root, file) expected.add(target) @@ -164,27 +177,45 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, * if write: target.parent.mkdir(parents=True, exist_ok=True) target.write_text(current, encoding="utf-8") - snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR - for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []: - if stale in expected: - continue - if _source_part(stale.relative_to(snapshots)) not in present_parts: - report.protected.append(stale.relative_to(fixtures_root)) - continue - report.orphans.append(stale.relative_to(fixtures_root)) - if write: - stale.unlink() + for snapshot_dir in (SNAPSHOT_DIR, STATEMENT_SNAPSHOT_DIR): + snapshots = fixtures_root / GOLDEN_DIR / snapshot_dir + for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []: + if stale in expected: + continue + if _snapshot_part(snapshot_dir, stale.relative_to(snapshots)) not in present_parts: + report.protected.append(stale.relative_to(fixtures_root)) + continue + report.orphans.append(stale.relative_to(fixtures_root)) + if write: + stale.unlink() return report -def _source_part(relative: Path) -> str: - """Часть дерева по пути в зеркале (относительно `osv/` или каталога снимков): `synthetic`, `dkb` или `real`.""" +def _file_part(fixtures_root: Path, file: Path) -> str: + """Часть дерева файла: `synthetic`, `dkb`, `real` (ОСВ), `statements` или `statements-synthetic` (формы).""" + mirrored = _mirrored(fixtures_root, file) + if _is_statement(fixtures_root, file): + return _statement_part(mirrored) + return _osv_part(mirrored) + + +def _snapshot_part(snapshot_dir: str, relative: Path) -> str: + """Часть дерева по пути снимка относительно его каталога (`expected_osv_parse` или `expected_statement_parse`).""" + return _statement_part(relative) if snapshot_dir == STATEMENT_SNAPSHOT_DIR else _osv_part(relative) + + +def _osv_part(relative: Path) -> str: first = relative.parts[0] if relative.parts else "" if first == SYNTHETIC_DIR: return SYNTHETIC_DIR return DKB_PART if first == OSV_DKB_DIR else REAL_PART +def _statement_part(relative: Path) -> str: + first = relative.parts[0] if relative.parts else "" + return STATEMENTS_SYNTHETIC_PART if first == SYNTHETIC_DIR else STATEMENTS_PART + + def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None: """Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом.""" snapshot = golden_path(fixtures_root, file) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 4d2a51b..7a5cc75 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -9,7 +9,8 @@ Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия», -«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO). +«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO), +«Реальные формы через воркер» (книга НПЗ → `needs_check`, документы BALANCE и INCOME, `result_schema_version 1.2`). Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с @@ -46,6 +47,12 @@ from src.core.config import get_settings pytestmark = pytest.mark.integration REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls" +REAL_NPZ = ( + Path("statements-xlsx") + / "npz" + / "2025-Q3" + / "Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" +) CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts" EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8")) @@ -298,7 +305,7 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file( body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}") assert body["status"] == "done", body - assert body["result_schema_version"] == "1.1" + assert body["result_schema_version"] == "1.2" assert body["metrics"]["duration_ms"] >= 0 result_ref = body["result_s3"] assert result_ref["bucket"] == request["s3"]["bucket"] @@ -313,3 +320,38 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file( assert [document["document_type"] for document in stored["documents"]] == ["OSV"] assert stored["documents"][0]["header"]["account_code_raw"] == "66" await core.assert_quiet(request) + + +@pytest.mark.fixtures +async def test_real_statements_through_worker_give_needs_check_with_two_documents( + core: Core, uploads: Uploads, fixtures_dir: Path +) -> None: + """«Реальные формы через воркер»: книга НПЗ → ack → `needs_check` (рубли при шапке «тыс. руб.», OQ-138).""" + request = _new_request(key_name="npz_2025_q3.xlsx") + request["file_type"] = "XLSX" + await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE) + + await core.publish(request) + _assert_ack(await core.next_message(request), request) + body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}") + + assert body["status"] == "needs_check", body + assert body["result_schema_version"] == "1.2" + result_ref = body["result_s3"] + uploads.keys.append((result_ref["bucket"], result_ref["key"])) + + stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"])) + assert schema_errors(stored) == [] + assert RecognitionResult.model_validate(stored).to_wire() == stored + assert stored["status"] == "needs_check" + balance, income = stored["documents"] + assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"] + assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"} + assert {item["relation"] for item in income["checks"]["relations_failed"]} == { + "2100 = 2110 - 2120", + "2200 = 2100 - 2210 - 2220", + "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", + } + # лист с единственной строкой 2400 присоединён (O-9) + assert "2400" in {line["line_code"] for line in income["lines"]} + await core.assert_quiet(request) diff --git a/api/tests/rules_coverage.py b/api/tests/rules_coverage.py index d9230ed..c81e919 100644 --- a/api/tests/rules_coverage.py +++ b/api/tests/rules_coverage.py @@ -19,7 +19,12 @@ DICTIONARIES = { "groups": "osv_groups.yaml", "periods": "periods.yaml", "data_types": "data_types.yaml", + "statement_headers": "statement_headers.yaml", + "statement_periods": "statement_periods.yaml", + "statement_checks": "statement_checks.yaml", } +# Словари без записей распознавания: соотношения §7 — выражения над кодами, а не `Pattern` (в покрытии не участвуют) +PATTERNLESS = frozenset({"statement_checks.yaml"}) def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]: diff --git a/api/tests/statement_helpers.py b/api/tests/statement_helpers.py new file mode 100644 index 0000000..b125199 --- /dev/null +++ b/api/tests/statement_helpers.py @@ -0,0 +1,42 @@ +"""Манифест ожиданий по реальным формам `fixtures/statements-xlsx/**` (`statements_manifest.yaml`).""" + +from functools import cache +from pathlib import Path +from typing import Literal + +import yaml +from fixtures_helpers import STATEMENTS_DIR +from pydantic import BaseModel, ConfigDict + +MANIFEST_PATH = Path(__file__).resolve().parent / "statements_manifest.yaml" + + +class DocumentExpectation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + type: Literal["BALANCE", "INCOME", "DISCLOSURE"] + status: Literal["done", "needs_check"] + complete: bool + reason: str | None = None + sheets: int | None = None + failed_relations: list[str] | None = None # None — не проверяется; список — точное множество relations_failed + + +class Expectation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + organization: str | None = None + inn: str | None = None + inn_optional_for: tuple[str, ...] = () + documents: list[DocumentExpectation] + + +@cache +def load_manifest() -> dict[str, Expectation]: + """Ожидания по файлам; ключ — путь относительно `statements-xlsx/` (posix).""" + raw = yaml.safe_load(MANIFEST_PATH.read_text(encoding="utf-8")) + return {key: Expectation.model_validate(value) for key, value in raw["files"].items()} + + +def relative_key(fixtures_dir: Path, path: Path) -> str: + return path.relative_to(fixtures_dir / STATEMENTS_DIR).as_posix() diff --git a/api/tests/statements_manifest.yaml b/api/tests/statements_manifest.yaml new file mode 100644 index 0000000..f3f0404 --- /dev/null +++ b/api/tests/statements_manifest.yaml @@ -0,0 +1,64 @@ +# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. +# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). +# Причина needs_check — данные файла (страница без шапки, шаблон без сумм, рубли при шапке «тыс. руб.»), не дефект разбора. +# Значения сверены прогоном парсера на файлах (R-13, OQ-138): расхождения с первоначальным планом помечены «по факту». +files: + "unagrande-co/2024-FY/Б.Б Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится", failed_relations: ["1600 = 1700"]}] + "unagrande-co/2024-FY/Б.Б. Лист 2.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + "unagrande-co/2024-FY/ОФР. Лист 1.xls": + organization: 'Акционерное общество "Унагранде Компани"' + inn: "3249004697" + documents: + - {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., итоги 2100/2200/2300 округлены до тысяч и расходятся с суммой частей (OQ-138)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + "unagrande-co/2024-FY/ОФР. Лист 2 .xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + "unagrande-group/2024-FY/ББ. Лист 1 .xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + "unagrande-group/2024-FY/ББ. Лист 2 (2).xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + "unagrande-group/2024-FY/ОФР. Лист 2.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет", failed_relations: []}] + "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + "unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls": + organization: 'Акционерное общество "Унагранде Групп"' + inn: "7728757141" + documents: + - {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., 2100 расходится на 17.50/9.43 руб., 2200/2300 — округление итогов до тысяч (OQ-138)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": + organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + inn: "2315995868" + inn_optional_for: [INCOME] + documents: + - {type: BALANCE, status: needs_check, complete: false, sheets: 5, reason: "1600 = 1700 расходится на 1.94/9.02/2.48 руб.: значения в рублях при шапке тыс. руб. (OQ-138)", failed_relations: ["1600 = 1700"]} + - {type: INCOME, status: needs_check, complete: false, sheets: 3, reason: "по факту: рубли с копейками при шапке тыс. руб., 2100/2200/2300 расходятся с округлёнными итогами (OQ-138); лист продолжения с единственной строкой 2400 присоединён (O-9)", failed_relations: ["2100 = 2110 - 2120", "2200 = 2100 - 2210 - 2220", "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350"]} + "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: BALANCE, status: done, complete: true, failed_relations: []}] + "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx": + organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' + inn: "7016006635" + documents: [{type: INCOME, status: done, complete: true, failed_relations: []}] + "templates/ББ до 01.01.2025.xlsx": + documents: + - {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: []} + - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE"} + "templates/ББ после 01.01.2025.xlsx": + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + "templates/ОФР_до 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + "templates/ОФР_после 01.01.2025.xlsx": + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 11893d3..4c13f50 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -28,7 +28,7 @@ from openpyxl.styles import Alignment, Font from tests.synthetic.xlsb_writer import write_xlsb -__all__ = ["FILE_NAMES", "generate_all", "main"] +__all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"] FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0) ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) @@ -557,7 +557,7 @@ def _s13() -> list[SheetSpec]: def _s14() -> list[SheetSpec]: - """Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ.""" + """Три листа форм, опознаваемые каждый одним своим признаком (заголовки и ОКУД словаря форм), и лист ОСВ.""" return [ form_sheet("Форма 1", "Форма по ОКУД 0710001"), form_sheet("ОФР", "Отчет о финансовых результатах"), diff --git a/api/tests/synthetic/generate_statements.py b/api/tests/synthetic/generate_statements.py new file mode 100644 index 0000000..6467ff7 --- /dev/null +++ b/api/tests/synthetic/generate_statements.py @@ -0,0 +1,228 @@ +"""Синтетические формы ББ/ОФР (Срез 2b): `python -m tests.synthetic.generate_statements <каталог>` из `api/`. + +st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две +строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ +и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных +файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»). +st07 — лист-продолжение ОФР с одной кодовой строкой (O-9). Детерминирован (см. `generate.py`). +""" + +import argparse +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +from tests.synthetic.generate import ORG, OsvSpec, SheetSpec, base_tree, build_osv, 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] + + +# Значения st02, согласованные со всеми жёсткими соотношениями ББ (statement_checks.yaml) в обеих колонках: +# 1100 = 1150, 1200 = 1210 (скобки — запись 66н), 1600 = 1100 + 1200 = 1700, 1300 = 1310, 1700 = 1300. +ST02_VALUES: dict[str, tuple[int | str, int | str]] = { + "1150": (100, 120), + "1100": (100, 120), + "1210": ("(50)", "(40)"), + "1200": ("(50)", "(40)"), + "1600": (50, 80), + "1310": (50, 80), + "1300": (50, 80), + "1700": (50, 80), +} + + +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) + value_now, value_before = ST02_VALUES.get(code, (current, previous)) + sheet.put(index, 3, value_now) + sheet.put(index, 4, value_before) + 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] + + +DISCLOSURE_TITLES = ( + "Расшифровка дебиторской задолженности", + "Отчет об изменениях капитала", + "Отчет о движении денежных средств", + "Отчет о целевом использовании средств", +) +ST06_OKUD_ONLY = "0710005" # прочие формы 0710xxx — расшифровки (design D-3, решение №2) + + +def _st06() -> list[SheetSpec]: + income = SheetSpec("Прибыли") + income.put(1, 2, "Отчет о прибылях и убытках") + income.put(2, 2, "«Синтетика-Групп»") # название без правовой формы и прямых кавычек: найдётся по « + income.put(3, 1, "Организация") # значения правее и ниже нет: название берётся из строки выше + income.put(5, 1, "Адрес") + income.put(5, 3, "г. Москва, ул. Синтетическая, д. 1") + income.put(6, 1, "Единица измерения:") + income.put(6, 3, "тыс. руб.") + header_row = 8 + _table(income, header_row, ["31.12.2024", "31.12.2023"], INCOME_LINES) + income.put(header_row, 3, "Пояснения") + # длинный текст в колонке «Пояснения»: без подписи она сошла бы за колонку названий (columns._name_column) + income.put(header_row + 1, 3, "См. подробные пояснения к выручке в составе раздела пятого") + sheets = [income] + for title in DISCLOSURE_TITLES: + sheet = SheetSpec(title[:25]) + sheet.put(1, 2, title) + sheets.append(sheet) + okud_only = SheetSpec("Прочая форма") + okud_only.put(1, 2, "Дополнительные сведения") + okud_only.put(2, 6, "Форма по ОКУД") + okud_only.put(2, 7, ST06_OKUD_ONLY) + return [*sheets, okud_only] + + +def _st07() -> list[SheetSpec]: + """ОФР без строки 2400 на первом листе и лист-продолжение с единственной кодовой строкой 2400 (O-9, как у НПЗ).""" + first = SheetSpec("ОФР") + row = _head(first, 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002") + _table(first, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES[:-1]) + tail = SheetSpec("ОФР_2") + tail.put(1, 2, INCOME_LINES[-1][0]) + tail.put(1, 4, INCOME_LINES[-1][1]) + tail.put(1, 5, INCOME_LINES[-1][2]) + tail.put(1, 6, INCOME_LINES[-1][3]) + return [first, tail] + + +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), + "st06_header_variants.xlsx": (_st06, to_xlsx), + "st07_single_code_continuation.xlsx": (_st07, 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…st07." + ) + 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()) diff --git a/api/tests/synthetic/test_generate_statements.py b/api/tests/synthetic/test_generate_statements.py new file mode 100644 index 0000000..b4bf465 --- /dev/null +++ b/api/tests/synthetic/test_generate_statements.py @@ -0,0 +1,20 @@ +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", + "st06_header_variants.xlsx", + "st07_single_code_continuation.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) diff --git a/api/tests/synthetic/test_statement_synthetic_files.py b/api/tests/synthetic/test_statement_synthetic_files.py new file mode 100644 index 0000000..69b1b2f --- /dev/null +++ b/api/tests/synthetic/test_statement_synthetic_files.py @@ -0,0 +1,258 @@ +"""Сценарии спеки `statement-parsing` на синтетических файлах (Срез 2b, Task 13). + +Ожидания выведены из сценариев спеки и из содержимого генератора, а не из текущего вывода парсера: +файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`). +""" + +from datetime import date +from decimal import Decimal +from pathlib import Path +from typing import cast + +import pytest +from result_contract import assert_valid_result + +from src.contract import FileType, OsvDocument, ResultStatus, StatementDocument +from src.contract.result import StatementLine +from src.parser import ParseOutcome, get_parser +from src.parser.statements.statement import SKIPPED_COLUMN_WARNING +from tests.fixtures_helpers import statement_synthetic_skip_reason +from tests.synthetic.generate_statements import FILE_NAMES + +pytestmark = pytest.mark.fixtures + +SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic" +EXPECTED_STATEMENT_COUNT_TWO = 2 +EXPECTED_COLUMN_COUNT = 2 +EXPECTED_LINES_COUNT = 0 +EXPECTED_DISCLOSURES = 5 +EXPECTED_INCOME_LINES = 10 + + +@pytest.fixture(autouse=True) +def _require_synthetic() -> None: + """T13.4: fixtures есть, а `statements-xlsx/synthetic` нет/пуст — skip, а не падение.""" + reason = statement_synthetic_skip_reason() + if reason is not None: + pytest.skip(reason) + + +def synthetic_path(fixtures_dir: Path, name: str) -> Path: + return fixtures_dir / SYNTHETIC_DIR / name + + +def parse_path(path: Path) -> ParseOutcome: + return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper())) + + +def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome: + path = synthetic_path(fixtures_dir, name) + if not path.is_file(): + pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)") + return parse_path(path) + + +def statement_documents(outcome: ParseOutcome) -> list[StatementDocument]: + """Документы форм результата.""" + assert outcome.error is None, outcome.error + return [doc for doc in outcome.documents if isinstance(doc, StatementDocument)] + + +def osv_documents(outcome: ParseOutcome) -> list[OsvDocument]: + """Документы ОСВ результата.""" + return [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] + + +def one_statement(outcome: ParseOutcome) -> StatementDocument: + (doc,) = statement_documents(outcome) + return doc + + +def warnings_of(outcome: ParseOutcome) -> list[str]: + assert outcome.metrics is not None + assert outcome.metrics.warnings is not None + return outcome.metrics.warnings + + +def by_code(doc: StatementDocument) -> dict[str, StatementLine]: + """Строки по коду показателя.""" + lines = {line.line_code: line for line in doc.lines} + return lines + + +def get_line_value(line: StatementLine, column_index: int = 0) -> Decimal | None: + """Получить значение из строки по индексу колонки.""" + for value in line.values: + if value.column_index == column_index: + return value.amount + return None + + +# --- Состав набора и общие ожидания --- + + +def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: + """В `fixtures/statements-xlsx/synthetic/` лежат все файлы st01…st05.""" + present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("st*") if path.is_file()) + assert present == list(FILE_NAMES) + + +def test_every_synthetic_file_is_listed(statement_synthetic_file: Path) -> None: + """Каждый файл каталога соответствует одному из ожидаемых.""" + assert statement_synthetic_file.name in FILE_NAMES + + +# --- Сценарии спеки --- + + +def test_st01_both_forms_one_sheet(fixtures_dir: Path) -> None: + """st01: ББ и ОФР на одном листе; два документа, один locator, разделение кодов по типам.""" + outcome = parse_named(fixtures_dir, "st01_both_forms_one_sheet.xlsx") + stmt_docs = statement_documents(outcome) + assert len(stmt_docs) == EXPECTED_STATEMENT_COUNT_TWO + + # Оба документа имеют один locator + locators = {doc.locator for doc in stmt_docs} + assert len(locators) == 1 + + # Проверяем разделение кодов: 1xxx в BALANCE, 2xxx в INCOME + balance_codes = set() + income_codes = set() + for doc in stmt_docs: + codes = {line.line_code for line in doc.lines if line.line_code} + if doc.document_type == "BALANCE": + balance_codes.update(codes) + elif doc.document_type == "INCOME": + income_codes.update(codes) + + # Проверяем что коды не пересекаются + assert not (balance_codes & income_codes), f"Пересечение кодов: {balance_codes & income_codes}" + # Проверяем что есть коды обоих типов + assert any(code.startswith("1") for code in balance_codes), "Должны быть коды 1xxx в BALANCE" + assert any(code.startswith("2") for code in income_codes), "Должны быть коды 2xxx в INCOME" + + # Статус done + assert outcome.status is ResultStatus.DONE + + +def test_st02_csv_like_format(fixtures_dir: Path) -> None: + """st02: CSV-подобный лист; организация/ИНН найдены, units None, колонки [2024-12-31, 2023-12-31], 1210=-50.""" + outcome = parse_named(fixtures_dir, "st02_csv_like.xlsx") + doc = one_statement(outcome) + + # Проверяем организацию и ИНН + assert doc.header.organization_name == 'ООО "Синтетика"' + assert doc.header.inn == "7700000001" + + # Проверяем units = None + assert doc.header.units is None + + # Проверяем колонки: две даты + assert len(doc.columns) == EXPECTED_COLUMN_COUNT + assert all(col.period_date is not None for col in doc.columns) + assert [col.period_date for col in doc.columns] == [date(2024, 12, 31), date(2023, 12, 31)] + # T13.I2: «Изменение, %» не колонка, её значения (5) не читаются, предупреждение — ровно одно + assert all("Изменение" not in col.label for col in doc.columns) + assert all(len(line.values) == EXPECTED_COLUMN_COUNT for line in doc.lines) + skipped = [w for w in warnings_of(outcome) if SKIPPED_COLUMN_WARNING in w] + assert skipped == [f"sheet=Лист1 {SKIPPED_COLUMN_WARNING}: Изменение, %"] + + # Проверяем код 1210 = -50 + lines_by_code = by_code(doc) + assert get_line_value(lines_by_code["1210"], 0) == Decimal("-50") + assert get_line_value(lines_by_code["1210"], 1) == Decimal("-40") + + # T13.I1: данные согласованы со всеми жёсткими соотношениями — статус done, нарушений нет + assert outcome.status is ResultStatus.DONE + assert doc.checks.relations_failed == [] + + # Проверяем warning о единицах + warnings = warnings_of(outcome) + assert any("unit" in w.lower() or "измер" in w.lower() for w in warnings), ( + f"ожидается warning о единицах, получены: {warnings}" + ) + + +def test_st03_no_code_column(fixtures_dir: Path) -> None: + """st03: форма без колонки кодов; needs_check, warning о коде, lines = [].""" + outcome = parse_named(fixtures_dir, "st03_no_codes.xlsx") + doc = one_statement(outcome) + + assert outcome.status is ResultStatus.NEEDS_CHECK + assert len(doc.lines) == EXPECTED_LINES_COUNT + + warnings = warnings_of(outcome) + assert any("code" in w.lower() or "код" in w.lower() for w in warnings), ( + f"ожидается warning о коде, получены: {warnings}" + ) + + +def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None: + """st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, порядок правильный.""" + outcome = parse_named(fixtures_dir, "st04_mixed_book.xlsx") + + # Проверяем количество документов + assert len(outcome.documents) == EXPECTED_STATEMENT_COUNT_TWO + + # Проверяем типы и порядок + assert isinstance(outcome.documents[0], OsvDocument), "Первый документ должен быть OsvDocument" + assert isinstance(outcome.documents[1], StatementDocument), "Второй документ должен быть StatementDocument" + assert outcome.documents[1].document_type == "BALANCE" + + # Статус done + assert outcome.status is ResultStatus.DONE + + +def test_st05_continuation_page(fixtures_dir: Path) -> None: + """st05: одинокая страница 2; needs_check, warning о continuation.""" + outcome = parse_named(fixtures_dir, "st05_continuation_only.xlsx") + doc = one_statement(outcome) + + assert outcome.status is ResultStatus.NEEDS_CHECK + + warnings = warnings_of(outcome) + assert any( + "continuation" in w.lower() or "страниц" in w.lower() or "страница" in w.lower() or "page" in w.lower() + for w in warnings + ), f"ожидается warning о continuation, получены: {warnings}" + + # Проверяем что есть коды + codes = {line.line_code for line in doc.lines if line.line_code} + assert len(codes) > 0, "должны быть коды" + + +def test_st06_header_variants_documents_and_header(fixtures_dir: Path) -> None: + """st06: старое название ОФР, четыре вида расшифровок и форма только с ОКУД 0710xxx; шапка с «», «Адрес».""" + outcome = parse_named(fixtures_dir, "st06_header_variants.xlsx") + documents = statement_documents(outcome) + + assert [doc.document_type for doc in documents] == ["INCOME", *["DISCLOSURE"] * EXPECTED_DISCLOSURES] + income = documents[0] + assert income.header.organization_name == "«Синтетика-Групп»" + assert income.header.address == "г. Москва, ул. Синтетическая, д. 1" + assert len(income.lines) == EXPECTED_INCOME_LINES + assert [column.period_date for column in income.columns] == [date(2024, 12, 31), date(2023, 12, 31)] + assert all(doc.lines == [] for doc in documents[1:]) + assert income.checks is not None + assert income.checks.relations_failed == [] + + +def test_st07_single_code_continuation_joins_the_income_form(fixtures_dir: Path) -> None: + """st07 (O-9): лист с единственной кодовой строкой 2400 присоединяется к ОФР; без него 2400 терялась бы.""" + outcome = parse_named(fixtures_dir, "st07_single_code_continuation.xlsx") + document = one_statement(outcome) + + assert document.document_type == "INCOME" + assert document.locator == "sheet:ОФР,ОФР_2" + assert [line.line_code for line in document.lines][-1] == "2400" + assert document.checks is not None and document.checks.relations_failed == [] + assert outcome.status is ResultStatus.DONE + + +# --- Валидация контракта --- + + +def test_all_synthetic_results_are_valid(statement_synthetic_file: Path) -> None: + """Все результаты валидны по контракту.""" + outcome = parse_path(statement_synthetic_file) + assert_valid_result(outcome) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 3f0089a..d079ae1 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -95,10 +95,10 @@ EXPECTED: dict[str, Expect] = { # «12 месяцев» "s11_header_repeat.xlsx": Expect("outline", 2025, "Q4", True), # «январь - сентябрь», данные «НУ» - "s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"), + "s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, status=ResultStatus.NEEDS_CHECK, data_type="NU"), "s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False), # три листа форм по одному признаку + лист ОСВ - "s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True), + "s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), # названия групп сумм: «Входящее/Конечное», «Начальное/Исходящее сальдо» "s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2), # шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2 @@ -134,9 +134,8 @@ def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome: def documents(outcome: ParseOutcome) -> list[OsvDocument]: assert outcome.error is None, outcome.error - docs = list(outcome.documents) - assert all(isinstance(doc, OsvDocument) for doc in docs) - return cast(list[OsvDocument], docs) + """Документы ОСВ результата; документы форм (`StatementDocument`) разбираются отдельными сценариями.""" + return [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] def one_document(outcome: ParseOutcome) -> OsvDocument: @@ -422,14 +421,18 @@ def test_s11_header_repeat_is_skipped(fixtures_dir: Path) -> None: assert outcome.status is ResultStatus.DONE -def test_s12_statement_sheet_is_skipped_with_warning(fixtures_dir: Path) -> None: - """«Лист ББ + лист ОСВ»: один документ, лист формы пропущен с предупреждением о подсрезе 2b.""" +def test_s12_statement_sheet_goes_to_statement_pipeline(fixtures_dir: Path) -> None: + """«Лист ББ + лист ОСВ»: документ ОСВ и документ ББ; у синтетического ББ нет шапки и колонки кодов → needs_check.""" outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx") (doc,) = documents(outcome) assert doc.locator == "sheet:ОСВ 60" - assert any("Бухгалтерский баланс" in w and "skipped: statement" in w for w in warnings_of(outcome)) - assert outcome.status is ResultStatus.DONE + assert [(d.document_type, d.locator) for d in outcome.documents] == [ + ("OSV", "sheet:ОСВ 60"), + ("BALANCE", "sheet:Бухгалтерский баланс"), + ] + assert not any("skipped: statement" in w for w in warnings_of(outcome)) + assert outcome.status is ResultStatus.NEEDS_CHECK def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> None: @@ -449,14 +452,15 @@ def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: Path) -> None: - """Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) пропущены, ОСВ разобран.""" + """Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) дают три документа форм.""" outcome = parse_named(fixtures_dir, "s14_statement_forms.xlsx") (doc,) = documents(outcome) assert doc.locator == "sheet:ОСВ 60" - skipped = [w for w in warnings_of(outcome) if "skipped: statement" in w] - assert [w.split(" skipped")[0] for w in skipped] == ["sheet=Форма 1", "sheet=ОФР", "sheet=Форма 2"] - assert outcome.status is ResultStatus.DONE + statements = [(d.document_type, d.locator) for d in outcome.documents if d.document_type != "OSV"] + assert statements == [("BALANCE", "sheet:Форма 1"), ("INCOME", "sheet:ОФР"), ("INCOME", "sheet:Форма 2")] + assert not any("skipped: statement" in w for w in warnings_of(outcome)) + assert outcome.status is ResultStatus.NEEDS_CHECK def test_s15_group_title_synonyms_find_all_six_sum_columns(fixtures_dir: Path) -> None: diff --git a/api/tests/unit/parser/statements/__init__.py b/api/tests/unit/parser/statements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/unit/parser/statements/test_checks.py b/api/tests/unit/parser/statements/test_checks.py new file mode 100644 index 0000000..2d7cde2 --- /dev/null +++ b/api/tests/unit/parser/statements/test_checks.py @@ -0,0 +1,234 @@ +"""Соотношения §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 == [] + assert checks.soft_relations_failed == [] + assert 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: + """Допуск ±1: 1600 = 151 (ожидание), 1100 + 1200 = 100 + 50 = 150 (расчёт), разница = 1.""" + lines = [ + line("1150", "100"), # компонента 1100 + line("1100", "100"), # 1100 = 1105 + … + 1150 + … = 100 + line("1210", "50"), # компонента 1200 + line("1200", "50"), # 1200 = 1210 + … = 50 + line("1600", "151"), # 1600 = 1100 + 1200 = 150 (разница 1 в допуске) + line("1310", "151"), # компонента 1300 + line("1300", "151"), # 1300 = 1310 + … = 151 + line("1700", "151"), # 1700 = 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н — в скобках; оба варианта сходятся. + + Цепочка: 2100 = 2110 - 2120; 2200 = 2100 - 2210 - 2220; 2300 = 2200 + … + В обоих вариантах (1С: минус; 66н: скобки) chain сходится с abs_terms. + """ + one_c = [ + line("2110", "1000"), + line("2120", "-600"), + line("2100", "400"), # 1000 - (-600) = 1600 plain | 1000 - abs(-600) = 400 (abs) ✓ + line("2210", "-50"), + line("2220", "-30"), + line("2200", "320"), # 400 - (-50) - (-30) = 480 plain | 400 - 50 - 30 = 320 (abs) ✓ + line("2330", "-20"), + line("2350", "-10"), + line("2300", "290"), # 320 - (-20) - (-10) = 350 plain | 320 - 20 - 10 = 290 (abs) ✓ + ] + brackets = [ + line("2110", "1000"), + line("2120", "600"), + line("2100", "400"), # 1000 - 600 = 400 ✓ + line("2210", "50"), + line("2220", "30"), + line("2200", "320"), # 400 - 50 - 30 = 320 ✓ + line("2330", "20"), + line("2350", "10"), + line("2300", "290"), # 320 - 20 - 10 = 290 ✓ + ] + + 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: + """Мягкие соотношения и генерируемые подстроки. + + Для INCOME: 2410 = 2411 + 2412 мягкое (strict=false) → -100 ≠ -110 (разница 10 > допуска 1) → нарушение. + Для BALANCE: две проверки подстрок: + - 1110 = 11101 + 11102 с разницей 2 (10 vs 8) → нарушение + - 1110 = 11101 + 11102 с разницей 1 (10 vs 9) → без нарушения (в допуске) + """ + # Case 1: нарушение подстроки (разница > допуска) + lines_case1 = [ + line("2400", "-100"), + line("2410", "-100"), + line("2411", "-80"), + line("2412", "-30"), # -80 + -30 = -110, но 2410 = -100, разница 10 > допуска 1 + line("1110", "10"), + line("11101", "6"), + line("11102", "2"), # 6 + 2 = 8, но 1110 = 10, разница 2 > допуска 1 + ] + + checks, warnings = run_statement_checks(lines_case1, 1, "INCOME", rules) + balance, _ = run_statement_checks(lines_case1, 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) + + # Case 2: подстрока в пределах допуска (разница = 1) + lines_case2 = [ + line("1110", "10"), + line("11101", "6"), + line("11102", "3"), # 6 + 3 = 9, но 1110 = 10, разница 1 = допуска → нет нарушения + ] + balance2, _ = run_statement_checks(lines_case2, 1, "BALANCE", rules) + assert balance2.soft_relations_failed == [] + + +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 == [] + + +def test_target_absent_no_violations(rules: Rules) -> None: + """R-6: Если цели правила нет в данных, проверка пропускается → нарушений нет.""" + # INCOME: 2100, 2110, 2120 есть; 2200 НЕТУ → правило 2200 не проверяется + lines_income = [line("2110", "1000"), line("2120", "-600"), line("2100", "400")] + checks_income, _ = run_statement_checks(lines_income, 1, "INCOME", rules) + assert checks_income.relations_failed == [] + assert checks_income.soft_relations_failed == [] + + # BALANCE: только 1150 БЕЗ 1100 → правило 1100 не проверяется (1100 это цель правила) + lines_balance = [line("1150", "100")] + checks_balance, _ = run_statement_checks(lines_balance, 1, "BALANCE", rules) + assert checks_balance.relations_failed == [] + + +def test_target_present_members_absent_as_zero(rules: Rules) -> None: + """R-6: Цель есть, члены отсутствуют (= 0) → соотношение с нарушением.""" + # BALANCE: 1100 = 100 БЕЗ подстатей → 1100 = 0 + 0 + … = 0 (не совпадает с 100) + lines = [line("1100", "100")] + + checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) + + # Правило 1100 = 1105 + … (strict: true) должно нарушиться + failed_relations = [item.relation for item in checks.relations_failed] + assert any("1100 =" in rel for rel in failed_relations) + + +def _full_balance(total: str) -> list[StatementLine]: + return [ + line("1150", "100"), + line("1100", "100"), + line("1210", "50"), + line("1200", "50"), + line("1600", total), + line("1310", "150"), + line("1300", "150"), + line("1700", "150"), + ] + + +def test_failed_and_empty_relation_lists_reach_the_wire(rules: Rules) -> None: + """Проверенный документ сериализует `relations_failed`/`soft_relations_failed` всегда: с нарушением и пустыми.""" + broken, _ = run_statement_checks(_full_balance("155"), 1, "BALANCE", rules) + clean, _ = run_statement_checks(_full_balance("150"), 1, "BALANCE", rules) + + wire = broken.to_wire() + assert [item["relation"] for item in wire["relations_failed"]] == ["1600 = 1100 + 1200", "1600 = 1700"] + assert wire["relations_failed"][0]["expected"] == "155" + assert wire["soft_relations_failed"] == [] + assert clean.to_wire()["relations_failed"] == [] + assert clean.to_wire()["soft_relations_failed"] == [] + + +def test_failed_relation_reports_the_nearest_variant_as_actual(rules: Rules) -> None: + """T14.I1: при нарушении `actual` — вариант (как записано / с abs_terms), ближайший к `expected`.""" + lines = [line("2110", "1000"), line("2120", "-600"), line("2100", "410")] # как записано 1600, с abs 400 + + checks, _ = run_statement_checks(lines, 1, "INCOME", rules) + + failed = {item.relation: item for item in checks.relations_failed} + assert failed["2100 = 2110 - 2120"].expected == Decimal("410") + assert failed["2100 = 2110 - 2120"].actual == Decimal("400") + + +def test_failed_relation_keeps_as_written_variant_on_a_tie(rules: Rules) -> None: + lines = [line("2110", "1000"), line("2120", "0"), line("2100", "900")] # оба варианта дают 1000 + + checks, _ = run_statement_checks(lines, 1, "INCOME", rules) + + assert {item.relation: item.actual for item in checks.relations_failed}["2100 = 2110 - 2120"] == Decimal("1000") diff --git a/api/tests/unit/parser/statements/test_columns.py b/api/tests/unit/parser/statements/test_columns.py new file mode 100644 index 0000000..edd0f99 --- /dev/null +++ b/api/tests/unit/parser/statements/test_columns.py @@ -0,0 +1,324 @@ +"""Заголовок таблицы формы, колонка кодов, наименований и периодов (design D-4; spec «Заголовок таблицы, колонка кодов +и колонки-периоды определяются по тексту»).""" + +import datetime +from decimal import Decimal + +import pytest +from gridbuilder import 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() + + +# Test constants +CODE_COL_ONE_C = 4 +NAME_COL_ONE_C = 2 +CODE_COL_TDSHEET = 2 +NAME_COL_TDSHEET = 1 +HEADER_ROW_ONE_C = 4 +FIRST_CODE_ROW_ONE_C = 5 +PERIOD_COL_ONE_C_1 = 5 +PERIOD_COL_ONE_C_2 = 6 +HEADER_ROW_TDSHEET = 2 +HEADER_LAST_ROW_TDSHEET = 3 +FIRST_CODE_ROW_TDSHEET = 4 +HEADER_ROW_TWO_ROW = 1 +HEADER_LAST_ROW_TWO_ROW = 2 +FIRST_CODE_ROW_TWO_ROW = 3 +HEADER_ROW_NO_CAPTION = 1 +CODE_COL_NO_CAPTION = 2 +NAME_COL_NO_CAPTION = 1 +HEADER_ROW_CONTINUATION = None +FIRST_DATA_ROW_CONTINUATION = 1 +CODE_COL_CONTINUATION = 6 +PERIOD_COL_CONT_1 = 7 +PERIOD_COL_CONT_2 = 8 +PERIOD_COL_CONT_3 = 9 +HEADER_ROW_BARE_YEAR = 2 +PERIOD_COL_BARE_YEAR_1 = 15 +PERIOD_COL_BARE_YEAR_2 = 20 + + +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 + + +# Test data: ONE_C_BALANCE sheet +_HEADER_ROW_ONE_C = [ + "Пояснения", + "Наименование показателя", + None, + "Код", + "На 31 декабря 2024 г.", + "На 31 декабря 2023 г.", + "Изменение, %", +] + +ONE_C_BALANCE = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + [], + _HEADER_ROW_ONE_C, + [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) == CODE_COL_ONE_C + + +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) == ( + HEADER_ROW_ONE_C, + HEADER_ROW_ONE_C, + FIRST_CODE_ROW_ONE_C, + ) + assert layout.code_col == CODE_COL_ONE_C and layout.name_col == NAME_COL_ONE_C + expected_periods = [ + (PERIOD_COL_ONE_C_1, "На 31 декабря 2024 г.", datetime.date(2024, 12, 31)), + (PERIOD_COL_ONE_C_2, "На 31 декабря 2023 г.", datetime.date(2023, 12, 31)), + ] + assert [(column.col, column.label, column.period_date) for column in layout.periods] == expected_periods + 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) == ( + HEADER_ROW_TDSHEET, + HEADER_LAST_ROW_TDSHEET, + FIRST_CODE_ROW_TDSHEET, + ) + assert layout.name_col == NAME_COL_TDSHEET and layout.code_col == CODE_COL_TDSHEET + expected_labels = ["За 9 месяцев 2025 года", "На 31 декабря 2024 года"] + assert [column.label for column in layout.periods] == expected_labels + 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) == ( + HEADER_ROW_TWO_ROW, + HEADER_LAST_ROW_TWO_ROW, + FIRST_CODE_ROW_TWO_ROW, + ) + expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + assert [column.period_date for column in layout.periods] == expected_dates + + +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 == HEADER_ROW_NO_CAPTION + assert layout.code_col == CODE_COL_NO_CAPTION + assert layout.name_col == NAME_COL_NO_CAPTION + expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + assert [column.period_date for column in layout.periods] == expected_dates + + +def test_layout_two_row_header_without_code_caption_keeps_upper_row(rules: Rules) -> None: + """T13.I2: без подписи «Код» верхняя строка двухстрочного заголовка не теряется (st02): «Изменение, %» пропущена.""" + sheet = sheet_from_rows( + [ + ["Показатель", None, "На 31 декабря", "На 31 декабря", "Изменение, %"], + [None, None, "2024 г.", "2023 г."], + ["Основные средства", "1150", 100, 90, 5], + ["Итого по разделу I", "1100", 100, 90, 5], + ["Запасы", "1210", 50, 40, 5], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 1 + assert layout.first_data_row == 3 # noqa: PLR2004 + expected_dates = [datetime.date(2024, 12, 31), datetime.date(2023, 12, 31)] + assert [column.period_date for column in layout.periods] == expected_dates + assert [column.label for column in layout.periods] == ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."] + assert layout.skipped_labels == ("Изменение, %",) + + +def test_layout_document_header_above_dates_is_not_upper_header_row(rules: Rules) -> None: + """Строка шапки документа над датами («Единица измерения…» правее кодов, без чисел) — не часть заголовка.""" + sheet = sheet_from_rows( + [ + ["Бухгалтерский баланс"], + [None, None, "Единица измерения: тыс. руб."], + [None, None, "2024 г.", "2023 г."], + ["Основные средства", "1150", 100, 90], + ["Итого по разделу I", "1100", 100, 90], + ["Запасы", "1210", 50, 40], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 3 # noqa: PLR2004 + assert layout.skipped_labels == () + assert len(layout.periods) == 2 # noqa: PLR2004 + + +def test_layout_upper_row_kept_when_a_foreign_label_sits_above_another_column(rules: Rules) -> None: + """T13.5: верхняя строка берётся, если хоть одна колонка в ней склеилась в период, хотя над соседней — подпись + без периода («Примечание»).""" + sheet = sheet_from_rows( + [ + ["Показатель", None, "На 31 декабря", "На 31 декабря", "Примечание"], + [None, None, "2024 г.", "2023 г.", "Изм."], + ["Основные средства", "1150", 100, 90, 5], + ["Итого по разделу I", "1100", 100, 90, 5], + ["Запасы", "1210", 50, 40, 5], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 1 + assert [column.period_date for column in layout.periods] == [ + datetime.date(2024, 12, 31), + datetime.date(2023, 12, 31), + ] + assert layout.skipped_labels == ("Примечание Изм.",) + + +def test_layout_units_label_above_a_complete_date_is_not_glued(rules: Rules) -> None: + """T13.3/T13.5 (в обратную сторону): «Единица измерения» над уже полной датой в той же колонке — не заголовок.""" + sheet = sheet_from_rows( + [ + [None, None, "Единица измерения: тыс. руб."], + [None, None, "на 31 декабря 2024 г."], + ["Основные средства", "1150", 100], + ["Итого по разделу I", "1100", 100], + ["Запасы", "1210", 50], + ] + ) + + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + assert layout.header_row == 2 # noqa: PLR2004 + assert [column.label for column in layout.periods] == ["на 31 декабря 2024 г."] + + +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 HEADER_ROW_CONTINUATION + assert layout.first_data_row == FIRST_DATA_ROW_CONTINUATION + assert layout.code_col == CODE_COL_CONTINUATION + expected_cols = [ + (PERIOD_COL_CONT_1, "", None), + (PERIOD_COL_CONT_2, "", None), + (PERIOD_COL_CONT_3, "", None), + ] + assert [(column.col, column.label, column.period_date) for column in layout.periods] == expected_cols + + +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 == NAME_COL_ONE_C + + +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) + + period_cols = [PERIOD_COL_BARE_YEAR_1, PERIOD_COL_BARE_YEAR_2] + assert layout.header_row == HEADER_ROW_BARE_YEAR + assert [column.col for column in layout.periods] == period_cols diff --git a/api/tests/unit/parser/statements/test_detect.py b/api/tests/unit/parser/statements/test_detect.py new file mode 100644 index 0000000..d9c4c4e --- /dev/null +++ b/api/tests/unit/parser/statements/test_detect.py @@ -0,0 +1,137 @@ +"""Блоки форм на листе, тип формы, лист-продолжение (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 + statement_continuation_sheet = sheet_from_rows([["Форма 0710002 с.2"], ["Код", "За 2024 г."], ["2510", 0]]) + assert classify_sheet(statement_continuation_sheet, rules) is SheetKind.STATEMENT_CONTINUATION + osv_sheet = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"], ["Бухгалтерский баланс"]]) + assert classify_sheet(osv_sheet, rules) is SheetKind.OSV diff --git a/api/tests/unit/parser/statements/test_header.py b/api/tests/unit/parser/statements/test_header.py new file mode 100644 index 0000000..32ec74a --- /dev/null +++ b/api/tests/unit/parser/statements/test_header.py @@ -0,0 +1,230 @@ +"""Шапка формы по меткам (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 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" + assert 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 # noqa: PLR2004 + 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 + assert header.okpo == "16098811" + assert header.units == "тыс. руб." + assert header.report_date == datetime.date(2025, 9, 30) + assert 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) + assert header.report_date_text is None + assert header.inn == "7016006635" + assert 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" + assert 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) + assert any("организация" in warning for warning in warnings) + + +def test_inn_from_auditor_block_only_is_none(rules: Rules) -> None: + """Guard test (a): without org INN, only auditor block ИНН — should be None.""" + sheet = sheet_from_rows( + [ + [*at({3: "Отчет о финансовых результатах"})], + [*at({3: "за январь - сентябрь 2025 г."})], + [*at({13: "Форма по ОКУД", 14: "0710002"})], + [*at({7: 'ООО "ПРОКАТНЫЙ ЗАВОД"'})], + [*at({3: "Организация:", 13: "по ОКПО", 14: "16098811"})], + [*at({3: "Единица измерения", 8: "тыс. руб.", 13: "по ОКЕИ", 14: "384"})], + [*at({2: "Наименование аудиторской организации"})], + [*at({2: 'АО "АУДИТ-КОМПАНИЯ"'})], + [ + *at( + { + 2: "Идентификационный номер налогоплательщика аудиторской организации", + 33: "ИНН", + 34: "9999999999", + } + ) + ], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 9, _block(sheet, rules)) + + assert header.inn is None, f"Should not pick up auditor INN 9999999999, got {header.inn}" + + +def test_inn_not_taken_from_auditor_block_above_organization_inn(rules: Rules) -> None: + """Guard test (b): auditor block ABOVE org INN; guard excludes auditor INN. + + Layout (intentionally reversed from real files): + - Auditor block starts at row 3 (auditor_from=3) + - Auditor INN at row 4: value 7716021332 + - Organization INN at row 8: value 3249004697 + + With guard (search 1..auditor_from-1=1..2): Neither INN found → header.inn is None ✓ + Without guard (search 1..last_row=1..9): Auditor INN at row 4 found first → **FAIL** ✗ + + This test verifies auditor INN is never taken, even when it appears before org INN. + """ + sheet = sheet_from_rows( + [ + [*at({3: "Бухгалтерский баланс"})], + [*at({3: "на 31 декабря 2024 г."})], + [*at({2: "Наименование аудиторской организации"})], + [*at({2: 'АО "ЮНИКОН"'})], + [ + *at( + { + 2: "Идентификационный номер налогоплательщика аудиторской организации", + 33: "ИНН", + 34: "7716021332", + } + ) + ], + [*at({7: 'ООО "ЗАВОД"'})], + [*at({3: "Организация", 7: 'ООО "ЗАВОД"', 13: "по ОКПО", 14: "16098811"})], + [*at({2: "Идентификационный номер налогоплательщика", 33: "ИНН", 34: "3249004697"})], + [*at({2: "Единица измерения", 7: "в тыс. рублей", 33: "по ОКЕИ", 34: "384"})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 9, _block(sheet, rules)) + + assert header.inn is None, ( + f"Auditor INN 7716021332 at row 4 is above auditor_from boundary; " + f"org INN 3249004697 at row 8 is below. Guard excludes both. " + f"Got {header.inn}" + ) diff --git a/api/tests/unit/parser/statements/test_lines.py b/api/tests/unit/parser/statements/test_lines.py new file mode 100644 index 0000000..0a9818e --- /dev/null +++ b/api/tests/unit/parser/statements/test_lines.py @@ -0,0 +1,183 @@ +"""Строки с кодом и значения (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 + +_FULL_CONFIDENCE = 100 +_FRACTIONAL_VALUES_COUNT = 4 +_NUMERIC_VALUES_COUNT = 9 +_SKIPPED_WITH_SUMS_COUNT = 0 +_BOUNDARY_NUMERIC_VALUES = 20 +_BOUNDARY_FRACTIONAL_VALUES = 2 +_BELOW_THRESHOLD_NUMERIC_VALUES = 12 +_BELOW_THRESHOLD_FRACTIONAL_VALUES = 1 + + +@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 == _FULL_CONFIDENCE for line in result.lines for value in line.values) + assert all( + line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE + 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 == _SKIPPED_WITH_SUMS_COUNT # строка нулей без кода суммой не считается (M-8) + assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT + + +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] == ["", "", ""] + + +def test_fractional_warning_at_boundary_10_percent(rules: Rules) -> None: + """Ровно 10% (2 из 20) → должно быть предупреждение.""" + # With 2 columns: 10 data rows = 20 numeric values + # To get exactly 10%, need 2 fractional values + rows = [ + [None, "Наименование показателя", "Код", "2024", "2023"], + [None, "Показатель 1", "1150", 100, 100], + [None, "Показатель 2", "1151", 200, 200], + [None, "Показатель 3", "1152", 300, 300], + [None, "Показатель 4", "1153", 400, 400], + [None, "Показатель 5", "1154", Decimal("500.50"), 500], # 1 fractional + [None, "Показатель 6", "1155", 600, Decimal("600.50")], # 1 fractional (2 из 20 = 10%) + [None, "Показатель 7", "1156", 700, 700], + [None, "Показатель 8", "1157", 800, 800], + [None, "Показатель 9", "1158", 900, 900], + [None, "Показатель 10", "1159", 1000, 1000], + ] + sheet = sheet_from_rows(rows) + layout = find_layout(sheet, rules, 1, len(rows)) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей") + + # Expect 20 numeric values and 2 fractional (10%) + assert result.numeric_values == _BOUNDARY_NUMERIC_VALUES and result.fractional_values == _BOUNDARY_FRACTIONAL_VALUES + assert not result.warnings # предупреждение о дробных — на уровне документа (statement.py), здесь только счётчики + + +def test_no_fractional_warning_below_10_percent(rules: Rules) -> None: + """1 из 12 (8.33%) → предупреждения нет.""" + # With 2 columns: 6 data rows = 12 numeric values + # 1 fractional = 8.33% < 10% + rows = [ + [None, "Наименование показателя", "Код", "2024", "2023"], + [None, "Показатель 1", "1150", 100, 100], + [None, "Показатель 2", "1151", 200, 200], + [None, "Показатель 3", "1152", 300, 300], + [None, "Показатель 4", "1153", 400, 400], + [None, "Показатель 5", "1154", 500, 500], + [None, "Показатель 6", "1155", 600, Decimal("600.50")], # 1 из 12 = 8.33% + ] + sheet = sheet_from_rows(rows) + layout = find_layout(sheet, rules, 1, len(rows)) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей") + + # Expect 12 numeric values and 1 fractional (8.33% < 10%) + assert ( + result.numeric_values == _BELOW_THRESHOLD_NUMERIC_VALUES + and result.fractional_values == _BELOW_THRESHOLD_FRACTIONAL_VALUES + ) + + +def test_no_crash_with_zero_numeric_values(rules: Rules) -> None: + """Ноль числовых значений — без падения.""" + # No numeric values (only recognized None and unrecognized text) + rows = [ + [None, "Наименование показателя", "Код", "2024", "2023"], + [None, "Показатель 1", "1150", None, None], # recognized None + [None, "Показатель 2", "1151", "–", "–"], # recognized empty (dash) + [None, "Показатель 3", "1152", "н/д", "н/д"], # unrecognized + ] + sheet = sheet_from_rows(rows) + layout = find_layout(sheet, rules, 1, len(rows)) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей") + + assert result.numeric_values == 0 + assert result.fractional_values == 0 + + +def test_numbering_row_and_zero_row_are_not_skipped_sums(rules: Rules) -> None: + """M-8: «1 2 3 4 5» на разрыве страницы и строка нулей без кода не считаются «строкой без кода с суммами».""" + rows = [ + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Основные средства", "1150", 100, 90], + ["1", "2", "3", "4"], + ["Раздел", None, 0, 0], + ["Запасы", "1210", 50, 40], + ["Итого", "1200", 50, 40], + ["Без кода", None, 7, 8], + ] + sheet = sheet_from_rows(rows) + layout = find_layout(sheet, rules, 1, len(rows)) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows)) + + assert result.skipped_with_sums == 1 # только «Без кода» + assert result.warnings == ["строк без кода с суммами пропущено: 1"] + + +def test_empty_name_cell_takes_the_nearest_text_to_the_left_of_codes(rules: Rules) -> None: + """I-3: название итога стоит в соседней колонке с отступом; пустое имя — не с уверенностью 100.""" + rows = [ + ["Пояснения", "Наименование показателя", None, "Код", "На 31 декабря 2024 г."], + ["1", "Основные средства", None, "1150", 100], + [None, None, "Итого по разделу I", "1100", 100], + [None, None, None, "1210", 5], + ] + sheet = sheet_from_rows(rows) + layout = find_layout(sheet, rules, 1, len(rows)) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows)) + + by_code = {line.line_code: line for line in result.lines} + assert by_code["1100"].line_name == "Итого по разделу I" + assert by_code["1100"].line_name_confidence == _FULL_CONFIDENCE + assert by_code["1210"].line_name == "" and by_code["1210"].line_name_confidence is None diff --git a/api/tests/unit/parser/statements/test_statement.py b/api/tests/unit/parser/statements/test_statement.py new file mode 100644 index 0000000..7f08660 --- /dev/null +++ b/api/tests/unit/parser/statements/test_statement.py @@ -0,0 +1,391 @@ +# 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 load_rules +from src.parser.statements.statement import CONTINUATION_WARNING, StatementParser + +TWO_COLUMNS = TWO_DOCUMENTS = 2 + + +@pytest.fixture(scope="module") +def parser() -> StatementParser: + return StatementParser(load_rules()) + + +def deadline() -> Deadline: + return Deadline(120) + + +BALANCE_PAGE_1 = [ + ["Бухгалтерский баланс"], + ["на 31 декабря 2024 г."], + ["Форма по ОКУД", None, "0710001"], + ["Организация", None, 'ООО "Пример"', "ИНН", "7700000000"], + ["Единица измерения:", None, "тыс. руб."], + ["Наименование показателя", "Код", "На 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 г."], + ["Организация", None, 'ООО "Пример"'], + ["Единица измерения:", None, "тыс. руб."], + ["Наименование показателя", "Код", "За 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) == TWO_COLUMNS 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 = [ + ["Налог", "2450", 0], + ["Прочее", "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], + ["Добавочный капитал", "1350", 1, 1, 1], + ["Итого по разделу III", "1300", 2, 2, 2], + ] + 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) == TWO_DOCUMENTS 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) + ) + + +def test_duplicate_codes_are_unique_in_order_of_first_duplicate(parser: StatementParser) -> None: + """spec: `duplicate_line_codes` — уникальные коды; повтор на каждое вхождение не плодит записей (T11-ревью).""" + rows = [*BALANCE_PAGE_1, ["Запасы", "1210", 1, 1], ["Запасы", "1210", 2, 2], ["Основные средства", "1150", 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", "1150"] + + +def test_code_repeated_on_continuation_is_duplicate_and_first_value_kept(parser: StatementParser) -> None: + first = (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT) + again = [ + ["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + ["Запасы", "1210", 7, 7], + ["Итого", "1200", 50, 40], + ["Прочее", "1230", 0, 0], + ] + + documents, _, ok = parser.parse_sheets( + [first, (sheet_from_rows(again, "ББ3"), SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert not ok and documents[0].checks.duplicate_line_codes == ["1210", "1200"] + assert [line.values[0].amount for line in documents[0].lines if line.line_code == "1210"] == [Decimal(50)] + + +def test_headerless_lonely_continuation_has_empty_header(parser: StatementParser) -> None: + documents, _, ok = parser.parse_sheets( + [(sheet_from_rows(NPZ_CONTINUATION, "хвост"), SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert not ok and documents[0].document_type == "OTHER" and documents[0].header.form_name is None + + +def test_continuation_without_code_column_is_dropped_and_not_ok(parser: StatementParser) -> None: + """Потерянный лист нельзя оставлять статусом `done`.""" + lost = [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]] + sheets = [ + (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), + (sheet_from_rows(BALANCE_PAGE_2, "ББ2"), SheetKind.STATEMENT_CONTINUATION), + (sheet_from_rows(lost, "с.3"), SheetKind.STATEMENT_CONTINUATION), + ] + + documents, warnings, ok = parser.parse_sheets(sheets, deadline()) + + assert ( + not ok + and len(documents) == 1 + and documents[0].checks.relations_failed == [] + and "sheet=с.3 колонка кодов не найдена" in warnings + ) + + +def test_block_with_okud_but_without_title_has_no_form_name(parser: StatementParser) -> None: + """Блок, открытый только кодом ОКУД: `form_name` не передаётся (в контракте нет null).""" + rows = [["Форма по ОКУД", None, "0710001"], *BALANCE_PAGE_1[3:]] + + documents, _, _ = parser.parse_sheets([(sheet_from_rows(rows), SheetKind.STATEMENT)], deadline()) + + assert documents[0].header.form_name is None and documents[0].header.okud == "0710001" + + +def _fractional_sheet(name: str, units: bool, rows: list[list[object]]) -> tuple: + head = [["Бухгалтерский баланс"], ["Организация", None, 'ООО "Пример"']] + if units: + head.append(["Единица измерения:", None, "тыс. руб."]) + return sheet_from_rows([*head, ["Наименование показателя", "Код", "На 31 декабря 2024 г."], *rows], name) + + +def test_fractional_warning_is_one_per_document_over_all_sheets(parser: StatementParser) -> None: + """M-11: две страницы документа — одно предупреждение с суммарными счётчиками.""" + first = _fractional_sheet( + "ББ1", True, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100], ["Запасы", "1210", 7]] + ) + second = sheet_from_rows( + [ + ["Форма 0710001 с.2"], + ["Наименование показателя", "Код", "На 31 декабря 2024 г."], + ["Уставный капитал", "1310", Decimal("50.25")], + ["Итого по разделу III", "1300", 50], + ["БАЛАНС", "1700", 50], + ], + "ББ2", + ) + + _, warnings, _ = parser.parse_sheets( + [(first, SheetKind.STATEMENT), (second, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + fractional = [text for text in warnings if "дробной частью" in text] + assert fractional == ["sheet=ББ1 значений с дробной частью при единице тыс. руб.: 2 из 6"] + + +def test_no_fractional_warning_without_thousands_units(parser: StatementParser) -> None: + sheet = _fractional_sheet("ББ", False, [["Основные средства", "1150", Decimal("100.5")], ["Итого", "1100", 100]]) + + _, warnings, _ = parser.parse_sheets([(sheet, SheetKind.STATEMENT)], deadline()) + + assert not any("дробной частью" in text for text in warnings) + + +def test_single_code_sheet_after_a_form_joins_as_continuation(parser: StatementParser) -> None: + """O-9 (I-1): лист с одной кодовой строкой (меньше min_codes), идущий после формы, присоединяется.""" + single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2") + documents, warnings, _ = parser.parse_sheets( + [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert len(documents) == 1 + assert documents[0].locator == "sheet:ОФР,ОФР_2" + assert [line.line_code for line in documents[0].lines][-1] == "2520" + + +def test_single_code_sheet_that_cannot_join_is_dropped_with_exact_warning(parser: StatementParser) -> None: + """O-9: число колонок сумм не совпало — лист не присоединён, книга needs_check, предупреждение точное.""" + single = sheet_from_rows([["Чистая прибыль (убыток)", "2400", 160, 150]], "ОФР_2") + + documents, warnings, ok = parser.parse_sheets( + [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert not ok and len(documents) == 1 + assert ( + "sheet=ОФР_2 лист с кодами форм (1 < 3) не присоединён: " + "число колонок сумм 2 не совпадает с открытым документом (1)" in warnings + ) + + +def test_continuation_of_another_form_type_does_not_join(parser: StatementParser) -> None: + """M-10: маркер «Форма 0710001 с.2» (ББ) не присоединяет лист к открытому ОФР.""" + income = sheet_from_rows(INCOME, "ОФР") + balance_page_2 = sheet_from_rows( + [ + ["Форма 0710001 с.2"], + ["Наименование показателя", "Код", "За 2024 г."], + ["Уставный капитал", "1310", 150], + ["Итого по разделу III", "1300", 150], + ["БАЛАНС", "1700", 150], + ], + "ББ2", + ) + + documents, warnings, ok = parser.parse_sheets( + [(income, SheetKind.STATEMENT), (balance_page_2, SheetKind.STATEMENT_CONTINUATION)], deadline() + ) + + assert [document.document_type for document in documents] == ["INCOME", "BALANCE"] + assert not ok and CONTINUATION_WARNING in " ".join(warnings) diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_book_parser.py similarity index 73% rename from api/tests/unit/parser/test_osv_parser.py rename to api/tests/unit/parser/test_book_parser.py index a69d163..c15ccce 100644 --- a/api/tests/unit/parser/test_osv_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -1,10 +1,11 @@ -"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют +"""Оркестратор `BookParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время», «Словари — данные, не код»). Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту. """ +import datetime import io import itertools import shutil @@ -21,14 +22,19 @@ from gridbuilder import L, M, sheet_from_rows from loguru import logger from result_contract import assert_valid_result -from src.contract import ErrorCode, OsvDocument, ResultStatus +from src.contract import ErrorCode, OsvDocument, ResultStatus, StatementDocument +from src.contract.result import StatementHeader from src.parser import ParseOutcome, hierarchy +from src.parser.book import BookParser, StatementSheetParser from src.parser.classify import SUM_FIELDS from src.parser.deadline import Deadline +from src.parser.detect import SheetKind from src.parser.errors import ParseTimeoutError +from src.parser.grid import Sheet from src.parser.hierarchy import BodyRow from src.parser.osv import OsvParser from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules +from src.parser.statements.statement import StatementParser OPENING = "Сальдо на начало периода" TURNOVER = "Обороты за период" @@ -53,12 +59,12 @@ def rules() -> Rules: def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes: """Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом).""" - book = openpyxl.Workbook() - default = book.active + workbook = openpyxl.Workbook() + default = workbook.active assert default is not None - book.remove(default) + workbook.remove(default) for name, rows in sheets.items(): - sheet = book.create_sheet(name) + sheet = workbook.create_sheet(name) for row_index, cells in enumerate(rows, start=1): col = 1 for value in cells: @@ -79,7 +85,7 @@ def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes: sheet.cell(row_index, col, value) col += 1 buffer = io.BytesIO() - book.save(buffer) + workbook.save(buffer) return buffer.getvalue() @@ -122,14 +128,22 @@ def statement() -> list[Cells]: return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]] -def parser( +def book( rules: Rules, *, + statements: StatementSheetParser | None = None, max_bytes: int = MAX_BYTES, timeout_seconds: float = TIMEOUT_SECONDS, clock: Callable[[], float] = time.monotonic, -) -> OsvParser: - return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock) +) -> BookParser: + return BookParser( + rules, + osv=OsvParser(rules), + statements=statements or StatementParser(rules), + max_bytes=max_bytes, + timeout_seconds=timeout_seconds, + clock=clock, + ) def only_document(outcome: ParseOutcome) -> OsvDocument: @@ -148,7 +162,7 @@ def warnings_of(outcome: ParseOutcome) -> list[str]: def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None: - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") assert outcome.status is ResultStatus.DONE assert outcome.error is None @@ -202,14 +216,14 @@ def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None: assert outcome.metrics.duration_ms >= 0 assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline"] payload = assert_valid_result(outcome) - assert payload["schema_version"] == "1.1" + assert payload["schema_version"] == "1.2" def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None: """Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя.""" spoiled = (60, None, 20, 10, 71, None) - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX") assert outcome.status is ResultStatus.NEEDS_CHECK checks = only_document(outcome).checks @@ -224,9 +238,33 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None: assert_valid_result(outcome) +def test_period_bounds_reach_osv_header(rules: Rules) -> None: + """Контракт v1.2: границы периода и признак «с начала года» заполнены парсером ОСВ.""" + header = only_document(book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")).header + + assert (header.period_start, header.period_end, header.is_from_year_start) == ( + datetime.date(2025, 1, 1), + datetime.date(2025, 3, 31), + True, + ) + + +def test_period_bounds_reach_osv_header_not_from_year_start(rules: Rules) -> None: + title = TITLE.replace("1 квартал", "3 квартал") + rows = [*head(title=title), *mini_osv()[len(head()) :]] + + header = only_document(book(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")).header + + assert (header.period_start, header.period_end, header.is_from_year_start) == ( + datetime.date(2025, 7, 1), + datetime.date(2025, 9, 30), + False, + ) + + def test_missing_total_needs_check(rules: Rules) -> None: """R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`.""" - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX") assert outcome.status is ResultStatus.NEEDS_CHECK checks = only_document(outcome).checks @@ -237,7 +275,7 @@ def test_missing_total_needs_check(rules: Rules) -> None: def test_empty_osv_is_done_with_total_only(rules: Rules) -> None: """Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм.""" - outcome = parser(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX") assert outcome.status is ResultStatus.DONE document = only_document(outcome) @@ -254,7 +292,7 @@ def test_number_warnings_carry_row_numbers(rules: Rules) -> None: rows = mini_osv() rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321") - outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX") assert outcome.status is ResultStatus.DONE assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome)) @@ -291,7 +329,7 @@ def code_name_osv(*, total_label: str | None = None) -> list[Cells]: def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> None: - outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") document = only_document(outcome) header = document.header @@ -310,7 +348,7 @@ def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: Rules) -> None: - outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX") + outcome = book(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX") document = only_document(outcome) assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [ @@ -324,7 +362,7 @@ def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None: - outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") document = only_document(outcome) assert document.rows[-1].group_type == "TOTAL" @@ -337,21 +375,21 @@ def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None # --- Несколько листов --- -def test_statement_sheet_is_skipped_and_osv_parsed(rules: Rules) -> None: - outcome = parser(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX") +def test_statement_sheet_goes_to_real_pipeline_next_to_osv(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX") - assert outcome.status is ResultStatus.DONE - assert only_document(outcome).locator == "sheet:ОСВ 60" - assert warnings_of(outcome) == ["sheet=Баланс skipped: statement (slice 2b)", "sheet=ОСВ 60 hierarchy=outline"] + assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"] + assert outcome.documents[0].locator == "sheet:ОСВ 60" and outcome.documents[1].locator == "sheet:Баланс" + assert "sheet=ОСВ 60 hierarchy=outline" in warnings_of(outcome) assert_valid_result(outcome) def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None: """Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings.""" other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]] - book = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()}) + content = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()}) - outcome = parser(rules).parse(book, "XLSX") + outcome = book(rules).parse(content, "XLSX") assert outcome.status is ResultStatus.DONE assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"] @@ -366,9 +404,9 @@ def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None: def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None: spoiled = (60, None, 20, 10, 71, None) - book = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)}) + content = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)}) - outcome = parser(rules).parse(book, "XLSX") + outcome = book(rules).parse(content, "XLSX") assert outcome.status is ResultStatus.NEEDS_CHECK assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ @@ -379,7 +417,9 @@ def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None: def test_book_without_osv_is_invalid_document(rules: Rules) -> None: """Spec «Нет ОСВ»: сообщение называет число просмотренных листов.""" - outcome = parser(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX") + outcome = book(rules).parse( + xlsx({"Справка": [["Наименование", "Сумма"], ["Аренда", 100]], "Данные": [["a", "b"], [1, 2]]}), "XLSX" + ) assert outcome.status is ResultStatus.ERROR assert outcome.error is not None @@ -391,7 +431,7 @@ def test_book_without_osv_is_invalid_document(rules: Rules) -> None: def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None: rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]] - outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX") assert outcome.status is ResultStatus.ERROR assert outcome.error is not None @@ -409,7 +449,7 @@ def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step( monkeypatch.setattr(hierarchy, "STRATEGIES", (("nothing", nothing),)) - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.INVALID_FORMAT @@ -419,10 +459,10 @@ def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step( def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None: """Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор.""" - book = xlsx({"ОСВ 60": mini_osv()}) + content = xlsx({"ОСВ 60": mini_osv()}) - over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX") - exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX") + over = book(rules, max_bytes=len(content) - 1).parse(content, "XLSX") + exact = book(rules, max_bytes=len(content)).parse(content, "XLSX") assert over.status is ResultStatus.ERROR assert over.error is not None @@ -432,21 +472,21 @@ def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> N def test_tiny_limit_rejects_any_file(rules: Rules) -> None: - outcome = parser(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX") + outcome = book(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT def test_unknown_signature_is_unsupported_format(rules: Rules) -> None: - outcome = parser(rules).parse(b"%PDF-1.7 not a workbook", "XLSX") + outcome = book(rules).parse(b"%PDF-1.7 not a workbook", "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT def test_broken_zip_is_unreadable(rules: Rules) -> None: - outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX") + outcome = book(rules).parse(b"PK\x03\x04 broken archive", "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNREADABLE @@ -455,7 +495,7 @@ def test_broken_zip_is_unreadable(rules: Rules) -> None: def test_deadline_exceeded_is_timeout(rules: Rules) -> None: clock = itertools.count(step=CLOCK_STEP) - outcome = parser(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse( + outcome = book(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse( xlsx({"ОСВ 60": mini_osv()}), "XLSX" ) @@ -471,7 +511,7 @@ def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, mo monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),)) - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") assert outcome.status is ResultStatus.ERROR assert outcome.error is not None @@ -498,7 +538,7 @@ def test_unexpected_exception_is_logged_with_traceback( monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),)) - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.RECOGNITION_FAILED @@ -514,7 +554,7 @@ def test_unexpected_exception_is_logged_with_traceback( def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, log_records: list[Any]) -> None: - outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX") + outcome = book(rules).parse(b"PK\x03\x04 broken archive", "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNREADABLE @@ -525,7 +565,7 @@ def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, lo def test_expected_errors_other_than_unreadable_are_not_logged(rules: Rules, log_records: list[Any]) -> None: - outcome = parser(rules).parse(b"%PDF-1.7", "XLSX") + outcome = book(rules).parse(b"%PDF-1.7", "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT @@ -538,7 +578,7 @@ def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None: deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__) with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"): - parser(rules).parse_sheet(sheet, deadline) + OsvParser(rules).parse_sheet(sheet, deadline) # --- Словари — данные (R-8, spec «Новый синоним без релиза кода») --- @@ -560,9 +600,9 @@ def rules_with_new_opening_synonym(tmp_path: Path) -> Path: def test_new_synonym_parses_without_code_changes(tmp_path: Path, rules: Rules) -> None: - book = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}) + content = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}) - outcome = parser(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(book, "XLSX") + outcome = book(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(content, "XLSX") assert outcome.status is ResultStatus.DONE document = only_document(outcome) @@ -577,7 +617,7 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None: необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом). """ - outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX") + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX") assert outcome.status is ResultStatus.NEEDS_CHECK document = only_document(outcome) @@ -589,3 +629,87 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None: checks = document.checks assert checks is not None assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW] + + +# --- Формы (задача 7, оркестрация книги) --- + +BALANCE = [["Бухгалтерский баланс"], ["Код", "На 31.12.2024"], ["1600", 1]] +CONTINUATION = [["Форма 0710001 с.2"], ["Код", "На 31.12.2024"], ["1700", 1]] + + +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() + content = xlsx({"ББ": BALANCE, "Лист_1": mini_osv(), "с.2": CONTINUATION}) + + outcome = book(rules, statements=fake).parse(content, "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 if outcome.metrics else []) + + +def test_balance_sheet_gives_statement_document_without_stub(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"ББ": BALANCE, "Лист_1": mini_osv()}), "XLSX") + + assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"] + assert not any("skipped: statement" in warning for warning in warnings_of(outcome)) + + +def test_statement_continuation_is_not_parsed_as_osv(rules: Rules) -> None: + """R-5: лист-продолжение формы идёт в конвейер форм (здесь заглушка), а не в разбор ОСВ.""" + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(), "с.2": CONTINUATION}), "XLSX") + + assert outcome.status is ResultStatus.NEEDS_CHECK # лист-продолжение потерян: в нём меньше трёх кодов + assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60"] + assert "sheet=с.2 колонка кодов не найдена" in warnings_of(outcome) + + +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 + assert outcome.error is not None + assert outcome.error.code is ErrorCode.INVALID_DOCUMENT + assert "нет листов ОСВ или форм" in outcome.error.message + + +def test_statement_not_ok_makes_book_needs_check(rules: Rules) -> None: + class _NotOk(_FakeStatements): + def parse_sheets( + self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline + ) -> tuple[list[StatementDocument], list[str], bool]: + documents, warnings, _ = super().parse_sheets(sheets, deadline) + return documents, warnings, False + + outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": BALANCE}), "XLSX") + + assert outcome.status is ResultStatus.NEEDS_CHECK + assert len(outcome.documents) == 1 diff --git a/api/tests/unit/parser/test_cli.py b/api/tests/unit/parser/test_cli.py index 52d65b1..0b37b5d 100644 --- a/api/tests/unit/parser/test_cli.py +++ b/api/tests/unit/parser/test_cli.py @@ -1,7 +1,7 @@ """CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»). Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования. -Синтетические книги собираются openpyxl в памяти (`test_osv_parser.xlsx`) и пишутся в `tmp_path`; реальный файл — +Синтетические книги собираются openpyxl в памяти (`test_book_parser.xlsx`) и пишутся в `tmp_path`; реальный файл — запуск как модуля через subprocess (маркер `fixtures`). """ @@ -14,7 +14,7 @@ from typing import Any import pytest from result_contract import schema_errors -from test_osv_parser import mini_osv, xlsx +from test_book_parser import mini_osv, xlsx from src.contract import RecognitionResult from src.parser import cli @@ -45,7 +45,7 @@ def test_done_book_returns_0_and_prints_result_json(tmp_path: Path, capsys: pyte assert code == EXIT_DONE payload: dict[str, Any] = json.loads(out) assert payload["status"] == "done" - assert payload["schema_version"] == "1.1" + assert payload["schema_version"] == "1.2" assert schema_errors(payload) == [] assert RecognitionResult.model_validate(payload).to_wire() == payload assert payload["documents"][0]["header"]["account_code_raw"] == "60" @@ -58,7 +58,7 @@ def test_output_is_indented_and_keeps_model_field_order(tmp_path: Path, capsys: _code, out, _err = _run(capsys, str(path)) - assert out.startswith('{\n "schema_version": "1.1",\n') + assert out.startswith('{\n "schema_version": "1.2",\n') keys = list(json.loads(out)) assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 9993cfb..dfe2820 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -78,6 +78,8 @@ def test_variant1_header_fields(rules: Rules) -> None: period_year=2024, period_quarter="Q3", is_cumulative=False, + period_start=datetime.date(2024, 7, 1), + period_end=datetime.date(2024, 9, 30), data_type="BU", currency="RUB", header_variant=1, @@ -529,3 +531,41 @@ def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None: assert header.inn_col is None assert all(g.has_inn is None for g in groups) + + +# --- Границы периода (контракт v1.2, OQ-131) --- + + +@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), + ("Январь - Май 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 5, 31), True), + ("Январь - Февраль 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 2, 29), True), + ("Январь - Октябрь 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 10, 31), True), + ("Февраль 2024 г.", datetime.date(2024, 2, 1), datetime.date(2024, 2, 29), False), + # I-2: дата на число и квартал с датой в скобках — не «одиночный месяц» + ("на 31 декабря 2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True), + ("1 квартал 2025 г. (по состоянию на 31 марта)", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True), + ("за 3 квартал 2025 г. на 30 сентября", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False), + ], +) +def test_period_bounds( + rules: Rules, period_text: str, start: datetime.date, end: datetime.date, from_year_start: bool +) -> None: + doc = _variant1(rules, title=_title(period_text)) + + assert doc.period_start is not None + 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 = _variant1(rules, title=_title("за неизвестно что")) + + assert doc.period_start is None and doc.period_end is None diff --git a/api/tests/unit/parser/test_no_coordinates.py b/api/tests/unit/parser/test_no_coordinates.py index bb0d5d6..d4b1c25 100644 --- a/api/tests/unit/parser/test_no_coordinates.py +++ b/api/tests/unit/parser/test_no_coordinates.py @@ -201,6 +201,8 @@ def test_linter_scans_parser_modules_and_skips_grid_and_readers() -> None: assert "rules.py" in scanned assert "errors.py" in scanned + assert "book.py" in scanned + assert "statements/columns.py" in scanned assert "grid.py" not in scanned assert not any(name.startswith("readers/") for name in scanned) diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py index 412b783..06e8019 100644 --- a/api/tests/unit/parser/test_read_limits.py +++ b/api/tests/unit/parser/test_read_limits.py @@ -14,6 +14,7 @@ import openpyxl import pytest from src.contract import ErrorCode, ResultStatus +from src.parser.book import BookParser from src.parser.errors import UnreadableError, UnsupportedFormatError from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet from src.parser.osv import OsvParser @@ -27,6 +28,7 @@ from src.parser.readers._limits import ( merged_area_of_xml, ) from src.parser.rules import Rules, load_rules +from src.parser.statements.statement import StatementParser FAST_SECONDS = 1.0 SHEET_PART = "xl/worksheets/sheet1.xml" @@ -122,6 +124,12 @@ def test_huge_merged_range_is_rejected_before_openpyxl() -> None: assert str(MERGED_AREA_LIMIT) in caught.value.message +def _book(rules: Rules, max_bytes: int) -> BookParser: + return BookParser( + rules, osv=OsvParser(rules), statements=StatementParser(rules), max_bytes=max_bytes, timeout_seconds=120 + ) + + @pytest.mark.usefixtures("openpyxl_forbidden") def test_many_small_merged_ranges_are_summed() -> None: per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик @@ -134,9 +142,7 @@ def test_many_small_merged_ranges_are_summed() -> None: @pytest.mark.usefixtures("openpyxl_forbidden") def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None: - outcome = OsvParser(rules, max_bytes=50 * 1024 * 1024, timeout_seconds=120).parse( - _xlsx_with_merges("A1:Z200000"), "XLSX" - ) + outcome = _book(rules, 50 * 1024 * 1024).parse(_xlsx_with_merges("A1:Z200000"), "XLSX") assert outcome.status is ResultStatus.ERROR assert outcome.error is not None @@ -534,9 +540,7 @@ def test_zip_expanding_beyond_limit_is_rejected() -> None: @pytest.mark.usefixtures("openpyxl_forbidden") def test_parser_passes_ten_times_max_bytes_as_zip_limit(rules: Rules) -> None: - outcome = OsvParser(rules, max_bytes=SMALL_MAX_BYTES, timeout_seconds=120).parse( - _xlsx_with_zeros(ZEROS_BYTES), "XLSX" - ) + outcome = _book(rules, SMALL_MAX_BYTES).parse(_xlsx_with_zeros(ZEROS_BYTES), "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 0b16162..458903f 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -346,12 +346,6 @@ def test_credit_synonyms(rules: Rules, text: str) -> None: assert not any(p.search(text) for p in rules.headers.debit) -@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"]) -def test_statement_markers(rules: Rules, text: str) -> None: - assert any(p.search(text) for p in rules.headers.statement_markers) - assert not any(p.search(text) for p in rules.headers.sheet_markers) - - @pytest.mark.parametrize( ("kind", "text"), [ diff --git a/api/tests/unit/parser/test_statement_rules.py b/api/tests/unit/parser/test_statement_rules.py new file mode 100644 index 0000000..3bef6b5 --- /dev/null +++ b/api/tests/unit/parser/test_statement_rules.py @@ -0,0 +1,178 @@ +"""Словари форм (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 + +_CODE_MIN_SHARE = 0.6 +_MIN_CODES = 3 +_FRACTIONAL_SHARE_PERCENT = 10 + + +@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_okud_type_boundaries(rules: Rules) -> None: + """Граничные случаи для кодов OKUD: диапазон 0710003-0710099 для DISCLOSURE.""" + headers = rules.statement_headers + # Нижняя граница диапазона DISCLOSURE + assert headers.okud_type("0710003") == "DISCLOSURE" + # Верхняя граница диапазона DISCLOSURE + assert headers.okud_type("0710099") == "DISCLOSURE" + # Середина диапазона + assert headers.okud_type("0710010") == "DISCLOSURE" + assert headers.okud_type("0710050") == "DISCLOSURE" + # За границами диапазона + assert headers.okud_type("0710000") is None # до диапазона + assert headers.okud_type("07101") is None # неполный код + assert headers.okud_type("07100030") is None # слишком длинный + assert headers.okud_type("0710100") is None # за диапазоном + + +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_explanations_caption("Пояснения") + assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)} + assert headers.code_min_share == _CODE_MIN_SHARE and headers.min_codes == _MIN_CODES + + +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) + + +def test_explicit_rules_reject_five_digit_codes(tmp_path: Path) -> None: + """R-9: явные правила должны содержать только четырёхзначные коды; пятизначные только в подстроках.""" + 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": "1100 = 11101 + 1105", "strict": True}) + (tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8") + with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"): + load_rules(tmp_path) + + +def _broken_headers(tmp_path: Path, **changes: object) -> None: + for name in DEFAULT_RULES_DIR.glob("*.yaml"): + (tmp_path / name.name).write_bytes(name.read_bytes()) + path = tmp_path / "statement_headers.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + for key, value in changes.items(): + if key == "min_codes_continuation": + data["line_codes"][key] = value + else: + data[key] = value + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + +def test_fractional_share_percent_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None: + """M-7: порог предупреждения о дробных значениях — из словаря, диапазон 1..100.""" + assert rules.statement_headers.fractional_share_percent == _FRACTIONAL_SHARE_PERCENT + for bad in (0, 101): + _broken_headers(tmp_path, fractional_share_percent=bad) + with pytest.raises(RuntimeError, match="fractional_share_percent"): + load_rules(tmp_path) + + +def test_min_codes_continuation_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None: + """I-1: ослабленный порог листа-продолжения — из словаря, 1 ≤ значение ≤ min_codes.""" + assert rules.statement_headers.min_codes_continuation == 1 + for bad in (0, _MIN_CODES + 1): + _broken_headers(tmp_path, min_codes_continuation=bad) + with pytest.raises(RuntimeError, match="min_codes_continuation"): + load_rules(tmp_path) + + +def test_units_thousands_and_okud_code_from_dictionary(rules: Rules) -> None: + """M-5: слово «тыс» и код ОКУД берутся из словаря, а не из литералов кода.""" + headers = rules.statement_headers + assert headers.is_thousands("в тыс. рублей") and not headers.is_thousands("руб.") + found = headers.okud_code.search("Форма по ОКУД 0710002") + assert found is not None and found.group(0) == "0710002" diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index bce77dc..485ff26 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -58,8 +58,9 @@ EXAMPLE_KIND = { "result-osv-minimal.json": "result", "result-osv-needs-check.json": "result", "result-statement-minimal.json": "result", + "result-statement-excel.json": "result", } -EXAMPLES_COUNT = 9 +EXAMPLES_COUNT = 10 def _load(path: Path) -> Any: @@ -604,7 +605,7 @@ def test_message_status_enum_values() -> None: def test_contract_version_constant() -> None: - assert CONTRACT_VERSION == "1.1" + assert CONTRACT_VERSION == "1.2" def test_python_float_amount_is_rejected_with_clear_error() -> None: diff --git a/api/tests/unit/test_contract_v12.py b/api/tests/unit/test_contract_v12.py new file mode 100644 index 0000000..2480420 --- /dev/null +++ b/api/tests/unit/test_contract_v12.py @@ -0,0 +1,84 @@ +"""Контракт 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) diff --git a/api/tests/unit/test_golden_helpers.py b/api/tests/unit/test_golden_helpers.py index 9e50e57..ec5c0ea 100644 --- a/api/tests/unit/test_golden_helpers.py +++ b/api/tests/unit/test_golden_helpers.py @@ -3,12 +3,19 @@ import json from pathlib import Path -from golden_helpers import SNAPSHOT_DIR, golden_path, normalize_result, snapshot_text, unified_diff +from golden_helpers import ( + SNAPSHOT_DIR, + STATEMENT_SNAPSHOT_DIR, + golden_path, + normalize_result, + snapshot_text, + unified_diff, +) def _result() -> dict: return { - "schema_version": "1.1", + "schema_version": "1.2", "job_id": "00000000-0000-0000-0000-000000000000", "file_id": "00000000-0000-0000-0000-000000000000", "service": {"name": "tabular-parser", "version": "0.1.0"}, @@ -87,3 +94,12 @@ def test_dkb_snapshot_path_mirrors_osv_dkb_under_its_own_subdirectory(tmp_path: golden_path(tmp_path, tmp_path / "osv" / "rb" / "a.xlsx") == tmp_path / "golden" / SNAPSHOT_DIR / "rb" / "a.json" ) + + +def test_statement_snapshot_path_mirrors_statements_tree_under_its_own_directory(tmp_path: Path) -> None: + file = tmp_path / "statements-xlsx" / "npz" / "2025-Q3" / "Отчётность (НПЗ).xlsx" + synthetic = tmp_path / "statements-xlsx" / "synthetic" / "st01.xlsx" + + expected = tmp_path / "golden" / STATEMENT_SNAPSHOT_DIR / "npz" / "2025-Q3" / "Отчётность (НПЗ).json" + assert golden_path(tmp_path, file) == expected + assert golden_path(tmp_path, synthetic) == tmp_path / "golden" / STATEMENT_SNAPSHOT_DIR / "synthetic" / "st01.json" diff --git a/api/tests/unit/test_golden_sync.py b/api/tests/unit/test_golden_sync.py index 2f8595f..0533290 100644 --- a/api/tests/unit/test_golden_sync.py +++ b/api/tests/unit/test_golden_sync.py @@ -132,6 +132,31 @@ def test_orphan_is_removed_only_within_a_present_source_directory(root: Path) -> assert real_snapshot.is_file() +def test_statement_snapshots_are_kept_without_statement_sources_and_removed_with_them(root: Path) -> None: + """Снимки форм (`expected_statement_parse`) лишние только там, где есть исходные файлы форм своей части.""" + parser = get_parser() + kept = root / "golden" / "expected_statement_parse" / "npz" / "2025-Q3" / "книга.json" + kept.parent.mkdir(parents=True) + kept.write_text("{}\n", encoding="utf-8") + + report = sync_snapshots(root, files_of(root), parser, write=True) + + assert report.orphans == [] + assert report.protected == [kept.relative_to(root)] + assert kept.is_file() + + form = root / "statements-xlsx" / "templates" / "ББ.xlsx" + form.parent.mkdir(parents=True) + form.write_bytes(files_of(root)[0].read_bytes()) + report = sync_snapshots(root, [*files_of(root), form], parser, write=False) + + assert report.orphans == [kept.relative_to(root)] + assert report.protected == [] + assert [path for path in report.created if "expected_statement_parse" in path.parts] == [ + golden_path(root, form).relative_to(root) + ] + + def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None: """Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update».""" file = files_of(root)[0] diff --git a/api/tests/unit/test_parser_factory.py b/api/tests/unit/test_parser_factory.py index 93c4d77..421b722 100644 --- a/api/tests/unit/test_parser_factory.py +++ b/api/tests/unit/test_parser_factory.py @@ -1,6 +1,6 @@ """Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»). -`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь — +`get_parser()` по умолчанию отдаёт `BookParser` со словарями `api/rules/` и лимитами из настроек; битый словарь — падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера: `NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются. """ @@ -15,7 +15,7 @@ import pytest from src.contract import ErrorCode, FileType, MessageError, ResultStatus from src.core.config import Settings from src.parser import NullParser, ParseOutcome, Parser, get_parser -from src.parser.osv import OsvParser +from src.parser.book import BookParser from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE DEFAULT_MAX_BYTES = 50 * 1024 * 1024 @@ -46,10 +46,10 @@ def test_null_parser_returns_not_implemented_without_touching_input(file_type: F assert outcome.metrics is None -def test_get_parser_returns_osv_parser_with_default_limits() -> None: +def test_get_parser_returns_book_parser_with_default_limits() -> None: parser = get_parser() - assert isinstance(parser, OsvParser) + assert isinstance(parser, BookParser) assert isinstance(parser, Parser) assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS) @@ -59,7 +59,7 @@ def test_get_parser_takes_limits_from_settings(minimal_settings: Callable[..., S parser = get_parser(settings) - assert isinstance(parser, OsvParser) + assert isinstance(parser, BookParser) assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста diff --git a/api/tests/unit/test_rules_coverage_helpers.py b/api/tests/unit/test_rules_coverage_helpers.py index f11c1b5..0431f5a 100644 --- a/api/tests/unit/test_rules_coverage_helpers.py +++ b/api/tests/unit/test_rules_coverage_helpers.py @@ -4,7 +4,7 @@ import shutil from pathlib import Path import pytest -from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered +from rules_coverage import DICTIONARIES, PATTERNLESS, MatchCounter, iter_patterns, uncovered from src.parser.rules import DEFAULT_RULES_DIR, Pattern, load_rules @@ -14,7 +14,7 @@ def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() -> entries = list(iter_patterns(rules)) - assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) + assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) - PATTERNLESS keys = {(file_name, path) for file_name, path, _ in entries} assert ("osv_headers.yaml", "sheet_markers[0]") in keys assert ("osv_headers.yaml", "groups.opening[0]") in keys @@ -22,6 +22,8 @@ def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() -> assert ("periods.yaml", "rules[0].pattern") in keys assert ("periods.yaml", "year") in keys assert ("data_types.yaml", "currency_aliases.RUB[0]") in keys + assert ("statement_headers.yaml", "form_titles.BALANCE[0]") in keys + assert ("statement_periods.yaml", "numeric_date") in keys # R-22: перечень кодов ISO — значения, не синонимы assert not any("currency_codes" in path for _, path, _ in entries) assert all(isinstance(pattern, Pattern) for _, _, pattern in entries) diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 8d43762..7ee53f7 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -231,7 +231,7 @@ async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None: assert subjects == {RESULT_SUBJECT} ack = env.bodies()[0] assert ack == { - "schema_version": "1.1", + "schema_version": "1.2", "job_id": JOB_ID, "file_id": FILE_ID, "status": "processing", @@ -368,7 +368,7 @@ async def test_unsupported_major_version_gives_schema_mismatch_without_ack(env: result = env.bodies()[0] _assert_message_valid(result) assert result["status"] == "error" - assert result["schema_version"] == "1.1" + assert result["schema_version"] == "1.2" assert result["job_id"] == JOB_ID assert result["file_id"] == FILE_ID assert result["error"]["code"] == "SCHEMA_MISMATCH" @@ -503,7 +503,7 @@ async def test_substituted_parser_gives_done_with_result_file() -> None: _assert_message_valid(envelope) assert envelope["status"] == "done" assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY} - assert envelope["result_schema_version"] == "1.1" + assert envelope["result_schema_version"] == "1.2" assert "error" not in envelope assert envelope["metrics"]["rows"] == PARSER_ROWS assert envelope["metrics"]["duration_ms"] >= 0 @@ -847,7 +847,7 @@ async def test_worker_with_real_osv_parser_gives_done_and_valid_result_file(fixt envelope = environment.bodies()[1] _assert_message_valid(envelope) assert envelope["status"] == "done" - assert envelope["result_schema_version"] == "1.1" + assert envelope["result_schema_version"] == "1.2" assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY} stored = environment.storage.json_objects[(BUCKET, RESULT_KEY)] assert [error.message for error in VALIDATORS["result"].iter_errors(stored)] == [] diff --git a/openspec/changes/slice-2b-statement-parser/design.md b/openspec/changes/slice-2b-statement-parser/design.md new file mode 100644 index 0000000..6a6fb35 --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/design.md @@ -0,0 +1,230 @@ +# Design: Срез 2b — парсер ББ и ОФР из Excel + +## Context + +- Точка подключения прежняя: `get_parser(settings)` → `Parser.parse(data, file_type) -> ParseOutcome`; воркер + (`core/worker.py`) вызывает парсер в `asyncio.to_thread`, собирает конверт и пишет `results/{file_id}/{job_id}/result.json`. + `ParseOutcome` требует непустые `documents` при `done|needs_check`. +- Сейчас `OsvParser.parse` делает и оркестрацию книги (лимит, чтение, дедлайн, `classify_sheet`, цикл по листам), и + конвейер листа ОСВ (`parse_sheet`). Листы `SheetKind.STATEMENT` пропускаются с предупреждением. +- Контракт v1.1 уже содержит `statementDocument{document_type BALANCE|INCOME|OTHER, locator, header{form_name, + organization_name, inn, report_date_text, report_date, units, okud, okpo, okopf, okfs, address, *_confidence}, + columns[{column_index, label, period_date}], lines[{line_code, line_name, *_confidence, values[{column_index, amount, + string_value, confidence, is_recognized}]}]}` — поля «как у OCR». Табличный путь заполняет `confidence = 100`, + `is_recognized = true` (§12). +- Реальные файлы (`../fixtures/statements-xlsx/**`, 15 файлов + 4 шаблона), три раскладки: + 1. **1С-xls постранично** (Унагранде Компани, Унагранде Групп): один файл = одна страница формы; на странице 1 титульный + блок (организация, ИНН, ОКПО, ОКВЭД, ОКОПФ/ОКФС, единица «в тыс. рублей», ОКЕИ 384, адрес, аудитор с **своим** ИНН), + заголовок таблицы «Пояснения | Наименование показателя | Код | На 31 декабря 2024 г. | … 2023 | … 2022»; на странице 2 + только «Форма 0710001 с.2» и та же строка заголовка таблицы. Под строками 1110/1150/1540 — пятизначные подстроки + 11101, 11102 (1С «в том числе» без слов), есть строки без кода и без имени с нулями. Значения — рубли с копейками при + единице «тыс. руб.». + 2. **Многолистовая xlsx ФСБУ 4/2023** (НПЗ): ББ на 5 листах, ОФР на 3; на первом листе титул «Бухгалтерская отчетность + за январь - сентябрь 2025 г.» и ниже заголовок «Бухгалтерский баланс | на 30 сентября 2025 г. | 0710001»; листы 2–5 + начинаются сразу со строк («в том числе:», 11901, 1100, …) без заголовка; есть строка 1105 «Гудвил», подстроки + 12301…12314, 23201…, в ОФР строка без кода с суммами («Реализация иного имущества…»). + 3. **`TDSheet`** (Межениновская): вся форма на одном листе, заголовок «БУХГАЛТЕРСКИЙ БАЛАНС / за 9 месяцев 2025 г.», + дата тройкой «30 | 9 | 2025» (числа, не строки), колонки «Наименование показателя | Коды | За 9 месяцев\n2025 года | + На 31 декабря\n2024 года | …», под шапкой строка нумерации «1 | 2 | 3 | 4 | 5», значения — тысячи с дробной частью. + 4. **Шаблоны** 66н и ФСБУ 4/2023: заголовок таблицы разнесён на две строки («На» / «20__ г.»), коды есть, сумм нет, + лист содержит обе страницы формы с маркером «Форма 0710001 с. 2» посередине; в шаблоне ФСБУ строка «АКТИВ» и код 1110 + стоят в одной строке из-за объединений (образец формы, не данные). +- Параллельно в ветках issues #9/#10/#11 правятся модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`, + словари ОСВ, golden ОСВ). Этот change их не трогает. + +## Goals / Non-Goals + +**Goals:** все 15 реальных файлов разбираются без правил под файл, организацию, имя листа или номер строки; полные формы +(НПЗ, Межениновская) дают `done` с сошедшимися жёсткими соотношениями §7; постраничные xls дают документы с данными и +честный `needs_check` для страниц без шапки; формулы §7 и синонимы — данные; конвейер форм изолирован от ОСВ так, что +параллельные правки ОСВ не конфликтуют; контракт растёт только MINOR-ом. **Non-Goals:** см. proposal. + +## Decisions + +### D-1. `BookParser` — оркестратор книги, `OsvParser` — конвейер листа +`book.py`: `BookParser(osv: OsvParser, statements: StatementParser, rules: Rules, max_bytes, timeout_seconds, clock)`. +`parse()` повторяет нынешний `OsvParser.parse`/`_parse_book`: лимит `PARSE_MAX_BYTES` → `read_book` → `Deadline` → для +каждого листа `classify_sheet` → `OSV` в `osv.parse_sheet`, `STATEMENT`/`STATEMENT_CONTINUATION` накапливаются и +передаются `statements.parse_sheets(sheets_in_order, deadline)` **одним вызовом** (склейка продолжений требует видеть +соседние листы), `OTHER` → предупреждение. Документы книги — ОСВ в порядке листов, затем формы в порядке листов. Статус +`done`, если все документы `ok`; ни одного документа ОСВ или формы → `InvalidDocumentError` («в книге нет листов ОСВ или +форм»). `OsvParser` теряет `parse`/`_parse_book`, оставляет `parse_sheet` и свойства; `get_parser` собирает `BookParser`. +`NullParser` без изменений. *Альтернативы:* ветка внутри `osv.py` (смешение доменов, конфликты с #9–#11); два +`Parser`-а с композитом (книга читается дважды, дедлайн и лимиты делятся). *Цена ошибки:* `BookParser` тонкий, +переносится. + +### D-2. Классификация листа — три вида для форм, признаки по области до таблицы +`detect.classify_sheet(sheet, rules) -> SheetKind` расширяется значением `STATEMENT_CONTINUATION`. Порядок проверки +прежний: признак ОСВ → `OSV`; иначе признак формы (заголовок формы или ОКУД из `statement_headers.yaml`) в любой строке +**до** первой строки, похожей на заголовок таблицы форм («Код»/«Коды» и справа ячейки-периоды) либо до первой строки с +кодом строки формы → `STATEMENT`; иначе маркер продолжения («Форма 0710001 с.2» / «с. 2») **или** строка заголовка +таблицы форм без заголовка формы **или** (без заголовка таблицы) колонка, где ≥ 60 % непустых значений — коды форм, → +`STATEMENT_CONTINUATION`; иначе `OTHER`. Ограничение «первые N строк» для признаков формы снимается: у НПЗ заголовок +формы стоит после титульного блока. Признак ОСВ по-прежнему ищется там же, где сейчас (модуль ОСВ не меняется). + +### D-3. Тип формы и разрез листа +`statements/detect.py`: `find_forms(sheet, rules) -> list[FormBlock(kind: BALANCE|INCOME|DISCLOSURE, title_row, +okud, table_header_row, first_data_row, last_row)]`. Тип по заголовку/ОКУД из `statement_headers.yaml`: +«Бухгалтерский баланс» / 0710001 → BALANCE; «Отчет о финансовых результатах», «Отчет о прибылях и убытках» / 0710002 → +INCOME; «Пояснения к бухгалтерскому балансу», «Расшифровка», «Отчет об изменениях капитала» (0710003), «Отчет о +движении денежных средств» (0710004) и прочие ОКУД 0710xxx → DISCLOSURE. Титул «Бухгалтерская отчетность» — не тип: тип +берёт ближайший следующий заголовок. Два заголовка форм на листе → два блока, второй начинается со своего заголовка +(§12 п. 1). Если ОКУД и текст заголовка противоречат — предупреждение, тип по ОКУД. DISCLOSURE → документ с `locator`, +шапкой (если нашлась) и пустыми `columns`/`lines`; на статус книги не влияет. + +### D-4. Заголовок таблицы, колонка кодов, колонка наименований, колонки-периоды +`statements/columns.py`. Строка заголовка таблицы — первая после заголовка формы строка, где есть ячейка «Код»/«Коды» +(синонимы в словаре) и правее неё хотя бы одна ячейка, матчащая период из `statement_periods.yaml`; при отсутствии +слова «Код» — строка, под которой начинается колонка кодов. Заголовок, разнесённый на две строки (шаблоны: «На» + +«20__ г.»; Межениновская: перенос строки внутри ячейки), склеивается: текст ячейки и текст ячейки той же колонки в +следующей строке, если та строка не содержит кодов и сумм. Строка нумерации колонок («1 2 3 4 5» подряд) пропускается. +Колонка кодов: среди колонок между наименованием и первой колонкой-периодом та, где ≥ 60 % непустых значений в блоке — +коды форм (`^\d{4}$` или `^\d{5}$` в диапазонах 1100–1799 / 2100–2999 и их пятизначных расширениях, плюс 5xxx для +расшифровок); значения читаются как текст (числа 1110.0 → «1110»). Нет такой колонки → документ без строк, `needs_check`, +предупреждение «колонка кодов не найдена» (решение владельца 06.10.2026, вопрос 6). Колонка наименований — ближайшая +левее кодов колонка с преобладанием текста; колонка «Пояснения» исключается по заголовку и по тому, что её значения — +пустые или короткие (≤ 3 символов). Колонки-периоды — все ячейки заголовка правее кодов с распознанным периодом, по +порядку слева направо: `column_index` 0, 1, 2…; `period_date` — дата («На 31 декабря 2024 г.», «31.12.2024») или +конец периода («За 9 месяцев 2025 г.» → 2025-09-30, «За январь - сентябрь 2025 г.» → 2025-09-30, «За 2024 г.» → +2024-12-31). Ячейки заголовка правее кодов без периода («Изменение», «%») не становятся колонками, их значения не +читаются, одно предупреждение на документ. `is_current` в контракте нет — ядро берёт самую позднюю дату. + +### D-5. Шапка документа +`statements/header.py`: область — от начала блока формы до строки заголовка таблицы; для первого блока на листе — от +начала листа (титульный блок НПЗ над заголовком формы входит в шапку баланса); шапка другого листа MUST NOT +использоваться (у ОФР НПЗ нет метки «ИНН» → `inn = null`). Метки и регулярки — в +`statement_headers.yaml`; значение метки — ближайшая непустая ячейка правее в той же строке, иначе первая непустая ячейка +следующей строки (адрес, имя организации под меткой). Особые правила: `inn` — 10 или 12 цифр у метки «ИНН», строки после +метки «аудитор» исключаются; `organization_name` — после «Организация», а если справа пусто и строкой выше есть текст с +признаком ОПФ (ООО/АО/ПАО/ЗАО/кавычки) — он (НПЗ); `okopf`/`okfs` — у общей метки «по ОКОПФ / ОКФС» два числа по порядку; +`units` — текст после «Единица измерения» как есть, ОКЕИ 384/385 рядом только подтверждает; `report_date_text` — подзаголовок +формы («на 31 декабря 2024 г.», «за 9 месяцев 2025 г.», «за январь - сентябрь 2025 г.»), `report_date` — его дата или конец +периода; при отсутствии — тройка «число, месяц, год» у метки «Дата»/«Отчетная дата» (строки или числа). Для ОФР дата отчёта +— конец периода. `*_confidence = 100` там, где поле найдено, `null` — где нет. Блок продолжения без шапки → все поля +`null`, `form_name` из маркера «Форма 0710001 с.2» (тип по ОКУД в маркере). + +### D-6. Строки и значения +`statements/lines.py`: строка блока с кодом в колонке кодов → `StatementLine(line_code=<текст без пробелов>, +line_name=, line_code_confidence=100, line_name_confidence=100)`; строки без кода (разделы, +«в том числе:», пустые с нулями, строка «Реализация иного имущества…» у НПЗ) не отдаются (`ASSUMPTION(OQ-36)`), их число — +в предупреждение «строк без кода пропущено: N» только если среди них есть суммы. Пятизначные подстроки отдаются как +обычные строки (решение владельца, вопрос 4). Повтор кода внутри документа → остаётся первая строка, код в +`checks.duplicate_line_codes`, `needs_check`. Значения: для каждой колонки-периода `StatementValue(column_index, amount, +string_value, confidence=100, is_recognized)`: число → `amount` через `numbers.parse_money` (Decimal, 2 знака, скобки → +минус, пробелы/NBSP убираются), `is_recognized = true`; пусто/«–»/«-» → `amount = null`, `string_value = null`, +`is_recognized = true`; нечисловой текст → `amount = null`, `string_value = <текст>`, `is_recognized = false`. Единицы не +нормализуются: если `header.units` содержит «тыс.» и хотя бы у 10 % значений дробная часть ≠ 0, в предупреждения пишется +«N значений с дробной частью при единице тыс. руб.» (решение владельца, вопрос 3; новый OQ в docs). + +### D-7. Соотношения §7 — данные в `statement_checks.yaml` +Формат записи: `{form: BALANCE|INCOME, relation: "1600 = 1100 + 1200", strict: true|false, abs_terms: [2120, 2210, …]}`. +Вычисление в `statements/checks.py` — чистая функция над `Mapping[str, Decimal | None]` по колонке: отсутствующий код — +0; правая часть считается дважды — как записано и с членами `abs_terms` по модулю (1С показывает расходы со знаком минус, +66н — в скобках, оба варианта допустимы); соотношение выполнено, если сходится хотя бы один вариант с допуском ±1 в +единицах формы (`Decimal("1")`). Жёсткие (`strict`): ББ `1100 = 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190` +(+1105 при наличии, `ASSUMPTION(OQ-37)`), `1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260` (+1215), `1300 = 1310 + 1320 + +1340 + 1350 + 1360 + 1370`, `1400 = 1410 + 1420 + 1430 + 1450`, `1500 = 1510 + 1520 + 1530 + 1540 + 1550`, `1600 = 1100 + +1200`, `1700 = 1300 + 1400 + 1500`, `1600 = 1700`; ОФР `2100 = 2110 − 2120`, `2200 = 2100 − 2210 − 2220`, `2300 = 2200 + +2310 + 2320 − 2330 + 2340 − 2350`. Мягкие (`strict: false`, только предупреждение): `2400 = 2300 − 2410 + 2420` и +варианты ФСБУ 4/2023 с 2411/2412/2460 (`ASSUMPTION(OQ-12)`), `2500 = 2400 + 2510 + 2520 + 2530`, и «сумма пятизначных +подстрок = родитель» (генерируется по коду: `1110 = 11101 + 11102 + …`, только когда подстроки есть). Проверка по каждой +колонке отдельно; результат — `StatementChecks(relations_failed[{column_index, relation, expected, actual}], +soft_relations_failed[…], duplicate_line_codes[])` + строки в предупреждения. Для форм без строк (нет колонки кодов, +DISCLOSURE) `checks` не заполняется. + +### D-8. Склейка листов-продолжений внутри книги, статус документа +`statements/merge.py`: `parse_sheets` идёт по листам книги по порядку; блок `STATEMENT` открывает документ; лист +`STATEMENT_CONTINUATION` присоединяется к последнему открытому документу формы, если число колонок-сумм в нём совпадает +(или заголовка таблицы на листе нет и число колонок с числами совпадает) — строки добавляются в конец, `locator` = +`sheet:<первый>` + `,<следующие имена>` через запятую. Продолжение без открытого документа (книга из одной страницы 2, +лист ОСВ между страницами) → документ-одиночка: шапка пустая, тип по ОКУД маркера или `OTHER`, `needs_check`, +предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, вопрос 2; переход к +признаку `is_continuation` в контракте — две необязательных поля, без смены логики). Документ `ok` ⇔ все жёсткие +соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов ∧ есть хотя бы одна колонка-период ∧ +в шапке есть организация или ИНН ∧ документ не одиночка-продолжение. Шаблоны (коды есть, сумм нет) → все значения +`null`, соотношения «0 = 0» сходятся, шапка пустая → `needs_check`. + +### D-9. Контракт v1.2 — модели и копия +`api/src/contract/result.py`: `StatementDocument.document_type: Literal["BALANCE","INCOME","DISCLOSURE","OTHER"]`; +`StatementChecks` и `StatementDocument.checks: OptionalNotNull[StatementChecks] = None`; `FailedRelation(column_index, +relation, expected: Money, actual: Money)`; `OsvHeader.period_start: date | None`, `period_end: date | None`, +`is_from_year_start: bool | None`. `CONTRACT_VERSION = "1.2"`; копия схемы и примеры из `../docs/contracts` v1.2 (тест +идентичности). Заполнение новых полей шапки ОСВ: `period_start`/`period_end` — по разобранному периоду (квартал/год → +первое и последнее число; диапазон месяцев из issue #11 — его границы), `is_from_year_start` — `period_start` = 1 января. +Если ветка #11 сливается раньше и уже вычисляет границы диапазона, заполнение делается здесь одной задачей на его +результате; если позже — поля в этом change заполняются для квартала/года, а для диапазона — в #11. Поля необязательные, +оба порядка совместимы. + +### D-10. Словари форм — отдельные файлы +`statement_headers.yaml` (заголовки форм и ОКУД по типам, маркер продолжения, метки шапки, синонимы «Код», «Наименование +показателя», «Пояснения», признаки ОПФ, метки-исключения «аудитор»), `statement_periods.yaml` (месяцы, формы «на <дата>», +«за месяцев <год>», «за <месяц> - <месяц> <год>», «за <год> г.», «<дд.мм.гггг>», «<год>»), `statement_checks.yaml` +(D-7). `periods.yaml` ОСВ не переиспользуется и не правится — ветка #11 его меняет. Загрузчик `rules.py` расширяется +моделями форм; тест покрытия словарей — на объединении файлов `statements-xlsx/**` и синтетики. + +### D-11. Тесты, golden, синтетика +Unit (`tests/unit/parser/statements/`) на `gridbuilder`; property (`tests/fixtures/test_statements_property.py`, маркер +`fixtures`) по манифесту `tests/statements_manifest.yaml` (каталог → организация, ИНН, ожидаемые типы и статусы, +полная форма или страница); golden `../fixtures/golden/expected_statement_parse///.json` через +обобщённые `golden_helpers` и `make golden-update`; синтетика в `tests/synthetic/generate_statements.py`: `st01_both_forms_one_sheet`, +`st02_csv_like`, `st03_no_codes`, `st04_mixed_book` (ОСВ + ББ), `st05_continuation_only`, `st06_header_variants`, +`st07_single_code_continuation`; линтер «нет координат» +включает `statements/**` и `book.py`; интеграционный тест воркера: книга НПЗ → `needs_check` (рубли при «тыс. руб.», OQ-138; O-10), `result_schema_version = "1.2"`, +два документа. CLI печатает формы так же, как ОСВ. + +## Risks / Trade-offs + +- [Колонка кодов не найдена из-за кодов-чисел или объединений] → коды читаются как текст из любого типа ячейки, merged + берётся по origin; синтетика `st02`. Остаточный риск закрывается фикстурой + синонимом, не `if`. +- [Соотношения §7 не сходятся из-за знаков 1С] → двойной расчёт с `abs_terms`; при несходимости обоих — `needs_check`, + перечень в `checks`; знак не угадывается. +- [Постраничные xls всегда `needs_check`] → осознанно (вопрос 2); ядро в Срезе 3 решит склейку; данные не теряются. +- [Конфликт с ветками #9–#11 в `osv.py`/`test_osv_parser.py`] → правило при rebase: логика ОСВ из их ветки, оркестрация + книги отсюда; PR создаётся после rebase на main. +- [Контракт 1.2 ломает идентичность копии в arm-core и scan-gateway] → bounded-PR с копией схемы и моделями раньше мержа + парсера; порядок merge docs → arm-core/scan-gateway → tabular-parser → fixtures. +- [Большие книги с формами] → те же лимиты и дедлайн, что у ОСВ; формы маленькие (≤ 100 строк на лист). + +## Migration Plan + +Не требуется: сервис без состояния. После merge — пересборка образа; потребители контракта обновляют копии раньше. + +## Open Questions + +Не блокируют: OQ-12 (знаки 2410/2420/2430 — мягкие проверки), OQ-36 (строки без кода — не отдаются), OQ-23 +(расшифровки — только классификация), OQ-37 (1105 как есть), OQ-131 (границы диапазона — D-9). Новые вопросы вносятся в +`07-open-questions.md` сквозным change'ем: единицы «тыс. руб.» против значений с копейками в 1С-выгрузках; форма без +колонки кодов (сопоставление по именам); страница-продолжение в отдельном файле (склейка в ядре, признак в контракте); +строка без кода с суммами в ОФР ФСБУ (НПЗ «Реализация иного имущества…»); шаблон ФСБУ с «АКТИВ» и 1110 в одной строке. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-06 | D-2: продолжение определяется маркером «с.2» либо заголовком таблицы/колонкой кодов без заголовка формы | у НПЗ листы 2–5 без маркера, у 1С — с маркером | добавить признак в словарь | +| 2 | 2026-10-06 | D-3: 0710003/0710004 и прочие ОКУД 0710xxx → DISCLOSURE, не OTHER | DBML различает расшифровку и прочее; ОДДС/ОИК для консолидации не нужны | переназначить тип | +| 3 | 2026-10-06 | D-4: порог 60 % кодов в колонке и диапазоны 1100–1799 / 2100–2999 (+5xxx) | §12 п. 2; 1105 и 1215 внутри диапазона | подвинуть границы в словаре | +| 4 | 2026-10-06 | D-4: колонка заголовка без периода не читается, одно предупреждение | §12 п. 4 «в extra», но у `statementDocument` поля `extra` нет; MINOR ради диагностики не оправдан | добавить `extra` в v1.x | +| 5 | 2026-10-06 | D-5: ИНН аудитора исключается по метке «аудитор» выше | иначе у Унагранде второй ИНН в шапке | синоним в словаре | +| 6 | 2026-10-06 | D-6: порог 10 % значений с дробной частью для предупреждения о единицах | единичные копейки в тысячах бывают от округления | число в словаре | +| 7 | 2026-10-06 | D-7: допуск ±1 единица формы, отсутствующий код = 0, двойной расчёт с `abs_terms` | §7 «расхождение > 0», но формы в тыс. руб. округляются построчно; знаки 1С vs 66н | допуск в словаре | +| 8 | 2026-10-06 | D-7: «сумма подстрок = родитель» — мягкая проверка, генерируется по коду | подстроки бывают неполными («прочее» без кода) | перевести в строгие | +| 9 | 2026-10-06 | D-8: критерий `ok` документа включает «в шапке есть организация или ИНН» | без шапки ядро не сверит владельца; страница 2 должна быть `needs_check` | ослабить критерий | +| 10 | 2026-10-06 | D-9: `period_start/period_end/is_from_year_start` в шапке ОСВ добавляются в контракт здесь, заполнение делится с #11 | владелец: поля в v1.2 (вопрос 5) | follow-up на заполнение | +| 11 | 2026-10-06 | D-1: документы книги — сначала ОСВ, потом формы | детерминированный порядок для golden | порядок листов | +| 12 | 2026-10-06 | Контракт v1.2 раскатан в arm-core (PR #27) и scan-gateway (PR #5) копиями; в scan-gateway вместе с v1.2 догнан v1.1 (`OsvRow.extra`, пример `result-osv-needs-check.json`), `CONTRACT_VERSION` обоих репозиториев не менялся | копия схемы обязана быть байт в байт с docs; потребители версию не повышают, пока не пишут новых полей | откат отдельным коммитом, поле необязательное (R-2) | +| 13 | 2026-10-06 | Минор-находки ревью задач 1–2 по `$comment` схемы («1.1»), статусу примера excel и `checks` в OCR-примере minimal отложены (правка в docs/contracts + пересинхронизация копий) | план запрещал трогать `$comment`; примеры предписаны планом | косметика примеров; ядро/парсер не затронуты (T1.1, T2.2, T2.3) | +| 14 | 2026-10-06 | Golden `schema_version` 1.1 → 1.2 (113 файлов) пересозданы сразу и закоммичены в ветку `feat/14-statements-golden` worktree `fixtures-wt` (шаг 1 задачи 13 выполнен раньше), симлинк `fixtures` переключён на worktree; основной checkout fixtures не тронут | смена `CONTRACT_VERSION` ломала golden до задачи 14, `make check` был бы красным на задачах 4–13 | коммит в fixtures-ветке раньше плана; откат — reset ветки (R-3) | +| 15 | 2026-10-06 | `docs/contracts/**` после задачи 1 заморожены: в задаче 16 не правятся; T1.1/T2.1/T2.2/T2.3 остаются deferred и уходят в follow-up | копии схемы в arm-core#27 и scan-gateway#5 на ревью и должны остаться байт в байт равными docs/contracts на main | три косметических замечания живут до следующего MINOR (R-4, указание координатора) | +| 16 | 2026-10-06 | Находка T6.I1 (osv.py не знает SheetKind.STATEMENT_CONTINUATION) не чинится в задаче 6, закрывается заменой osv.py::parse на BookParser в задаче 7 | реальные фикстуры ОСВ листов-продолжений не содержат, промежуточное состояние невидимо | если T7 не покроет — поймает финальное ревью (R-5) | +| 17 | 2026-10-06 | Task 11: соотношение проверяется в колонке, только если присутствует его цель (строка-итог); отсутствующие члены = 0; подстрочные правила — когда подстроки есть | код и тесты плана противоречили друг другу (тесты 1/4 требуют пропуска цели); фрагменты форм (страница 1 ОФР без 2200) не должны давать ложных нарушений | итоговая строка, пропущенная при наличии членов, этим соотношением не ловится (R-6) | +| 18 | 2026-10-06 | Task 11: словарь `statement_checks.yaml` не ослаблен — жёсткие соотношения ББ 1100…1500 и ОФР 2100…2300 остаются `strict: true`; данные теста плана исправлены | спека D-7 и решение №7; ослабление словаря ради зелёного теста — не решение исполнителя | меняется одна строка yaml (R-7) | +| 19 | 2026-10-06 | Task 11: для подстрок «сумма подстрок = родитель» действует общий допуск ±1, не нулевой | спека: допуск ±1 без исключений; формы в тыс. руб. округляются построчно | расхождение в 1 единицу у подстрок не ловится (R-8) | +| 20 | 2026-10-06 | Task 11: `_RELATION/_TERM` допускают пятизначные коды справа (предписано планом), но явные правила из yaml валидатор загрузки ограничивает четырёхзначными | подстрочные правила генерируются кодом; спека: словарь — только четырёхзначные | пятизначный код в yaml отвергается при загрузке (R-9) | +| 21 | 2026-10-06 | Task 12: лист-продолжение без колонки кодов при непустом результате не теряется молча — книга получает `needs_check` (флаг `dropped`); продолжение с типом `OTHER` проверяется по правилам ББ; `form_name` продолжения без маркера не передаётся | данные не должны теряться при `done`; продолжение без шапки не имеет собственного названия | лишний `needs_check` на редком листе (R-10) | +| 22 | 2026-10-06 | Task 12: допуск соотношений не менялся: НПЗ (`npz/2025-Q3`) даёт `needs_check` — 1600 = 1700 расходятся на 1.94 / 9.02 / 2.48 (значения в рублях с копейками при заголовке «тыс. руб.», сам файл не сходится) | спека D-6/D-7: единицы не нормализуются, допуск ±1 в единицах формы; подгонка допуска под файл — решение владельца (OQ-138) | если владелец хочет `done` для НПЗ: допуск/нормировка единиц, одна строка в словаре; манифест в задаче 14 фиксирует `needs_check` с причиной (R-11) | +| 23 | 2026-10-06 | R-13: допуск ±1 не меняется; баланс НПЗ ожидаемо `needs_check` (значения в рублях с копейками при шапке «тыс. руб.», 1600 = 1700 расходятся на 1.94/9.02/2.48, OQ-138); решение владельца, вариант А | единицы не нормализуются (D-6), допуск ±1 (D-7) | если владелец передумает — допуск/нормировка в словаре | +| 24 | 2026-10-06 | R-14: ОФР НПЗ и ОФР Унагранде (стр. 1) по факту `needs_check` — рубли с копейками при тыс. руб., 2100 расходится на 17.50/9.43/1416; допуск ±1 не меняется | то же, что №23 (OQ-138) | нормировка единиц/допуск в словаре, если владелец хочет `done` | +| 25 | 2026-10-06 | O-9: лист ОФР НПЗ с одной строкой 2400 (`min_codes` = 3) не распознавался как форма и терялся. **Решено в финальной волне (R-16, I-1):** лист после листа формы с ≥ `min_codes_continuation` = 1 кодов (словарь) идёт как продолжение и присоединяется по общим правилам; не присоединён — `needs_check` и предупреждение «лист с кодами форм (N < min_codes) не присоединён: …»; первая страница по-прежнему требует `min_codes` = 3 | потеря строки 2400 при `needs_check` недопустима; порог ослаблен только для продолжения | лишний лист-продолжение с 1 кодом присоединяется к последнему открытому документу (R-16) | +| 26 | 2026-10-06 | R-15: наши OQ-137…142 перенумерованы в OQ-138…143 | номер OQ-137 в main занят вопросом о раскладке горячих папок (docs #41/#43) | ссылки в PR-описаниях | +| 27 | 2026-10-06 | R-16: состав финальной волны ревью — I-1 (O-9), M-10 (тип формы маркера «Форма 0710002 с.2» запрещает присоединение к документу иного типа), I-2 (одиночный месяц не задаёт границы при дате на число/квартале, `not_single_month` в словаре), I-3 (название строки из ближайшей текстовой ячейки левее кодов; пустое имя без confidence), M-5 (`units_thousands`, `okud_code` — в словарь), M-8 (строка нумерации и нули не «суммы»), M-11 (предупреждение о дробных — одно на документ), T13.5 (`_upper_header_row`), M-1 (`uv.lock` вне git), M-6/M-7/T15.1/T15.2/M-3/M-4/M-2 — документация и тесты | исправления из финального ревью ветки, без изменения контракта | golden форм перегенерирован, снимки ОСВ не менялись | diff --git a/openspec/changes/slice-2b-statement-parser/proposal.md b/openspec/changes/slice-2b-statement-parser/proposal.md new file mode 100644 index 0000000..7377dab --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/proposal.md @@ -0,0 +1,74 @@ +# Proposal: Срез 2b — парсер ББ и ОФР из Excel (§12), контракт v1.2 + +**Срез:** 2 «Загрузка файлов + табличный парсер», подсрез 2b (`../docs/context/09-delivery-plan.md`). **Репозиторий:** +arm-tabular-parser. **Issue:** RSHB/arm-tabular-parser#14. **Сквозной change:** `../docs/openspec/changes/slice-2b-statement-parser` +(контракт v1.2, §12, OQ; RSHB/arm-ka-docs#26). Golden и синтетика — отдельный PR в arm-ka-fixtures. Копии схемы v1.2 — +bounded-PR в arm-core и arm-scan-gateway (без изменения поведения) до мержа этого change. + +Решение владельца 06.10.2026 (brainstorming): объём среза — **только парсер и контракт**; маппинг `statementDocument` в +ядро остаётся в Срезе 3. + +## Why + +После Среза 2a сервис разбирает только ОСВ, а листы с признаками ББ/ОФР пропускает с предупреждением +`skipped: statement (slice 2b)`. Без форм из Excel нет пути «отчётность компании → консолидация»: ББ и ОФР дают строки +КФО (§8), гудвилл (1310) и логические проверки (§7). В fixtures есть 15 реальных файлов пакета ДКБ в трёх раскладках +(постраничные xls 1С, многолистовая книга ФСБУ 4/2023, выгрузка `TDSheet`) и четыре пустых шаблона 66н / ФСБУ 4/2023 — +на них парсер обязан проходить без единого правила под файл, организацию или имя листа (§1.0, §12). + +## What Changes + +- **Оркестратор книги** `api/src/parser/book.py` (`BookParser`): лимит размера, чтение книги, дедлайн, классификация + листов (`detect.classify_sheet`), раздача листов конвейерам ОСВ и форм, сбор документов, предупреждений, метрик и + статуса. `OsvParser` сужается до конвейера листа ОСВ (`parse_sheet`); `get_parser()` возвращает `BookParser`. +- **Конвейер форм** `api/src/parser/statements/` (`StatementParser`): заголовок формы и ОКУД, колонка кодов, + шапка документа, колонки-периоды, строки и значения, соотношения §7, склейка листов-продолжений внутри книги, + расшифровки как `DISCLOSURE`. +- **Словари** `api/rules/statement_headers.yaml`, `statement_periods.yaml`, `statement_checks.yaml` (соотношения §7 — + данные, не код) с pydantic-валидацией при старте и тестом покрытия. +- **Контракт v1.2** (копия `api/contracts/`, `CONTRACT_VERSION = "1.2"`): `statementDocument.document_type` + + `DISCLOSURE`; `statementDocument.checks`; `osvDocument.header.period_start/period_end/is_from_year_start`. +- **Тесты**: unit по модулям на синтетических сетках; property-тест на всех `../fixtures/statements-xlsx/**` по манифесту; + golden `../fixtures/golden/expected_statement_parse/`; синтетика `../fixtures/statements-xlsx/synthetic/`; линтер «нет + координат» на `statements/**` и `book.py`; покрытие словарей; интеграционный тест воркера с книгой НПЗ. +- **Документы**: README (раздел «Парсер форм»), CLAUDE.md сервиса, `rules/README.md`, спеки `statement-parsing` (новая) + и `tabular-recognition-service` (дельта). + +## Capabilities + +### New Capabilities +- `statement-parsing`: разбор ББ/ОФР из Excel по §12 — классификация листов и продолжений, тип формы, шапка, колонки- + периоды, колонка кодов, строки и значения, соотношения §7, склейка листов, статусы и диагностика, словари. + +### Modified Capabilities +- `tabular-recognition-service`: «Парсер — заменяемый интерфейс» — реализация по умолчанию разбирает и ОСВ, и формы + (`BookParser`), результат пишется с `schema_version = "1.2"`; каталог `api/rules/` содержит словари форм. + +## Impact + +- Код: `api/src/parser/book.py` (новое), `api/src/parser/statements/**` (новое), `api/src/parser/osv.py` (удаление + `parse`/`_parse_book`), `api/src/parser/detect.py` (`classify_sheet`: продолжения форм, поиск признаков по области до + таблицы), `api/src/parser/__init__.py` (`get_parser`), `api/src/contract/{common,result}.py` (v1.2), `api/contracts/` + (копия), `api/rules/statement_*.yaml`, тесты, README, CLAUDE.md. +- Таблицы DBML: не затрагиваются (сервис без БД). Поля результата — только из контракта v1.2. +- Правила: §12 п. 1–8 (с решениями владельца 06.10.2026, внесёнными в §12 сквозным change'ем), §7 (формулы), §1.2 (числа). + Допущения: `ASSUMPTION(OQ-12)` — знаки 2410/2420/2430 проверяются мягко; `ASSUMPTION(OQ-36)` — строки без кода не + отдаются; `ASSUMPTION(OQ-23)` — расшифровки только классифицируются; `ASSUMPTION(OQ-37)` — код 1105 как пришёл. +- Соседи: `../docs` (контракт v1.2, §12, OQ), `../fixtures` (golden, синтетика), `arm-core` и `scan-gateway` — копия + схемы и pydantic-модели v1.2 отдельными bounded-PR, логика не меняется (ядро по-прежнему маппит только ОСВ). +- Пересечение с параллельной работой по ОСВ (issues #9, #10, #11): модули `header.py`, `classify.py`, `checks.py`, + `hierarchy.py`, `periods.yaml`, `osv_headers.yaml`, golden ОСВ в этом change не правятся; точка пересечения — + `osv.py` и `test_osv_parser.py`, разрешается при rebase на main перед PR. + +## Non-goals + +- Маппинг `statementDocument` в `document` / `financial_statement_*` ядра, логические проверки §7 в ядре, подтверждение + документа, чек-лист ББ/ОФР — Срез 3. +- Склейка страниц одной формы из **разных файлов** (постраничные xls 1С): страница-продолжение без шапки отдаётся как + документ с пустой шапкой и `needs_check`; признак продолжения в контракте — позже, при появлении потребителя в ядре. +- Сопоставление строк по наименованиям при отсутствии колонки кодов (§12 п. 2, словарь `statement_lines.yaml`) — + отложено до реального файла; форма без колонки кодов даёт `needs_check`. +- Нормализация единиц измерения (деление на 1000, пересчёт рублей в тысячи) — ядро и аналитик; парсер отдаёт как есть. +- Разбор содержимого расшифровок (`DISCLOSURE`), лист `К балансу`, рекомендуемые формы банка (OQ-23). +- Изменения в парсере ОСВ, кроме переноса оркестрации книги в `BookParser`; заполнение новых полей шапки ОСВ + `period_start/period_end/is_from_year_start` — в ветке issue #11 либо follow-up, см. design D-9. diff --git a/openspec/changes/slice-2b-statement-parser/specs/statement-parsing/spec.md b/openspec/changes/slice-2b-statement-parser/specs/statement-parsing/spec.md new file mode 100644 index 0000000..43559bb --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/specs/statement-parsing/spec.md @@ -0,0 +1,215 @@ +# statement-parsing Specification + +## Purpose +Разбор бухгалтерского баланса (0710001) и отчёта о финансовых результатах (0710002) из книг Excel в +`statementDocument` контракта v1.2 по §12 `05-domain-rules.md`: тип формы, шапка, колонки-периоды, строки с кодами и +значения, соотношения §7, склейка листов-продолжений внутри книги. Структура ищется по содержимому: ни буквы колонок, +ни номера строк, ни имена листов не являются контрактом; синонимы и соотношения — данные в `rules/statement_*.yaml`. + +## ADDED Requirements + +### Requirement: Листы форм и их продолжения находятся по содержимому +Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER`. Лист SHALL считаться +`STATEMENT`, если в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок +формы или код ОКУД из словаря `statement_headers.yaml`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`, +если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее +ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм. Признак ОСВ SHALL +проверяться первым. Тип формы SHALL определяться по заголовку или ОКУД: баланс / 0710001 → `BALANCE`, отчёт о +финансовых результатах (о прибылях и убытках) / 0710002 → `INCOME`, пояснения, расшифровки и прочие формы 0710xxx → +`DISCLOSURE`. Титул «Бухгалтерская отчетность» MUST NOT задавать тип: тип берётся из ближайшего следующего заголовка. +Два заголовка форм на одном листе SHALL давать два документа, второй — со строки своего заголовка. + +#### Scenario: Заголовок формы после титульного блока +- **WHEN** разбирается `statements-xlsx/npz/2025-Q3/Бухгалтерская отчетность … .xlsx`, лист «Бухгалтерский баланс. Лист 1», + где заголовок «Бухгалтерский баланс» стоит ниже титула «Бухгалтерская отчетность за январь - сентябрь 2025 г.» +- **THEN** лист классифицирован как `STATEMENT`, документ `BALANCE`, `header.okud = "0710001"` + +#### Scenario: Лист-продолжение без заголовка +- **WHEN** разбирается лист «Бухгалтерский баланс. Лист 2» той же книги (первая строка — «в том числе:», далее 11901, 1100…) +- **THEN** лист классифицирован как `STATEMENT_CONTINUATION` и не даёт отдельного документа + +#### Scenario: Маркер «с.2» в 1С-выгрузке +- **WHEN** разбирается `statements-xlsx/unagrande-co/2024-FY/Б.Б. Лист 2.xls` (первая строка «Форма 0710001 с.2») +- **THEN** лист классифицирован как `STATEMENT_CONTINUATION`, тип по ОКУД маркера — `BALANCE` + +#### Scenario: Две формы на одном листе +- **WHEN** разбирается синтетический лист `st01_both_forms_one_sheet` (баланс, затем ОФР) +- **THEN** результат содержит два документа — `BALANCE` и `INCOME` с одинаковым `locator` листа, строки ОФР не попали в баланс + +#### Scenario: Расшифровка +- **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах» +- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`; статус книги от него не зависит + +### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту +Строкой заголовка таблицы SHALL считаться первая после заголовка формы строка с ячейкой «Код»/«Коды» (синонимы в +словаре) и хотя бы одной ячейкой-периодом правее; заголовок, разнесённый на две строки или с переносами внутри ячейки, +SHALL склеиваться; строка нумерации колонок («1 2 3 4 5») SHALL пропускаться. Колонкой кодов SHALL считаться колонка, +в которой не меньше 60 % непустых значений блока — четырёх- или пятизначные коды строк форм (1100–1799 для ББ, 2100–2999 +для ОФР и их пятизначные расширения, 5xxx для расшифровок); коды читаются как текст из ячеек любого типа. Колонкой +наименований SHALL считаться ближайшая левее кодов колонка с преобладанием текста; колонка «Пояснения» в наименования +MUST NOT попадать. Колонками-периодами SHALL быть все ячейки заголовка правее кодов с распознанным периодом, по порядку +слева направо, `column_index` с нуля, `period_date` — дата или конец периода из `statement_periods.yaml`. Ячейки заголовка +без периода MUST NOT становиться колонками; их значения не читаются, в предупреждения пишется одна строка. Если колонки +кодов нет, документ SHALL отдаваться без строк со статусом `needs_check` и предупреждением «колонка кодов не найдена». + +#### Scenario: Три раскладки, одни и те же колонки +- **WHEN** разбираются `unagrande-co/2024-FY/Б.Б Лист 1.xls` (колонки «Пояснения | Наименование показателя | Код | На 31 + декабря 2024 г. | … 2023 | … 2022»), `npz/2025-Q3/…xlsx` («Пояснения | Наименование показателя | Код | На 30 сентября + 2025 г. | …») и `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` («Наименование показателя | Коды | За 9 месяцев 2025 года + | На 31 декабря 2024 года | …», под шапкой строка «1 2 3 4 5») +- **THEN** у каждого документа три колонки с `period_date` `2024-12-31/2023-12-31/2022-12-31`, `2025-09-30/2024-12-31/2023-12-31` + и `2025-09-30/2024-12-31/2023-12-31` соответственно, строка нумерации не стала строкой данных, «Пояснения» не попали в + `line_name` + +#### Scenario: Заголовок в две строки +- **WHEN** разбирается синтетический лист `st02_csv_like` («На 31 декабря» в одной строке, «2024 г.» в следующей, коды + без подписи «Код») +- **THEN** колонки-периоды найдены по склеенному тексту (`2024-12-31`, `2023-12-31`), колонка кодов найдена без подписи + +#### Scenario: Пустые шаблоны форм +- **WHEN** разбираются `templates/*.xlsx` (образцы 66н и ФСБУ 4/2023: коды есть, сумм и шапки организации нет; год в + заголовке колонки разнесён по ячейкам «20» и « г.») +- **THEN** документы `BALANCE`/`INCOME` со строками-кодами без значений, статус `needs_check`, ошибки разбора нет; + колонки-периоды могут отсутствовать + +#### Scenario: Колонка без периода +- **WHEN** в синтетическом листе правее колонок дат есть колонка «Изменение, %» +- **THEN** она не входит в `columns`, предупреждение «колонка без периода пропущена: Изменение, %» + +#### Scenario: Нет колонки кодов +- **WHEN** разбирается синтетический лист `st03_no_codes` (наименования и суммы без кодов) +- **THEN** документ `BALANCE` с пустыми `lines`, статус `needs_check`, предупреждение «колонка кодов не найдена» + +### Requirement: Шапка документа +Поля шапки SHALL искаться по меткам из `statement_headers.yaml` в области от начала блока формы до строки заголовка +таблицы; значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка следующей строки. `inn` SHALL +быть 10 или 12 цифр у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться. `organization_name` SHALL +браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком ОПФ. `units` SHALL +отдаваться текстом как в файле без нормализации. `report_date_text`/`report_date` SHALL браться из подзаголовка формы +(«на <дата>», «за <период>»), иначе из тройки «число, месяц, год»; для ОФР `report_date` — конец периода. Найденные поля +SHALL иметь `*_confidence = 100`. У документа без шапки все поля SHALL быть `null`, кроме `form_name` из маркера. + +#### Scenario: Полная шапка 1С +- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` +- **THEN** `organization_name = 'Акционерное общество "Унагранде Компани"'`, `inn = "3249004697"` (не ИНН аудитора + 7716021332), `okpo = "47888105"`, `okud = "0710001"`, `units = "в тыс. рублей"`, `report_date = 2024-12-31`, + `report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440» + +#### Scenario: Организация над меткой, ОФР за период +- **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ +- **THEN** `organization_name = 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'`, `inn = null` (на листе ОФР метки «ИНН» нет, а + шапка другого листа не используется), `okpo = "16098811"`, `report_date = 2025-09-30`, `units = "тыс. руб."`; у + документа `BALANCE` той же книги `inn = "2315995868"` из титульного блока над заголовком формы + +#### Scenario: Дата из тройки чисел +- **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа) +- **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"` + +### Requirement: Строки с кодом и значения +Каждая строка блока с кодом в колонке кодов SHALL давать `line` с `line_code` (текст без пробелов), `line_name` (trim, +переносы заменены пробелом), `line_code_confidence = line_name_confidence = 100` и по одному `value` на каждую +колонку-период. Строки без кода MUST NOT отдаваться (ASSUMPTION(OQ-36)); пятизначные подстроки SHALL отдаваться как +обычные строки. Число SHALL приводиться к `Decimal` по §1.2 (скобки → минус, пробелы убираются); пусто, «–», «-» → +`amount = null`, `is_recognized = true`; нечисловой текст → `amount = null`, `string_value` = текст, `is_recognized = +false`; `confidence = 100`. Повтор кода внутри документа SHALL оставлять первую строку, записывать код в +`checks.duplicate_line_codes` и переводить документ в `needs_check`. Единицы MUST NOT нормализоваться: при `units` с +«тыс.» и дробной частью не меньше чем у 10 % значений в предупреждения SHALL писаться «значений с дробной частью при +единице тыс. руб.: N». + +#### Scenario: Подстроки и значения с копейками +- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` +- **THEN** есть строки `1110` («Нематериальные активы», `5971264.14`) и `11101` («Нематериальные активы в организации», + `5852464.14`); строка без кода с нулями после 11103 не отдана; предупреждение о дробной части при «тыс. рублей» есть + +#### Scenario: Пустые значения и прочерки +- **WHEN** в `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` у строки 1170 пусто в первой колонке и 10 в остальных +- **THEN** `values[0].amount = null`, `values[1].amount = "10"`, все `is_recognized = true` + +#### Scenario: Отрицательные в скобках +- **WHEN** в синтетическом ОФР 2120 записана как «(6 162 067)» +- **THEN** `amount = "-6162067"` + +### Requirement: Соотношения §7 — данные, проверка по колонкам +Соотношения SHALL храниться в `statement_checks.yaml` в виде `{form, relation, strict, abs_terms}` и вычисляться чистой +функцией над «код → сумма» по каждой колонке-периоду отдельно и только по четырёхзначным кодам; отсутствующий код — +0; соотношение SHALL считаться выполненным, если сходится правая часть как записана либо с членами `abs_terms` по +модулю, с допуском ±1 в единицах формы. Нарушение жёсткого соотношения SHALL попадать в `checks.relations_failed` +(`column_index`, `relation`, `expected`, `actual`) и переводить документ в `needs_check`; нарушение мягкого — в +`checks.soft_relations_failed` и предупреждение. Проверка «сумма пятизначных подстрок = родитель» SHALL генерироваться по +коду как мягкая, когда подстроки есть. Перечень нарушений SHALL дублироваться строками в `metrics.warnings`. + +#### Scenario: Полные формы сходятся +- **WHEN** разбираются обе формы Межениновской (`1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`) +- **THEN** у документов `BALANCE` и `INCOME` `checks.relations_failed = []` по всем колонкам, статус `done` + +#### Scenario: Знаки 1С +- **WHEN** в ОФР Межениновской расходы записаны со знаком минус (2120 = −417659 при 2110 = 276851 и 2100 = −140808) +- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms`, в `relations_failed` его нет; то же доказывает + unit-тест `test_relation_holds_with_abs_terms`. Реальные ОФР Унагранде (страница 1) и НПЗ расходятся по 2100 из-за единиц + (рубли с копейками при шапке «тыс. руб.», OQ-138), а не из-за знака + +#### Scenario: Испорченный итог +- **WHEN** в синтетическом балансе 1600 на 5 больше суммы 1100 + 1200 +- **THEN** `relations_failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected, actual}`, статус `needs_check` + +#### Scenario: Мягкое соотношение +- **WHEN** в ОФР подстроки 2411 + 2412 не равны 2410 +- **THEN** нарушение в `soft_relations_failed`, статус документа не меняется + +### Requirement: Склейка листов-продолжений и статус документа +Листы книги SHALL обрабатываться по порядку; лист `STATEMENT_CONTINUATION` SHALL присоединяться к последнему открытому +документу формы при совпадении числа колонок-сумм, строки добавляются в конец, `locator` SHALL перечислять имена листов +через запятую. Продолжение без открытого документа SHALL давать документ с пустой шапкой, типом по ОКУД маркера (иначе +`OTHER`), статусом `needs_check` и предупреждением «страница-продолжение без шапки: загрузите форму одним файлом». +Документ SHALL считаться `ok`, если все жёсткие соотношения сошлись по всем колонкам, дубликатов кодов нет, колонка +кодов найдена, есть хотя бы одна колонка-период, в шапке есть организация или ИНН и документ не является одиночным +продолжением. Книга SHALL получать `done`, если все документы ОСВ и форм `ok`, иначе `needs_check`; книга без документов +ОСВ и форм SHALL давать `error INVALID_DOCUMENT`. + +#### Scenario: Баланс на пяти листах +- **WHEN** разбирается книга НПЗ +- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"` + и строками от 1105 до 1700 включительно, `INCOME` на трёх листах (лист отчёта с единственной строкой 2400 присоединяется + как продолжение: после листа формы хватает одного кода, `min_codes_continuation`), строка 2400 присутствует; `checks.relations_failed` баланса содержит только + `1600 = 1700` по трём колонкам (расхождения 1.94 / 9.02 / 2.48 руб.: значения в рублях с копейками при шапке + «тыс. руб.», OQ-138; допуск ±1 не меняется); `INCOME` — `needs_check` по `2100`, `2200`, `2300` (OQ-138); статус + книги `needs_check` + +#### Scenario: Лист-продолжение с одной кодовой строкой +- **WHEN** после листа формы идёт лист с единственной кодовой строкой (`2400`, меньше `min_codes`) и тем же числом колонок сумм +- **THEN** лист присоединяется к последнему открытому документу, строка присутствует; если присоединить нельзя (число + колонок или тип формы маркера не совпали), книга `needs_check` и предупреждение «лист с кодами форм (N < min_codes) не + присоединён: …»; первая страница формы с одной кодовой строкой формой не считается + +#### Scenario: Одинокая страница 2 +- **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет) +- **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check`, предупреждение о продолжении + +#### Scenario: Страница 1 без продолжения +- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив) +- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1700`/`1300` отсутствуют + (считаются нулём: соотношение `1600 = 1700` нарушено → `needs_check` с перечнем) + +#### Scenario: Смесь ОСВ и форм в одной книге +- **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса) +- **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим + +### Requirement: Словари форм, координаты, golden +Словари `statement_headers.yaml`, `statement_periods.yaml`, `statement_checks.yaml` SHALL валидироваться при старте; +каждая запись SHALL срабатывать хотя бы на одном файле `../fixtures/statements-xlsx/**` или синтетике. Код +`src/parser/statements/**` и `src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`). +Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы `statements-xlsx/**` и обновляться +только `make golden-update`; property-тест по манифесту `tests/fixtures/statements_manifest.yaml` SHALL проверять тип, +ИНН, ожидаемый статус и сходимость жёстких соотношений для полных форм. + +#### Scenario: Новый синоним без релиза кода +- **WHEN** в `statement_headers.yaml` добавлен синоним заголовка «Баланс предприятия» и файл с таким заголовком +- **THEN** лист классифицирован как `BALANCE` без изменения кода + +#### Scenario: Мёртвая запись словаря +- **WHEN** запись словаря не срабатывает ни на одном файле +- **THEN** тест покрытия падает с именем записи + +#### Scenario: Регрессия по снимкам +- **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка +- **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures diff --git a/openspec/changes/slice-2b-statement-parser/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-2b-statement-parser/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..d64dc4d --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/specs/tabular-recognition-service/spec.md @@ -0,0 +1,34 @@ +# Spec Delta: tabular-recognition-service + +## MODIFIED Requirements + +### Requirement: Парсер — заменяемый интерфейс +Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о +NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL быть +оркестратором книги `BookParser`: он читает книгу один раз, классифицирует листы по содержимому и отдаёт листы ОСВ +конвейеру спеки `osv-parsing`, листы форм и их продолжения — конвейеру спеки `statement-parsing`; прочие листы +пропускаются с предупреждением. Файл результата SHALL писаться с `schema_version = "1.2"`; документы книги — ОСВ в +порядке листов, затем формы в порядке листов. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только +в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте +приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, +`osv_groups`, `periods`, `data_types`), словари форм (`statement_headers`, `statement_periods`, `statement_checks`) и README. + +#### Scenario: Подмена реализации в тесте +- **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument` +- **THEN** файл результата с этим документом записан в S3, конверт имеет `status = done` и `result_s3` на этот файл + +#### Scenario: Реальная ОСВ через воркер +- **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS` +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата проходит схему v1.2 и + содержит один `osvDocument` с `account_code_raw = "66"` + +#### Scenario: Реальные формы через воркер +- **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX` +- **THEN** после ack публикуется конверт `needs_check` с `result_schema_version = "1.2"` (книга НПЗ: `1600 = 1700` + расходится на 1.94 / 9.02 / 2.48 руб. — значения в рублях при шапке «тыс. руб.», OQ-138), файл результата содержит два + `statementDocument` (`BALANCE`, `INCOME`); `checks.relations_failed` баланса содержит только `1600 = 1700` по трём + колонкам, у `INCOME` — нарушения `2100`, `2200`, `2300` по двум колонкам (те же рубли при «тыс. руб.», OQ-138) + +#### Scenario: Книга без ОСВ и форм +- **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы +- **THEN** публикуется конверт `error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов diff --git a/openspec/changes/slice-2b-statement-parser/tasks.md b/openspec/changes/slice-2b-statement-parser/tasks.md new file mode 100644 index 0000000..94eff89 --- /dev/null +++ b/openspec/changes/slice-2b-statement-parser/tasks.md @@ -0,0 +1,52 @@ +# Tasks: Срез 2b — парсер ББ и ОФР из Excel (tabular-parser) + +Каждая задача ≤ половины дня, начинается с тестов, заканчивается зелёным `make check`. Пути — от корня репозитория. +Фикстуры — `../fixtures` (worktree `.wt/slice-2b/fixtures`, отдельный PR в arm-ka-fixtures); контракт v1.2 — +`../docs/contracts` (ветка `feat/26-slice-2b-statement-parser`). Детализация шагов и модели исполнителей — в плане +superpowers (`docs/superpowers/plans/`), который пишется после согласования этой спеки. + +## 1. Основание + +- [x] 1.1 Контракт v1.2: копия `../docs/contracts/recognition-result.schema.json` и примеров в `api/contracts/`; + `StatementDocument.document_type` + `DISCLOSURE`, `StatementChecks`/`FailedRelation`, `OsvHeader.period_start/ + period_end/is_from_year_start`; `CONTRACT_VERSION = "1.2"`; тесты контракта и воркера (`result_schema_version`). +- [x] 1.2 Фикстуры форм в тестах: `conftest.py` — параметризация `statement_file` по `../fixtures/statements-xlsx/**` + (кроме `synthetic`), `statement_synthetic_file`; манифест `tests/statements_manifest.yaml`; обобщение + `golden_helpers.py` на каталог `expected_statement_parse/`; `make golden-update`/`golden-check` для обоих каталогов. +- [x] 1.3 Словари форм: `api/rules/statement_headers.yaml`, `statement_periods.yaml`, `statement_checks.yaml`; модели в + `rules.py`; тест на битый словарь; `rules/README.md`. + +## 2. Оркестратор книги + +- [x] 2.1 `detect.classify_sheet`: `SheetKind.STATEMENT_CONTINUATION`, признаки формы по области до таблицы (D-2); unit + на `gridbuilder`, fixtures-тест классификации всех листов `statements-xlsx/**` и `osv/**` (ОСВ не изменились). +- [x] 2.2 `book.py` (`BookParser`, D-1): перенос лимита/чтения/дедлайна/цикла из `OsvParser`, `OsvParser` без `parse`; + `get_parser` собирает `BookParser`; `test_osv_parser.py` → `test_book_parser.py` + unit на смесь листов; golden ОСВ и + property ОСВ без изменений. + +## 3. Конвейер форм + +- [x] 3.1 `statements/detect.py`: `find_forms` — заголовок формы, ОКУД, тип, разрез листа на блоки, расшифровки (D-3). +- [x] 3.2 `statements/columns.py`: заголовок таблицы, склейка двухстрочного заголовка, строка нумерации, колонка кодов, + колонка наименований, колонки-периоды и даты (D-4); unit на три раскладки и шаблоны. +- [x] 3.3 `statements/header.py`: шапка по меткам, ИНН не аудитора, организация над меткой, дата из тройки (D-5). +- [x] 3.4 `statements/lines.py`: строки с кодом, значения, прочерки, скобки, дубликаты, предупреждение о единицах (D-6). +- [x] 3.5 `statements/checks.py`: соотношения из словаря, `abs_terms`, допуск, мягкие, подстроки, `StatementChecks` (D-7). +- [x] 3.6 `statements/merge.py` + `statement.py` (`StatementParser.parse_sheets`): склейка продолжений, одиночная + страница, `locator`, критерий `ok` (D-8); unit на книгу из нескольких листов. + +## 4. Реальные файлы, синтетика, регрессия + +- [x] 4.1 Синтетика `tests/synthetic/generate_statements.py`: `st01_both_forms_one_sheet`, `st02_csv_like`, `st03_no_codes`, + `st04_mixed_book`, `st05_continuation_only`, `st06_header_variants`, `st07_single_code_continuation` → `../fixtures/statements-xlsx/synthetic/`. +- [x] 4.2 Property-тест `tests/fixtures/test_statements_property.py` по манифесту: типы, ИНН, статусы, сходимость §7 для + полных форм, шаблоны не падают; golden для всех файлов `statements-xlsx/**`. +- [x] 4.3 Покрытие словарей форм в `test_rules_coverage.py`; линтер координат на `statements/**` и `book.py`. +- [x] 4.4 Заполнение `period_start/period_end/is_from_year_start` в шапке ОСВ для квартала/года (D-9); для диапазона + месяцев — по результату ветки #11 (одна задача здесь либо follow-up). +- [x] 4.5 Интеграционный тест воркера: книга НПЗ → `needs_check` (OQ-138), два документа, `result_schema_version = "1.2"`; CLI печатает формы. + +## 5. Завершение + +- [ ] 5.1 README (раздел «Парсер форм»: конвейер, словари, статусы, как запустить на файле), CLAUDE.md сервиса, + `rules/README.md`; `make check-full`; rebase на main (ветки #9–#11), PR с «Rulings I made» и находками.