diff --git a/CLAUDE.md b/CLAUDE.md index 12326a2..5d880ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,8 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( ## Правила репозитория - `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` - (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`). Словари `api/rules/statement_*.yaml` - сервиса остаются до плана 4. + (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`); код возврата 20 при расхождении. Словари + форм — в пакете; `api/rules/statement_*.yaml` удалены планом 4 (#54). ## Тесты - property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`; для пакета ДКБ @@ -47,7 +47,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 — Срез 2b; -- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53), +- `api/src/contract/` — pydantic-модели контракта v2.0 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53), `tests/contract_identity.py`, с `../docs/contracts` проверяется в `make check`) — 3.1; - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) появились в Срезе 2a — 4.1; @@ -93,8 +93,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23). -**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы -в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`). +**Контракт:** `rows[].extra` (нераспознанные колонки таблицы по заголовку; появилось в v1.1); текущая версия — 2.0 +(`CONTRACT_VERSION`, раздел «Формы через arm_statements»); копия схемы в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`). **Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden, property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция @@ -102,26 +102,40 @@ property, покрытие словарей); без каталога `../fixtur Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR. -## Добавлено в Срезе 2b (парсер ББ/ОФР; `openspec/changes/slice-2b-statement-parser/`) +## Формы через arm_statements (план 4, #54) -**Модули:** `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`). +Конвейер форм ББ/ОФР (шаги 2–8, словари форм, перевод в контракт) — в пакете `api/arm_statements/` (копия +`arm-statements`, версия — `arm_statements/VERSION`). Собственного конвейера форм и словарей форм в сервисе нет: удалены +`api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement}.py` и +`api/rules/statement_*.yaml`. + +**Модули адаптера** (`api/src/parser/statements/`): `forms.py` (блоки форм и вид листа по словарям пакета), `cells.py` +(ячейка Excel -> `arm_statements.Cell`), `header_cells.py` (шапка по меткам словаря), `adapter.py` (`plan_sheets`, +`build_candidates`: листы -> кандидаты для пакета, продолжение присоединяется), `pipeline.py` (`StatementPipeline`: +кандидаты -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42). +`book.py` (`BookParser`) склеивает ОСВ и формы; `get_parser(settings)` создаёт его с `default_rules()` пакета. + +**Контракт 2.0:** `CONTRACT_VERSION = "2.0"`; у документов `status` и `warnings`; `checks` обязателен у BALANCE/INCOME со +строками; `line_code` может быть пуст (`line_status`). `service.statements_version` — версия копии пакета (без `VERSION` +сервис не стартует). Копия `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`). + +**Коды `OSV_*`:** предупреждения листа ОСВ — уровень `info`, модуль `osv_warnings.py` (контракт 2.0); предупреждение файла +`XLS_NO_CODEPAGE` — из пакета. **Правила расширения форм:** -- Новая форма или написание = строка в `api/rules/statement_*.yaml` (+ синтетика `st0x` в - `tests/synthetic/generate_statements.py` или реальный файл в `../fixtures/statements-xlsx/`). Не `if` под файл, - организацию или лист. Соотношения §7 — данные `statement_checks.yaml`. +- Словари форм меняются только в `arm-statements`, затем `sync-statements` (`--in-place` в PR фичи). Новое написание или + соотношение — там же, плюс фикстура или синтетика, на которой оно срабатывает (`tests/synthetic/generate_statements.py`, + `../fixtures/statements-xlsx/`). Не `if` под файл, организацию или лист. +- **Копию `api/arm_statements/` не правим напрямую** (код возврата 20 у `statements-check` при расхождении). Правка в + копии теряется при следующем `sync-statements`. - Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`): тот же AST-линтер `test_no_coordinates.py`; слов формы в - коде нет — только в YAML. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется. + коде сервиса нет. Деньги — `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/`. Значения ББ/ОФР из Excel — как их показывает -числовой формат ячейки (#42, `statements/number_format.py`; xlsb — как в файле + предупреждение); ОСВ формат не применяет. +**Значения форм:** значения ББ/ОФР из Excel — как их показывает числовой формат ячейки (#42, `statements/number_format.py`; +xlsb — как в файле + предупреждение `XLSB_NO_FORMATS`); ОСВ формат не применяет. Книга НПЗ в манифесте — `done` (итоги расходятся в пределах округления, OQ-138). diff --git a/README.md b/README.md index bec430b..1482637 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Stateless-сервис распознавания табличных файло возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается `arm-core`. -Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в +Обмен с `arm-core` идёт по контракту **v2.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в `api/contracts/` (идентичность с источником проверяется в `make check`). **Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта); @@ -58,7 +58,7 @@ api/ бэкенд (все команды разработки распознавания), logger.py, exception_handlers.py src/parser/ парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация, контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже) - src/contract/ pydantic-модели контракта v1.1 + src/contract/ pydantic-модели контракта v2.0 contracts/ копии JSON-схем контракта и примеры rules/ словари синонимов *.yaml (osv_headers, osv_groups, periods, data_types) и README scripts/ golden_update.py — обновление golden-снимков @@ -117,7 +117,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте | | `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) | | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | -| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` | +| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | | `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | | `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) | @@ -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` | +| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `warnings[]` документа листа, коды `OSV_*`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` | | `error` / `INVALID_DOCUMENT` | в книге нет листов ОСВ и форм (в сообщении — число просмотренных листов) | | `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг | | `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING | @@ -147,9 +147,11 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` | | `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») | -В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы -(`skipped: нет признаков ОСВ или формы`), несовпадение `file_type` с содержимым, свёрнутое -сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). +Диагностика, не влияющая на статус, — структурные предупреждения `{code, severity, message, locator?}`. Предупреждения +файла в `metrics.warnings`: пропущенный лист — `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), несовпадение `file_type` +с содержимым — `FILE_TYPE_MISMATCH`, xls без кодовой страницы — `XLS_NO_CODEPAGE`. Предупреждения листа ОСВ — в +`warnings[]` его документа (`OSV_*`, `info`): стратегия иерархии (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`), свёрнутое сальдо, +нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ). ### Контрольные суммы (§1.4) @@ -167,7 +169,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в -`metrics.warnings` — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием» +`warnings[]` документа (`OSV_INN_ROWS`) — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием» заменено), затем из конца текста названия («Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк @@ -271,29 +273,34 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 ## Парсер форм (ББ/ОФР) -Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через `statements/`. Документы -результата: сначала ОСВ в порядке листов, затем формы в порядке листов. Структура ищется по содержимому, как в ОСВ; -синонимы и соотношения §7 — данные `api/rules/statement_*.yaml`, в коде нет ни слова из формы и ни одной координаты. +Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через адаптер `statements/` +к пакету `arm_statements`. Документы результата: сначала ОСВ в порядке листов, затем формы в порядке листов. + +**Словари форм и шаги 2–8 живут в пакете `arm_statements`** (копия `api/arm_statements/`, версия — `arm_statements/VERSION`, +`service.statements_version`). Собственного конвейера форм и словарей форм в сервисе нет: синонимы заголовков, периодов, +соотношения §7 — данные пакета (`rules/statement_*.yaml`), код сервиса их не содержит. + +**Копию `api/arm_statements/` не правим напрямую.** Обновление — `docs/scripts/sync-statements.sh` (`--in-place` в PR +фичи); свежесть проверяет `make statements-check` (код возврата 20 при расхождении, входит в `make check-full`). | Модуль (`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` | +| `forms.py` | блоки форм на листе и вид листа по словарям пакета: заголовок формы или код ОКУД открывает блок; маркер «с.2», подпись «Код» с периодами или колонка кодов — продолжение | +| `cells.py` | ячейка Excel -> `arm_statements.Cell`: текст как его показывает Excel, значение, ссылка на ячейку; без нормализации | +| `header_cells.py` | шапка формы по меткам словаря пакета: ячейки-значения для `parse_header` (ИНН аудитора не берётся, организация — после метки «Организация», дата — из подзаголовка или тройки «число | месяц | год») | +| `adapter.py` | `plan_sheets` раскладывает листы по видам (ОСВ / форма / продолжение / прочее); `build_candidates` собирает кандидатов для пакета, лист-продолжение присоединяется к открытой форме | +| `pipeline.py` | `StatementPipeline`: кандидаты адаптера -> шаги 2–8 пакета (`normalize_candidate`) -> документы контракта через `to_contract`; предупреждения файла — `warning_to_contract` | +| `number_format.py` | отображаемое значение числа по числовому формату ячейки (`#,##0,` и т. п.; #42) | -Форма и продолжение: лист с заголовком формы или ОКУД — новая форма; лист без заголовка, но с маркером «Форма … с.2», -подписью «Код» с периодами или колонкой кодов — продолжение, присоединяется к последнему открытому документу. -Книга только из листов пояснений даёт `done` с документами `DISCLOSURE` без строк. +Модули ОСВ (`osv.py`, `osv_warnings.py`) формы не затрагивают. Предупреждения листа ОСВ — коды `OSV_*` (уровень `info`, +контракт 2.0): `osv_warnings.py`; `XLS_NO_CODEPAGE` — предупреждение файла пакета. -Критерий `ok` документа: жёсткие соотношения сошлись, дубликатов кодов нет, колонка кодов найдена, есть колонка-период, -в шапке есть организация или ИНН, документ не одинокое продолжение. Статус книги — `done`, если `ok` у всех документов, -иначе `needs_check` (список нарушений — `documents[].checks`; `soft_relations_failed` на статус не влияют). -Новый синоним или соотношение — строка в YAML (`api/rules/README.md`), плюс файл-фикстура или синтетика, на которой она -срабатывает (`test_rules_coverage.py`). +Результат формы — контракт **2.0** (`CONTRACT_VERSION = "2.0"`, `src/contract/`): у документа `status` и `warnings`, +`checks` обязателен у BALANCE/INCOME со строками, `line_code` может быть пуст (`line_status`: `ok | unreadable | missing | +detail`). Статус книги и критерий `ok` документа вычисляет пакет (шаги 2–8); сервис статус не пересчитывает. + +Словарь форм не правится в сервисе. Новое написание формы или соотношение — правка в `arm-statements` с последующим +`sync-statements`, плюс фикстура или синтетика, на которой оно срабатывает. ### Ограничения @@ -303,15 +310,19 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 (`[=0]"-"`, `[=-646532]"(647)"`) дают прочерк или текст-литерал («(647)» -> -647). Формат `General` — сырое значение. Формат, который не удалось разобрать (`%`, даты, дроби, экспонента), — сырое значение и одно предупреждение на документ: `формат чисел не разобран, значения как в ячейках: «…»`. В xlsb числовые форматы не читаются (pyxlsb): значения - как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в файле`. Разбор ОСВ + как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в ячейках` (`XLSB_NO_FORMATS`). Разбор ОСВ формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше - `fractional_share_percent` выдаётся одно предупреждение на документ; само оно на статус не влияет — `needs_check` - задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138). + `fractional_share_percent` выдаётся одно предупреждение на документ: `FRACTIONAL_IN_THOUSANDS` в xlsx/xls — на статус + не влияет, `XLSB_SCALE_SUSPECT` в xlsb (формат чисел не читается) — уровня `error`, документ `needs_check`; кроме того, + `needs_check` задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138). - Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся одиноким продолжением и даёт `needs_check`. - Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся - как продолжение (`min_codes_continuation` словаря) и присоединяется, если совпало число колонок сумм и тип формы; - иначе не присоединён, книга `needs_check`, предупреждение «лист с кодами форм (N < min_codes) не присоединён: …». + как продолжение (`min_codes_continuation` словаря) и присоединяется, если все его коды принадлежат типу открытой формы + (число колонок сумм при этом не сверяется); лист с `min_codes` и более кодами без маркера присоединяется, только если + совпало и число колонок сумм (`sheet_joins` пакета). Иначе лист остаётся отдельным кандидатом с предупреждением + `SHEET_NOT_JOINED` (`error`): «лист «X» не присоединён к форме «Y»: коды или число колонок сумм не подходят»; книга + `needs_check`. ### Реальные формы: файл → статус @@ -339,7 +350,7 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 | `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`. +в stdout — файл результата v2.0, у форм в `documents[]` — `header`, `lines[]`, `checks`. ## Тесты на реальных данных и golden-снимки @@ -414,7 +425,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.2 — тот же, что воркер кладёт в S3 под +В stdout печатается JSON файла результата контракта v2.0 — тот же, что воркер кладёт в S3 под `results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` — фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`, @@ -431,25 +442,32 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL ```json { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "00000000-0000-0000-0000-000000000000", "file_id": "00000000-0000-0000-0000-000000000000", "service": { "name": "tabular-parser", - "version": "1.0.0" + "version": "1.0.0", + "statements_version": "3390f92daddbfd4919d7f799ad2959cc35bfbd92" }, "status": "done", "metrics": { - "duration_ms": 2, + "duration_ms": 3, "rows": 9, - "warnings": [ - "sheet=Лист_1 hierarchy=outline" - ] + "warnings": [] }, "documents": [ { "document_type": "OSV", "locator": "sheet:Лист_1", + "status": "done", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + } + ], "header": { "organization_name": "АО \"Русский бисквит\"", "organization_inn": null, @@ -458,6 +476,9 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL "period_year": 2024, "period_quarter": "Q4", "is_cumulative": true, + "period_start": "2024-01-01", + "period_end": "2024-12-31", + "is_from_year_start": true, "data_type": "BU", "currency": "RUB", "header_variant": 1 diff --git a/api/arm_statements/VERSION b/api/arm_statements/VERSION index e7dc345..efc6084 100644 --- a/api/arm_statements/VERSION +++ b/api/arm_statements/VERSION @@ -1 +1 @@ -52857a0491c352c4997f8d2cc7011f36b57defb1 +3390f92daddbfd4919d7f799ad2959cc35bfbd92 diff --git a/api/arm_statements/columns.py b/api/arm_statements/columns.py index 75ae3ff..e3ab9d6 100644 --- a/api/arm_statements/columns.py +++ b/api/arm_statements/columns.py @@ -14,6 +14,7 @@ from arm_statements.loader import Rules from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, StatementHeader, StatementWarning _FULL_CONFIDENCE = 100.0 +_MAX_REFS = 10 @dataclass(frozen=True, slots=True) @@ -94,7 +95,10 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана """Колонки значений сегмента и предупреждения о пропущенных/недатированных столбцах.""" drafts: list[ColumnDraft] = [] warnings: list[StatementWarning] = [] - for col in range(code_col + 1, _width(rows, [*header_rows, *data_rows])): + columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows])) + last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules) + outside: list[int] = [] + for col in columns: numbers = has_numbers(rows, data_rows, col, code_col, source, rules) if not header_rows: if numbers: @@ -107,14 +111,86 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана warnings.append(make_warning(WarningCode.COLUMN_SKIPPED, message, rules, column_index=col)) elif cell is not None and parsed.kind is not PeriodKind.NONE: drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date)) + elif numbers and cell is None and last_dated is not None and col > last_dated: + outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм elif numbers: label, conf = (cell.text, cell.confidence) if cell is not None else ("", None) message = f"у столбца {col} с числами нет разобранной подписи периода" warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col)) drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None)) + if outside: + warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules)) return drafts, warnings +def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы + rows: Grid, + header_rows: tuple[int, ...], + data_rows: Sequence[int], + columns: range, + code_col: int, + source: Source, + rules: Rules, +) -> int | None: + """Правый столбец сегмента с подписью периода и числами в строках с кодами; без шапки — `None`. + + Подписанный столбец без чисел (потерянная подпись объединённой ячейки) границей таблицы не служит. + """ + dated = [ + col + for col in columns + if header_rows + and (cell := _label(rows, header_rows, col)) is not None + and not (parsed := parse_period_label(cell, rules)).excluded + and parsed.kind is not PeriodKind.NONE + and has_numbers(rows, data_rows, col, code_col, source, rules) + ] + return max(dated, default=None) + + +def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения + rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules +) -> StatementWarning: + refs = [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in outside + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + return outside_table_warning(refs, rules) + + +def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning: + """`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки).""" + shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "") + message = f"числа правее таблицы формы не учитываются: {shown}" + return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs)) + + +def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]: + """Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов).""" + refs: dict[str, None] = {} + rest: list[StatementWarning] = [] + for warning in warnings: + if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE: + refs.update(dict.fromkeys((warning.locator or "").split("\n"))) + else: + rest.append(warning) + return [*rest, outside_table_warning(list(refs), rules)] if refs else rest + + +def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка + rows: Grid, data_rows: Sequence[int], cols: Sequence[int], code_col: int, source: Source, rules: Rules +) -> list[str]: + """Адреса чисел в строках с кодами по столбцам `cols` (хвост сегмента правее колонок документа).""" + return [ + cell.ref + for r in _code_rows(rows, data_rows, code_col, rules) + for col in cols + if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules) + ] + + def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None: days = [ d.date diff --git a/api/arm_statements/issues.py b/api/arm_statements/issues.py index 4f54512..0efc9c4 100644 --- a/api/arm_statements/issues.py +++ b/api/arm_statements/issues.py @@ -45,6 +45,7 @@ class WarningCode(StrEnum): VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE" SHEET_OTHER = "SHEET_OTHER" ROW_SPLIT = "ROW_SPLIT" + NUMBERS_OUTSIDE_TABLE = "NUMBERS_OUTSIDE_TABLE" def make_warning( # noqa: PLR0913 -- сигнатура задана публичным API пакета diff --git a/api/arm_statements/rules/statement_warnings.yaml b/api/arm_statements/rules/statement_warnings.yaml index d81a271..03699c9 100644 --- a/api/arm_statements/rules/statement_warnings.yaml +++ b/api/arm_statements/rules/statement_warnings.yaml @@ -34,3 +34,4 @@ warnings: VALUE_OUT_OF_RANGE: {severity: info, scope: document} SHEET_OTHER: {severity: info, scope: file} ROW_SPLIT: {severity: info, scope: document} + NUMBERS_OUTSIDE_TABLE: {severity: info, scope: document} diff --git a/api/arm_statements/segments.py b/api/arm_statements/segments.py index 9cbedec..9bdce13 100644 --- a/api/arm_statements/segments.py +++ b/api/arm_statements/segments.py @@ -1,11 +1,20 @@ """Шаг 2: сегменты сетки, повторные шапки, границы листов и сопоставление колонок с первым сегментом.""" from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import NamedTuple from arm_statements.codes import code_parts, is_code_cell, majority_section, parse_code -from arm_statements.columns import ColumnDraft, DocumentDates, document_dates, finalize_columns, segment_columns +from arm_statements.columns import ( + ColumnDraft, + DocumentDates, + document_dates, + finalize_columns, + merge_outside_table, + outside_table_warning, + segment_columns, + trailing_outside_refs, +) from arm_statements.issues import WarningCode, make_warning from arm_statements.layout import ( Grid, @@ -20,6 +29,7 @@ from arm_statements.layout import ( from arm_statements.loader import CodeSection, Rules from arm_statements.model import ( FormCandidate, + PeriodKind, RowRole, StatementColumn, StatementHeader, @@ -212,6 +222,22 @@ def _map_segment( return layout, warning +def _trim_outside(seg: _Analysed, width: int, candidate: FormCandidate, rules: Rules) -> _Analysed: + """Сегмент без подписанных колонок: колонки сверх `width` документа — числа правее таблицы, не колонки.""" + if seg.code_col is None or len(seg.drafts) <= width or any(d.kind is not PeriodKind.NONE for d in seg.drafts): + return seg + tail = seg.drafts[width:] + if any(d.label for d in tail): # столбец с текстом подписи — не формула аналитика, остаётся расхождением + return seg + data = [r for r, role in zip(range(seg.first, seg.last + 1), seg.roles, strict=True) if role is RowRole.DATA] + refs = trailing_outside_refs( + candidate.rows, data, [d.grid_col for d in tail], seg.code_col, candidate.source, rules + ) + return replace( + seg, drafts=seg.drafts[:width], draft_warnings=(*seg.draft_warnings, outside_table_warning(refs, rules)) + ) + + def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout: """Раскладка кандидата: сегменты, колонки документа, роли всех строк и предупреждения шага 2.""" segments, section = _split_and_analyse(candidate, rules) @@ -223,7 +249,23 @@ def find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) if defining is not None: dates = document_dates(defining.drafts, header) columns, finalize_warnings = finalize_columns(defining.drafts, dates, rules) - warnings.extend(defining.draft_warnings) + # числа правее таблицы — по всем сегментам (формула аналитика может стоять на любой странице формы); + # сегмент без подписанных колонок (лист-продолжение) сверх колонок документа тоже «правее таблицы» + if any(d.kind is not PeriodKind.NONE for d in defining.drafts): + segments = [ + seg if seg is defining else _trim_outside(seg, len(columns), candidate, rules) for seg in segments + ] + warnings.extend( + merge_outside_table( + [ + w + for seg in segments + for w in seg.draft_warnings + if seg is defining or w.code == WarningCode.NUMBERS_OUTSIDE_TABLE + ], + rules, + ) + ) warnings.extend(finalize_warnings) if not any(s.header_rows for s in coded): warnings.append( diff --git a/api/arm_statements/tests/test_columns.py b/api/arm_statements/tests/test_columns.py index 2a12edc..c516d4d 100644 --- a/api/arm_statements/tests/test_columns.py +++ b/api/arm_statements/tests/test_columns.py @@ -68,18 +68,63 @@ def test_junk_label_over_numbers_is_kept(rules: Rules) -> None: assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)] -def test_empty_label_over_numbers_is_kept(rules: Rules) -> None: +def test_empty_label_right_of_dated_columns_is_outside_table(rules: Rules) -> None: rows: list[list[Any]] = [ ["Наименование", "Код", "На 30.09.2025", ""], ["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"], ] columns, warnings = run(rows, OCR, rules) - assert [(c.column_index, c.label, c.label_confidence, c.period_kind) for c in columns] == [ - (2, "На 30.09.2025", 100.0, PeriodKind.ABSOLUTE), - (3, "", None, PeriodKind.NONE), + assert [(c.column_index, c.period_kind) for c in columns] == [(2, PeriodKind.ABSOLUTE)] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO, None)] + assert warnings[0].locator == "row:1\nrow:2" + + +def test_numbers_right_of_last_dated_column_are_not_amounts(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None, None], + ["Запасы", "", "1210", D("5"), D("4"), None, D("9")], + ["Баланс", "", "1600", D("5"), D("4"), D("7"), None], + ["Итого", "", "1700", D("5"), D("4"), None, None], ] - assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3)] + columns, warnings = run(rows, EXCEL, rules, data_rows=(1, 2, 3)) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.severity) for w in warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert warnings[0].locator == "Лист1!G2\nЛист1!F3" + assert "G2" in warnings[0].message and "F3" in warnings[0].message + + +def test_numbers_outside_table_do_not_depend_on_cell_count(rules: Rules) -> None: + rows: list[list[Any]] = [["Наименование", "", "Код", "На 30.09.2025", None]] + rows += [["Строка", "", str(1200 + i), D("1"), D("2")] for i in range(12)] + cand = excel([("Лист1", rows)]) + _, warnings = segment_columns(cand.rows, (0,), tuple(range(1, 13)), 2, EXCEL, rules) + assert len(warnings) == 1 + assert warnings[0].message.endswith("и ещё 2") + assert warnings[0].message.count("Лист1!E") == 10 # noqa: PLR2004 + assert len((warnings[0].locator or "").split("\n")) == 12 # noqa: PLR2004 + + +def test_no_dated_columns_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "", ""], + ["Запасы", "", "1210", D("5"), D("4")], + ["Итого", "", "1200", D("5"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", 3), ("COLUMN_WITHOUT_DATE", 4)] + + +def test_undated_column_between_dated_keeps_old_behaviour(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None, "На 31.12.2024"], + ["Запасы", "", "1210", D("5"), D("9"), D("4")], + ["Итого", "", "1200", D("5"), D("9"), D("4")], + ] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 4)] def test_two_row_header_is_joined_with_min_confidence(rules: Rules) -> None: @@ -144,3 +189,25 @@ def test_public_amount_columns(rules: Rules) -> None: ] assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) + + +def test_dated_column_without_numbers_is_not_the_table_edge(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "", "На 31.12.2024", ""], + ["Запасы", "", "1210", None, D("5"), None, D("4")], + ["Итого", "", "1200", None, D("5"), None, D("4")], + ] # подпись объединённой ячейки стоит левее значений: границы таблицы нет, колонки остаются + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4, 5, 6][:0] or [w.code for w in warnings].count( + "NUMBERS_OUTSIDE_TABLE" + ) == 0 + + +def test_sheet_name_with_comma_keeps_addresses(rules: Rules) -> None: + rows: list[list[Any]] = [ + ["Наименование", "", "Код", "На 30.09.2025", None], + ["Запасы", "", "1210", D("5"), D("9")], + ] + cand = excel([("Баланс, 2025", rows)]) + _, warnings = segment_columns(cand.rows, (0,), (1,), 2, EXCEL, rules) + assert (warnings[0].locator or "").split("\n") == ["'Баланс, 2025'!E2"] diff --git a/api/arm_statements/tests/test_loader.py b/api/arm_statements/tests/test_loader.py index f5df35c..fe1e0f9 100644 --- a/api/arm_statements/tests/test_loader.py +++ b/api/arm_statements/tests/test_loader.py @@ -43,6 +43,7 @@ SPEC_WARNINGS = { "VALUE_OUT_OF_RANGE": ("info", "document"), "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), + "NUMBERS_OUTSIDE_TABLE": ("info", "document"), "XLS_NO_CODEPAGE": ("info", "file"), } diff --git a/api/arm_statements/tests/test_pipeline.py b/api/arm_statements/tests/test_pipeline.py index 45af8d9..4c5ad8a 100644 --- a/api/arm_statements/tests/test_pipeline.py +++ b/api/arm_statements/tests/test_pipeline.py @@ -194,3 +194,19 @@ def test_document_warnings_order(rules: Rules) -> None: ] assert [c for c in codes if c in expected] == [c for c in expected if c in codes] assert codes[0] == "NUMBER_FORMAT_UNPARSED" and codes.index("TYPE_CONFLICT") < codes.index("NO_ORGANIZATION_NO_INN") + + +# --- числа правее таблицы формы (решение владельца 18.10.2026): не колонки сумм, статус не портят --- + + +def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules: Rules) -> None: + rows: list[list[str | None]] = [[*r, None] for r in bal_rows()] + rows[6][3] = "7" # формула аналитика правее таблицы, в строке БАЛАНС (1600) + rows[9][3] = "8" + rows.insert(7, [*HEAD, None]) # повторная шапка второго сегмента (страница 2 формы) + cand = ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE) + doc = normalize_candidate(cand, rules).document + assert doc is not None and doc.status is DocStatus.DONE + assert [c.column_index for c in doc.columns] == [2] + assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)] + assert all(len(line.values) == 1 for line in doc.lines) diff --git a/api/arm_statements/tests/test_segments.py b/api/arm_statements/tests/test_segments.py index d636409..3f18448 100644 --- a/api/arm_statements/tests/test_segments.py +++ b/api/arm_statements/tests/test_segments.py @@ -187,3 +187,31 @@ def test_continuation_sheet_with_duplicate_date_maps_by_order(rules: Rules) -> N cand = excel(_npz_sheets(duplicate)) layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules) assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24)) + + +def test_numbers_right_of_table_in_all_segments_give_one_warning(rules: Rules) -> None: + head: list[str | D | None] = ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", None] + rows: list[list[str | D | None]] = [ + head, + ["Запасы", "1210", D("5"), D("4"), D("9")], + head, + ["Итого", "1200", D("5"), D("4"), D("8")], + ] + layout = find_layout(excel([("Лист1", rows)]), parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!E2\nЛист1!E4" + assert layout.segments[1].value_cols == ((2, 2), (3, 3)) + + +def test_headerless_continuation_sheet_with_outside_numbers(rules: Rules) -> None: + first: list[list[str | D | None]] = [ + ["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", None], + ["Запасы", "", "1210", D("5"), D("4"), D("9")], + ] + second: list[list[str | D | None]] = [["Итого", "", "1200", D("5"), D("4"), D("8")]] + cand = excel([("Лист1", first), ("Лист2", second)]) + layout = find_layout(cand, parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["NUMBERS_OUTSIDE_TABLE"] + assert layout.warnings[0].locator == "Лист1!F2\nЛист2!F1" + assert layout.segments[1].value_cols == ((3, 3), (4, 4)) + assert layout.segments[1].extra_cols == () diff --git a/api/contracts/examples/result-error.json b/api/contracts/examples/result-error.json index 54af7cb..b043599 100644 --- a/api/contracts/examples/result-error.json +++ b/api/contracts/examples/result-error.json @@ -1,8 +1,11 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "error", "error": { "code": "NOT_IMPLEMENTED", diff --git a/api/contracts/examples/result-message-done.json b/api/contracts/examples/result-message-done.json index 6d56f17..4e47bd1 100644 --- a/api/contracts/examples/result-message-done.json +++ b/api/contracts/examples/result-message-done.json @@ -1,14 +1,17 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "status": "done", - "service": { "name": "scan-gateway", "version": "0.1.0" }, + "service": { + "name": "scan-gateway", + "version": "0.1.0" + }, "result_s3": { "bucket": "arm-ka", "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" }, - "result_schema_version": "1.0", + "result_schema_version": "2.0", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "metrics": { "duration_ms": 18400, diff --git a/api/contracts/examples/result-message-error.json b/api/contracts/examples/result-message-error.json index f5ec77a..6da980c 100644 --- a/api/contracts/examples/result-message-error.json +++ b/api/contracts/examples/result-message-error.json @@ -1,9 +1,12 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "error", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "error": { "code": "NOT_IMPLEMENTED", "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" diff --git a/api/contracts/examples/result-message-processing.json b/api/contracts/examples/result-message-processing.json index 2087150..9136790 100644 --- a/api/contracts/examples/result-message-processing.json +++ b/api/contracts/examples/result-message-processing.json @@ -1,7 +1,10 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "processing", - "service": { "name": "tabular-parser", "version": "0.1.0" } + "service": { + "name": "tabular-parser", + "version": "0.1.0" + } } diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index b0a9e97..f54525f 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,8 +1,11 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "done", "documents": [ { @@ -24,7 +27,11 @@ "header_variant": 1 }, "group_schema": [ - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "C", @@ -70,14 +77,18 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } } ], "checks": { "total_row_matches_root": true, "balance_identity_violations": [], "parent_sum_violations": [] - } + }, + "status": "done", + "warnings": [] } ] } diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json index 81d64a8..6827c89 100644 --- a/api/contracts/examples/result-osv-needs-check.json +++ b/api/contracts/examples/result-osv-needs-check.json @@ -1,15 +1,15 @@ { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "1.0.0" }, + "service": { + "name": "tabular-parser", + "version": "1.0.0" + }, "status": "needs_check", "metrics": { "rows": 3, - "warnings": [ - "sheet=Лист_1 hierarchy=outline", - "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" - ] + "warnings": [] }, "documents": [ { @@ -28,8 +28,15 @@ "header_variant": 1 }, "group_schema": [ - { "type": "ACCOUNT", "name": "Счет" }, - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "ACCOUNT", + "name": "Счет" + }, + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "D", @@ -76,7 +83,9 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } }, { "row_number": 3, @@ -117,9 +126,24 @@ ], "checks": { "total_row_matches_root": true, - "balance_identity_violations": [3], + "balance_identity_violations": [ + 3 + ], "parent_sum_violations": [] - } + }, + "status": "needs_check", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + }, + { + "code": "OSV_BALANCE_IDENTITY", + "severity": "info", + "message": "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + } + ] } ] } diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json index 260528b..5e7b311 100644 --- a/api/contracts/examples/result-statement-excel.json +++ b/api/contracts/examples/result-statement-excel.json @@ -1,44 +1,99 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "service": { "name": "tabular-parser", "version": "0.3.0" }, - "status": "done", - "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 12, "warnings": [] }, "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" }, + "status": "done", + "form_variant": "full", + "warnings": [ + { "code": "FRACTIONAL_IN_THOUSANDS", "severity": "info", "message": "значений с дробной частью при единице тыс. руб.: 6 из 14" } + ], + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710001", "okud_text": "0710001", "okud_confidence": 100, + "okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100, + "okei": "384", "okei_text": "384", "okei_confidence": 100, + "needs_review_fields": [] + }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }, + { "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" } ], "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 } ] } + { "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] } ], - "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + "checks": { + "relations_failed": [], + "soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ] + } }, { "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 } ] } + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" } ], - "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + "header": { + "form_name": "Отчет о финансовых результатах", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": null, "inn_text": null, "inn_confidence": null, + "report_date": "2025-09-30", "report_date_text": "за январь - сентябрь 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710002", "okud_text": "0710002", "okud_confidence": 100, + "needs_review_fields": [] + }, + "columns": [ + { "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } + ], + "lines": [ + { "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false, + "values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] } + ], + "checks": { + "relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ], + "soft_relations_failed": [] + } }, { "document_type": "DISCLOSURE", "locator": "sheet:Пояснения", - "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "status": "done", + "form_variant": null, + "warnings": [], + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] }, "columns": [], "lines": [] } diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 913cfc5..9a3eb1d 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -1,50 +1,51 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", - "service": { "name": "scan-gateway", "version": "0.1.0" }, - "status": "done", + "service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" }, + "status": "needs_check", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] }, "documents": [ { "document_type": "BALANCE", - "locator": "pages:1-2", + "locator": "file:8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json;table:0", + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "организация распознана неуверенно", "field": "organization_name" }, + { "code": "ROW_SPLIT", "severity": "info", "message": "строка с несколькими кодами разлеплена: 1370, 1300", "line_refs": ["row:14#1", "row:14#2"] } + ], "header": { "form_name": "Бухгалтерский баланс", - "organization_name": "ООО Ромашка", - "organization_name_confidence": 97.5, - "inn": "7700000000", - "inn_confidence": 99, - "report_date_text": "30 сентября 2025 г.", - "report_date": "2025-09-30", - "units": "тыс. руб." + "organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "тыс. руб.", "units_confidence": 100, + "needs_review_fields": [] }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } ], "lines": [ - { - "line_code": "1100", - "line_code_confidence": 99, - "line_name": "Итого по разделу I", - "line_name_confidence": 96, - "values": [ - { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, - { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } - ] - }, - { - "line_code": "1600", - "line_code_confidence": 99, - "line_name": "БАЛАНС (актив)", - "line_name_confidence": 98, - "values": [ - { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, - { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } - ] - } - ] + { "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false, + "values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] }, + { "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [] } } ] } diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json index 4d9f401..a78afd8 100644 --- a/api/contracts/recognition-result-message.schema.json +++ b/api/contracts/recognition-result-message.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result-message/v1", - "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "$id": "arm-ka/recognition-result-message/v2", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0", "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).", "type": "object", "required": ["schema_version", "job_id", "file_id", "status", "service"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, "status": { "enum": ["processing", "done", "needs_check", "error"] }, @@ -47,7 +47,22 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла — те же, что metrics.warnings файла результата" } + } + } + }, + "$defs": { + "fileWarning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "severity": { "enum": ["error", "info"] }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" } }, + "column_index": { "type": "integer" }, + "field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "locator": { "type": "string" } } } }, diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index 43781b3..19531dd 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.2", - "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "$id": "arm-ka/recognition-result/v2", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence, statementDocument.form_variant обязателен, OTHER удалён из statementDocument.document_type. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -15,10 +15,11 @@ "required": ["name", "version"], "properties": { "name": { "enum": ["tabular-parser", "scan-gateway"] }, - "version": { "type": "string" } + "version": { "type": "string" }, + "statements_version": { "type": "string", "minLength": 1, "description": "коммит репозитория arm-statements, из которого разложена копия общего кода; обязателен, если в documents есть statementDocument" } } }, - "status": { "enum": ["done", "needs_check", "error"] }, + "status": { "enum": ["done", "needs_check", "error"], "description": "done ⇔ все документы done и нет предупреждений файла уровня error; error — документов нет" }, "error": { "type": ["object", "null"], "properties": { @@ -35,7 +36,7 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла (ошибочный json набора ИС, потерянная таблица, формат файла)" } } }, "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, @@ -44,31 +45,68 @@ "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } } }, - "if": { - "properties": { "status": { "enum": ["done", "needs_check"] } }, - "required": ["status"] - }, - "then": { - "properties": { "documents": { "minItems": 1 } } - }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + } + }, + { + "if": { + "properties": { "documents": { "contains": { "properties": { "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"] } }, "required": ["document_type"] } } }, + "required": ["documents"] + }, + "then": { + "properties": { "service": { "required": ["name", "version", "statements_version"] } } + } + } + ], "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "documentStatus": { "enum": ["done", "needs_check"] }, + "headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "warning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" }, + "severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" }, + "column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" }, + "field": { "$ref": "#/$defs/headerField" } + } + }, + "fileWarning": { + "allOf": [ { "$ref": "#/$defs/warning" } ], + "properties": { + "locator": { "type": "string", "examples": ["file:aep_3_ofr.json", "sheet:Лист3"], "description": "место в файле, к которому относится предупреждение" } + } + }, "failedRelation": { "type": "object", - "required": ["column_index", "relation", "expected", "actual"], + "required": ["column_index", "relation", "codes", "expected", "actual"], "properties": { "column_index": { "type": "integer" }, "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, - "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, - "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + "codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" } } }, "osvDocument": { "type": "object", - "required": ["document_type", "locator", "header", "group_schema", "rows"], + "required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"], "properties": { "document_type": { "const": "OSV" }, "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" }, "header": { "type": "object", "required": ["organization_name", "account_code_raw", "period_text"], @@ -142,79 +180,131 @@ } } }, + "statementValue": { + "type": "object", + "required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"], + "properties": { + "column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" }, + "text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" }, + "confidence": { "$ref": "#/$defs/confidence", "description": "уверенность источника (у Excel 100; позиции нет — 100)" }, + "is_recognized": { "type": "boolean", "description": "текст разобран как число без выброшенных и исправленных символов; от confidence не зависит" }, + "needs_review": { "type": "boolean", "description": "сводный признак: нуждается в покрытии §7 и не покрыто, не подтверждён знак, или в несошедшемся жёстком соотношении (§11)" } + } + }, + "statementLine": { + "type": "object", + "required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"], + "properties": { + "row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" }, + "ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:[#k]; Excel — <лист>! по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" }, + "line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" }, + "line_code_confidence": { "$ref": "#/$defs/confidence", "description": "от источника; у кода с заменой двойника — не выше 99" }, + "line_name": { "type": ["string", "null"], "description": "распознанное наименование как в источнике (не из справочника)" }, + "line_name_confidence": { "$ref": "#/$defs/confidence" }, + "parent_ref": { "type": ["string", "null"], "description": "ref строки-родителя у detail" }, + "needs_review": { "type": "boolean", "description": "неуверенный код не подтверждён структурой или повтор кода с неуверенной копией" }, + "values": { "type": "array", "items": { "$ref": "#/$defs/statementValue" } } + }, + "allOf": [ + { + "if": { "properties": { "line_status": { "const": "ok" } }, "required": ["line_status"] }, + "then": { "properties": { "line_code": { "type": "string" } } }, + "else": { "properties": { "line_code": { "type": "null" } } } + }, + { + "if": { "properties": { "line_status": { "const": "detail" } }, "required": ["line_status"] }, + "then": { "required": ["parent_ref"], "properties": { "parent_ref": { "type": "string" } } } + } + ] + }, "statementDocument": { "type": "object", - "required": ["document_type", "locator", "header", "columns", "lines"], + "required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, - "locator": { "type": "string", "examples": ["pages:1-2"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" }, + "locator": { "type": "string", "examples": ["file:aep_3.json;table:0", "sheet:Баланс,Баланс (2)"] }, + "status": { "$ref": "#/$defs/documentStatus" }, + "form_variant": { "enum": ["full", "simplified", null], "description": "по структуре кодов строк ok (§11); null — DISCLOSURE; обязателен" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" } }, "header": { "type": "object", "properties": { - "form_name": { "type": "string" }, + "form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" }, "organization_name": { "type": ["string", "null"] }, - "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "inn": { "type": ["string", "null"] }, - "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "report_date_text": { "type": ["string", "null"] }, + "organization_name_text": { "type": ["string", "null"] }, + "organization_name_confidence": { "$ref": "#/$defs/confidence" }, + "inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" }, + "inn_text": { "type": ["string", "null"] }, + "inn_confidence": { "$ref": "#/$defs/confidence" }, "report_date": { "type": ["string", "null"], "format": "date" }, + "report_date_text": { "type": ["string", "null"] }, + "report_date_confidence": { "$ref": "#/$defs/confidence" }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "units_text": { "type": ["string", "null"] }, + "units_confidence": { "$ref": "#/$defs/confidence" }, "okud": { "type": ["string", "null"] }, + "okud_text": { "type": ["string", "null"] }, + "okud_confidence": { "$ref": "#/$defs/confidence" }, "okpo": { "type": ["string", "null"] }, + "okpo_text": { "type": ["string", "null"] }, + "okpo_confidence": { "$ref": "#/$defs/confidence" }, "okopf": { "type": ["string", "null"] }, + "okopf_text": { "type": ["string", "null"] }, + "okopf_confidence": { "$ref": "#/$defs/confidence" }, "okfs": { "type": ["string", "null"] }, - "address": { "type": ["string", "null"] } + "okfs_text": { "type": ["string", "null"] }, + "okfs_confidence": { "$ref": "#/$defs/confidence" }, + "okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" }, + "okei_text": { "type": ["string", "null"] }, + "okei_confidence": { "$ref": "#/$defs/confidence" }, + "address": { "type": ["string", "null"] }, + "address_text": { "type": ["string", "null"] }, + "address_confidence": { "$ref": "#/$defs/confidence" }, + "needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" } } }, "columns": { "type": "array", "items": { "type": "object", - "required": ["column_index", "label"], + "required": ["column_index", "label", "period_kind"], "properties": { - "column_index": { "type": "integer" }, - "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, - "period_date": { "type": ["string", "null"], "format": "date" } - } - } - }, - "lines": { - "type": "array", - "items": { - "type": "object", - "required": ["line_code", "line_name", "values"], - "properties": { - "line_code": { "type": "string" }, - "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "line_name": { "type": "string" }, - "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "values": { - "type": "array", - "items": { - "type": "object", - "required": ["column_index"], - "properties": { - "column_index": { "type": "integer" }, - "amount": { "$ref": "#/$defs/money" }, - "string_value": { "type": ["string", "null"] }, - "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "is_recognized": { "type": "boolean" } - } - } - } + "column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] }, + "label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" }, + "period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" }, + "period_kind": { "enum": ["absolute", "relative:current", "relative:prev_year_end", "relative:prev_prev_year_end", "relative:prev_period", "none"] } } } }, + "lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" }, "checks": { "type": "object", - "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "required": ["relations_failed", "soft_relations_failed"], + "description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».", "properties": { - "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, - "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, - "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "жёсткие соотношения, не сошедшиеся вне допуска ±1 и округления 0,001 %: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "мягкие соотношения (OQ-12, подстроки) и жёсткие в пределах округления (OQ-138): info" } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "document_type": { "enum": ["BALANCE", "INCOME"] }, "lines": { "minItems": 1 } }, + "required": ["document_type", "lines"] + }, + "then": { "required": ["checks"] } + }, + { + "if": { "properties": { "lines": { "maxItems": 0 } }, "required": ["lines"] }, + "then": { "not": { "required": ["checks"] } } + } + ] } } } diff --git a/api/rules/README.md b/api/rules/README.md index 2773014..bcfd9f8 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -15,9 +15,9 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (начальный состав `91`, `99`; OQ-132): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения | | `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` (допуск в единицах формы), `rounding_tolerance_percent` (OQ-138: доля в процентах от наибольшего по модулю числа соотношения; жёсткое расхождение в её пределах — округление итогов, уходит в мягкие с предупреждением «в пределах округления»; сейчас 0,001), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) | + +Словари форм ББ/ОФР (`statement_*.yaml` в прежней версии) удалены из сервиса: их держит пакет `arm_statements` +(`api/arm_statements/rules/`), сервис их не читает. ## Как писать паттерны diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml deleted file mode 100644 index 5df0807..0000000 --- a/api/rules/statement_checks.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. -# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check -# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в -# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). -tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) -# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление. -# Значения форм берутся как их показывает числовой формат ячейки (#42, 15.10.2026): рубли с копейками в тысячах. -# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа -# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус: -# уходит в soft_relations_failed с предупреждением «в пределах округления». -rounding_tolerance_percent: 0.001 -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 deleted file mode 100644 index c9bb152..0000000 --- a/api/rules/statement_headers.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# Словарь форм ББ/ОФР (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 deleted file mode 100644 index e487f59..0000000 --- a/api/rules/statement_periods.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §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/src/app.py b/api/src/app.py index 609c904..aa1edcb 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -26,7 +26,7 @@ from src.core.exception_handlers import register_exception_handlers from src.core.logger import setup_logger from src.core.nats import NatsConnection, used_subjects from src.core.worker import STOP_TIMEOUT, RecognitionWorker -from src.parser import get_parser +from src.parser import get_parser, require_statements_version settings = get_settings() @@ -83,6 +83,7 @@ def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker parser=get_parser(settings), service_name=settings.SERVICE_NAME, service_version=VERSION, + statements_version=require_statements_version(), concurrency=settings.WORKER_CONCURRENCY, done_cache_size=settings.DONE_CACHE_SIZE, result_subject=settings.OPENBAO.NATS.NATS_RESULT_SUBJECT, diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py index bd042b7..a5e28ec 100644 --- a/api/src/contract/__init__.py +++ b/api/src/contract/__init__.py @@ -1,4 +1,6 @@ -"""Контракт обмена с сервисами распознавания v1.1 (схемы — `api/contracts/`, копии `docs/contracts/`). +"""Контракт обмена с сервисами распознавания v2.0 (запрос — v1.0). + +Схемы — `api/contracts/`, копии `docs/contracts/`. Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений. """ @@ -7,7 +9,9 @@ from src.contract.common import ( CONTRACT_VERSION, ContractModel, ErrorCode, + FileWarning, Metrics, + ResultWarning, S3Ref, ServiceInfo, ServiceName, @@ -15,6 +19,7 @@ from src.contract.common import ( from src.contract.message import MessageError, MessageStatus, RecognitionResultMessage from src.contract.request import FileType, RecognitionRequest from src.contract.result import ( + DocumentStatus, OsvDocument, RecognitionResult, ResultStatus, @@ -41,8 +46,10 @@ __all__ = [ "RESULT_SUBJECT", "TABULAR_REQUEST_SUBJECT", "ContractModel", + "DocumentStatus", "ErrorCode", "FileType", + "FileWarning", "MessageError", "MessageStatus", "Metrics", @@ -52,6 +59,7 @@ __all__ = [ "RecognitionResult", "RecognitionResultMessage", "ResultStatus", + "ResultWarning", "S3Ref", "ServiceInfo", "ServiceName", diff --git a/api/src/contract/common.py b/api/src/contract/common.py index dd6b4a1..5556128 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -1,4 +1,4 @@ -"""Общие основы моделей контракта v1.1: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. +"""Общие основы моделей контракта v2.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — @@ -27,10 +27,11 @@ from pydantic import ( model_validator, ) -CONTRACT_VERSION = "1.2" +CONTRACT_VERSION = "2.0" -# Шаблоны версий — буквально из схем -SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +# Шаблоны версий — буквально из схем: результат и конверт — MAJOR 2, запрос — MAJOR 1 +SCHEMA_VERSION_PATTERN = r"^2\.\d+$" # результат и конверт: свой MAJOR, любой MINOR +REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # запрос остаётся контрактом v1 RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version T = TypeVar("T") @@ -174,6 +175,7 @@ HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] NonEmptyStr = Annotated[str, Field(min_length=1)] SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] +RequestSchemaVersion = Annotated[str, Field(pattern=REQUEST_SCHEMA_VERSION_PATTERN)] ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] # Уверенность распознавания, проценты (число 0–100) Confidence = Annotated[float, Field(ge=0, le=100)] | None @@ -205,10 +207,15 @@ LaxErrorCode = Annotated[ErrorCode, Field(strict=False)] class ServiceInfo(ContractModel): - """Кто ответил: `service {name, version}`.""" + """Кто ответил: `service {name, version, statements_version?}`. + + `statements_version` — коммит arm-statements, из которого разложена копия общего кода; обязателен в файле + результата, если в нём есть документ формы (проверяет `RecognitionResult`). + """ name: LaxServiceName version: str + statements_version: OptionalNotNull[NonEmptyStr] = None class S3Ref(ContractModel): @@ -218,6 +225,30 @@ class S3Ref(ContractModel): key: NonEmptyStr +HeaderFieldName = Literal[ + "organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address" +] +WARNING_CODE_PATTERN = r"^[A-Z][A-Z0-9_]*$" +Severity = Literal["error", "info"] + + +class ResultWarning(ContractModel): + """`warning` схемы: предупреждение документа (`error` → документ `needs_check`, `info` на статус не влияет).""" + + code: Annotated[str, Field(pattern=WARNING_CODE_PATTERN)] + severity: Severity + message: str + line_refs: OptionalNotNull[list[str]] = None + column_index: OptionalNotNull[IntLike] = None + field: OptionalNotNull[HeaderFieldName] = None + + +class FileWarning(ResultWarning): + """`fileWarning` схемы: предупреждение файла, с местом в файле (`locator`).""" + + locator: OptionalNotNull[str] = None + + class Metrics(ContractModel): """Метрики обработки; все поля необязательны.""" @@ -227,7 +258,7 @@ class Metrics(ContractModel): fields_total: OptionalNotNull[IntLike] = None fields_low_confidence: OptionalNotNull[IntLike] = None fields_unrecognized: OptionalNotNull[IntLike] = None - warnings: OptionalNotNull[list[str]] = None + warnings: OptionalNotNull[list[FileWarning]] = None DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"] diff --git a/api/src/contract/message.py b/api/src/contract/message.py index 6e07b12..36b90c2 100644 --- a/api/src/contract/message.py +++ b/api/src/contract/message.py @@ -1,4 +1,4 @@ -"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v1.0. +"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v2.0. `processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и diff --git a/api/src/contract/request.py b/api/src/contract/request.py index 11890ee..d70440c 100644 --- a/api/src/contract/request.py +++ b/api/src/contract/request.py @@ -10,14 +10,22 @@ from typing import Annotated, Literal from pydantic import Field -from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime +from src.contract.common import ( + ContractModel, + ContractUUID, + IntLike, + NonEmptyStr, + RequestSchemaVersion, + S3Ref, + UtcDatetime, +) from src.contract.subjects import Pipeline FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] class RecognitionRequest(ContractModel): - schema_version: SchemaVersion + schema_version: RequestSchemaVersion job_id: ContractUUID # recognition_job.id file_id: ContractUUID # stored_file.id pipeline: Pipeline diff --git a/api/src/contract/result.py b/api/src/contract/result.py index c7948c0..236193e 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -1,12 +1,15 @@ -"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.2. +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v2.0. -tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). +tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / +пояснения). Формы ББ/ОФР нормализует общий код `arm_statements` (`05-domain-rules.md` §11): статусы строк, проверки, +статус документа и предупреждения приходят готовыми. Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор — `docs/context/03-architecture.md`). Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). -Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь. +Нормализация счетов, резолв контрагентов и проверки периода — в arm-core, не здесь. """ +import re from enum import StrEnum from typing import Annotated, Literal, Self @@ -18,17 +21,20 @@ from src.contract.common import ( ContractModel, ContractUUID, DataType, + HeaderFieldName, HeaderVariant, IntLike, LaxErrorCode, Metrics, Money, OptionalNotNull, + ResultWarning, SchemaVersion, ServiceInfo, ) __all__ = [ + "DocumentStatus", "FailedRelation", "OsvChecks", "OsvDocument", @@ -46,6 +52,11 @@ __all__ = [ "StatementValue", ] +DocumentStatus = Literal["done", "needs_check"] +LINE_CODE_PATTERN = r"^\d{4,5}$" +INN_PATTERN = r"^(\d{10}|\d{12})$" +_RELATION_CODE = re.compile(LINE_CODE_PATTERN, re.ASCII) + class ResultStatus(StrEnum): DONE = "done" @@ -119,6 +130,8 @@ class OsvChecks(ContractModel): class OsvDocument(ContractModel): document_type: Literal["OSV"] locator: str # «sheet:Лист_1» + status: DocumentStatus # done — контрольные суммы §1.4 сошлись + warnings: list[ResultWarning] # все уровня info, код с префиксом OSV_ header: OsvHeader group_schema: list[OsvGroup] columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН @@ -126,75 +139,152 @@ class OsvDocument(ContractModel): checks: OptionalNotNull[OsvChecks] = None -# --- ББ / ОФР / прочее ---------------------------------------------------------------------------------------------- +# --- ББ / ОФР / пояснения ------------------------------------------------------------------------------------------- class StatementHeader(ContractModel): - form_name: OptionalNotNull[str] = None + """Шапка формы: по каждому полю значение, сырой текст и уверенность; все поля необязательны (как в схеме).""" + + form_name: str | None = None organization_name: str | None = None + organization_name_text: str | None = None organization_name_confidence: Confidence = None - inn: str | None = None + inn: Annotated[str | None, Field(pattern=INN_PATTERN)] = None + inn_text: str | None = None inn_confidence: Confidence = None - report_date_text: str | None = None report_date: ContractDate | None = None + report_date_text: str | None = None + report_date_confidence: Confidence = None units: str | None = None # «тыс. руб.» + units_text: str | None = None + units_confidence: Confidence = None okud: str | None = None + okud_text: str | None = None + okud_confidence: Confidence = None okpo: str | None = None + okpo_text: str | None = None + okpo_confidence: Confidence = None okopf: str | None = None + okopf_text: str | None = None + okopf_confidence: Confidence = None okfs: str | None = None + okfs_text: str | None = None + okfs_confidence: Confidence = None + okei: str | None = None # код ОКЕИ единицы: 384 — тыс. руб., 383 — руб., 385 — млн руб. + okei_text: str | None = None + okei_confidence: Confidence = None address: str | None = None + address_text: str | None = None + address_confidence: Confidence = None + needs_review_fields: OptionalNotNull[list[HeaderFieldName]] = None + + @model_validator(mode="after") + def _unique_review_fields(self) -> Self: + fields = self.needs_review_fields + if fields is not None and len(set(fields)) != len(fields): + raise ValueError("needs_review_fields: элементы не должны повторяться") + return self + + +PeriodKindName = Literal[ + "absolute", + "relative:current", + "relative:prev_year_end", + "relative:prev_prev_year_end", + "relative:prev_period", + "none", +] class StatementColumn(ContractModel): - column_index: IntLike + column_index: Annotated[IntLike, Field(ge=0)] label: str # «На 30 сентября 2025 г.» + label_confidence: Confidence = None period_date: ContractDate | None = None + period_kind: PeriodKindName class StatementValue(ContractModel): column_index: IntLike - amount: Money = None + amount: Money string_value: str | None = None - confidence: Confidence = None - is_recognized: OptionalNotNull[bool] = None + text: str | None + confidence: Confidence + is_recognized: bool + needs_review: bool class StatementLine(ContractModel): - line_code: str + row_index: Annotated[IntLike, Field(ge=0)] + ref: Annotated[str, Field(min_length=1)] + line_status: Literal["ok", "unreadable", "missing", "detail"] + line_code: Annotated[str | None, Field(pattern=LINE_CODE_PATTERN)] + line_code_text: str | None line_code_confidence: Confidence = None - line_name: str + line_name: str | None line_name_confidence: Confidence = None + parent_ref: str | None = None + needs_review: bool values: list[StatementValue] + @model_validator(mode="after") + def _check_code_and_parent(self) -> Self: + """`line_code` не `null` ⇔ `line_status = ok`; у `detail` обязателен `parent_ref` (`allOf` схемы).""" + if (self.line_code is not None) != (self.line_status == "ok"): + raise ValueError(f"line_code {self.line_code!r} при line_status = {self.line_status}: код есть только у ok") + if self.line_status == "detail" and self.parent_ref is None: + raise ValueError("у строки detail обязателен parent_ref") + return self + class FailedRelation(ContractModel): """Нарушенное соотношение §7 по одной колонке. - `expected` — левая часть как в файле, `actual` — правая по строкам. + `expected` — левая часть (цель) по строкам ok, `actual` — правая; `null` — цели нет или все члены пусты. """ column_index: IntLike - relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + relation: str # «1600 = 1100 + 1200» — текст из словаря соотношений пакета arm_statements + codes: Annotated[list[str], Field(min_length=1)] # коды цели и членов expected: Money actual: Money + @model_validator(mode="after") + def _check_codes(self) -> Self: + bad = [code for code in self.codes if not _RELATION_CODE.fullmatch(code)] + if bad: + raise ValueError(f"коды соотношения не по шаблону {LINE_CODE_PATTERN}: {bad}") + return self + class StatementChecks(ContractModel): - """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" + """Самопроверка формы по соотношениям §7: пустые списки — «проверено, сошлось».""" - 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) + relations_failed: list[FailedRelation] + soft_relations_failed: list[FailedRelation] class StatementDocument(ContractModel): - document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] - locator: str # «pages:1-2» + document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"] + locator: str # «sheet:Баланс,Баланс (2)» + status: DocumentStatus + form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE + warnings: list[ResultWarning] header: StatementHeader columns: list[StatementColumn] lines: list[StatementLine] checks: OptionalNotNull[StatementChecks] = None + @model_validator(mode="after") + def _check_checks(self) -> Self: + """`checks` обязателен у BALANCE/INCOME со строками и запрещён у документа без строк — как `allOf` схемы.""" + if self.lines: + if self.document_type in ("BALANCE", "INCOME") and self.checks is None: + raise ValueError(f"у {self.document_type} со строками обязателен checks") + elif self.checks is not None: + raise ValueError("у документа без строк checks быть не должно") + return self + ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] @@ -216,3 +306,12 @@ class RecognitionResult(ContractModel): if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") return self + + @model_validator(mode="after") + def _check_statements_version(self) -> Self: + """Документ формы в файле ⇒ `service.statements_version` обязателен — `allOf` схемы.""" + if any(isinstance(document, StatementDocument) for document in self.documents) and ( + self.service.statements_version is None + ): + raise ValueError("при наличии документа формы обязателен service.statements_version") + return self diff --git a/api/src/core/worker.py b/api/src/core/worker.py index 5af63ee..52a3deb 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -139,6 +139,7 @@ class RecognitionWorker: parser: Parser, service_name: str, service_version: str, + statements_version: str, concurrency: int = 1, done_cache_size: int = 1000, clock: Clock = time.monotonic, @@ -150,7 +151,9 @@ class RecognitionWorker: self._publisher = publisher self._result_subject = result_subject self._parser = parser - self._service = ServiceInfo.model_validate({"name": service_name, "version": service_version}) + self._service = ServiceInfo.model_validate( + {"name": service_name, "version": service_version, "statements_version": statements_version} + ) self._concurrency = concurrency self._done_cache_size = done_cache_size self._clock = clock diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py index cddbf2c..16f5f49 100644 --- a/api/src/parser/__init__.py +++ b/api/src/parser/__init__.py @@ -12,11 +12,12 @@ from dataclasses import dataclass from pathlib import Path from typing import Protocol, runtime_checkable +from arm_statements import RulesError, default_rules, statements_version from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument from src.core.config import Settings from src.parser.rules import DEFAULT_RULES_DIR, load_rules -__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"] +__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser", "require_statements_version"] NOT_IMPLEMENTED_MESSAGE = "разбор XLS/XLSX/XLSB реализуется в Срезе 2" @@ -61,26 +62,44 @@ class NullParser: ) +def require_statements_version(path: Path | None = None) -> str: + """Версия копии `arm_statements` (коммит arm-statements) для `service.statements_version`. + + Копию раскладывает `sync-statements`; без файла `VERSION` сервис не стартует: результат с формами без версии общего + кода контракт отвергает (P4-16). `path` — файл версии (в тестах), по умолчанию `arm_statements/VERSION`. + """ + version = statements_version(path) + if version is None: + raise RuntimeError("копия arm_statements без VERSION: разложите её sync-statements") + return version + + def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser: """Фабрика парсера для воркера: `BookParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` / `PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`). Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом - и ключом, сервис не стартует, а не падает на первом файле. + и ключом, сервис не стартует, а не падает на первом файле. Словари форм — в пакете `arm_statements` + (`default_rules()`), `RulesError` — тоже `RuntimeError` с текстом ошибки. """ # `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 - цикл импорта + from src.parser.statements.pipeline import StatementPipeline # 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 rules = load_rules(rules_dir) + try: + statement_rules = default_rules() + except RulesError as exc: + raise RuntimeError(str(exc)) from exc return BookParser( rules, osv=OsvParser(rules), - statements=StatementParser(rules), + statements=StatementPipeline(statement_rules), + statement_rules=statement_rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, ) diff --git a/api/src/parser/book.py b/api/src/parser/book.py index 7e4ec52..becfe08 100644 --- a/api/src/parser/book.py +++ b/api/src/parser/book.py @@ -2,39 +2,79 @@ Карта исходов та же, что была у `OsvParser.parse` (D-6 Среза 2a): нет документов ОСВ и форм → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок → `INVALID_FORMAT`; книга не открывается → `UNREADABLE`; сигнатура/лимиты → -`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Нарушение контрольных сумм ОСВ или -соотношений формы хотя бы в одном документе → `needs_check`. Документы: ОСВ в порядке листов, затем формы. +`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; прочее → `RECOGNITION_FAILED`. Статус книги — `envelope_status` пакета от +статусов документов (ОСВ — по контрольным суммам, формы — по шагам 2–8) и предупреждений файла. Документы: ОСВ в +порядке листов, затем формы. `metrics.warnings` — только предупреждения файла (контракт 2.0): предупреждения листов ОСВ +и форм лежат в самих документах. """ import time -from collections.abc import Callable, Sequence -from typing import Protocol +from collections.abc import Callable from loguru import logger -from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument +from arm_statements import ( + DocStatus, + Severity, + StatementWarning, + WarningCode, + envelope_status, + make_warning, + warning_to_contract, +) +from arm_statements import Rules as StatementRules +from src.contract import ( + ErrorCode, + FileType, + FileWarning, + 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.detect import is_osv_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_detected +from src.parser.osv import OsvParser, data_rows +from src.parser.osv_warnings import OSV_XLSB_NO_OUTLINE, OsvNote +from src.parser.readers import BookRead, read_book_detected from src.parser.readers._common import one_line_reason from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR +from src.parser.readers.xls import NO_CODEPAGE_WARNING +from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING from src.parser.rules import Rules -from src.parser.statements.detect import is_weak_continuation +from src.parser.statements.adapter import plan_sheets +from src.parser.statements.pipeline import StatementPipeline -__all__ = ["BookParser", "StatementSheetParser"] +__all__ = ["BookParser"] _MS_IN_SECOND = 1000 -STATEMENT_KINDS = frozenset({SheetKind.STATEMENT, SheetKind.STATEMENT_CONTINUATION}) +SHEET_OTHER_MESSAGE = "лист без признаков ОСВ и формы" -class StatementSheetParser(Protocol): - def parse_sheets( - self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False - ) -> tuple[list[StatementDocument], list[str], bool]: ... +def _file_warning(warning: StatementWarning) -> FileWarning: + return FileWarning.model_validate(warning_to_contract(warning, file_level=True)) + + +def _as_package(warning: FileWarning) -> StatementWarning: + """Предупреждение файла в виде объекта пакета: `envelope_status` сравнивает уровень с `Severity`.""" + return StatementWarning(warning.code, Severity(warning.severity), warning.message) + + +def _reader_notes(read: BookRead) -> list[OsvNote]: + """Заметки читателя для документов ОСВ (сейчас — xlsb без outline).""" + return [OsvNote(OSV_XLSB_NO_OUTLINE, OUTLINE_UNAVAILABLE_WARNING)] if read.no_outline else [] + + +def _sheet_position(locator: str | None, names: list[str]) -> int: + """Номер первого листа в `sheet:<А>[,<Б>…]`; предупреждение без листа (читатель) — в начале.""" + if locator is None or not locator.startswith("sheet:"): + return -1 + body = locator.removeprefix("sheet:") + matches = [i for i, name in enumerate(names) if body == name or body.startswith(f"{name},")] + return min(matches, default=len(names)) class BookParser: @@ -43,7 +83,8 @@ class BookParser: rules: Rules, *, osv: OsvParser, - statements: StatementSheetParser, + statements: StatementPipeline, + statement_rules: StatementRules, max_bytes: int, timeout_seconds: float, clock: Callable[[], float] = time.monotonic, @@ -51,6 +92,7 @@ class BookParser: self._rules = rules self._osv = osv self._statements = statements + self._statement_rules = statement_rules self._max_bytes = max_bytes self._timeout_seconds = timeout_seconds self._clock = clock @@ -65,9 +107,9 @@ class BookParser: def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: started = self._clock() - warnings: list[str] = [] + warnings: list[FileWarning] = [] try: - documents, ok = self._parse_book(data, file_type, warnings) + documents, status = self._parse_book(data, file_type, warnings) except ParserError as exc: if exc.code is ErrorCode.UNREADABLE: logger.warning("Книга не читается: {reason}", reason=exc.message) @@ -80,13 +122,12 @@ class BookParser: 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 - ) + return ParseOutcome(status=ResultStatus(status), documents=tuple(documents), metrics=metrics) def _parse_book( - self, data: bytes, file_type: FileType, warnings: list[str] - ) -> tuple[list[OsvDocument | StatementDocument], bool]: + self, data: bytes, file_type: FileType, warnings: list[FileWarning] + ) -> tuple[list[OsvDocument | StatementDocument], str]: + """Документы и статус книги; `warnings` наполняется по ходу, чтобы ответ с ошибкой нёс собранное до неё.""" if len(data) > self._max_bytes: raise UnsupportedFormatError( f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: " @@ -96,52 +137,41 @@ class BookParser: read = read_book_detected( data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes ) + rules = self._statement_rules + if read.mismatch is not None: + warnings.append(_file_warning(make_warning(WarningCode.FILE_TYPE_MISMATCH, read.mismatch, rules))) + if read.no_codepage: + warnings.append(_file_warning(make_warning(WarningCode.XLS_NO_CODEPAGE, NO_CODEPAGE_WARNING, rules))) sheets = read.sheets - warnings.extend(unique(read.warnings)) + plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, self._rules), rules, deadline) + osv_names = {sheet.name for sheet in plan.osv} + other_names = {sheet.name for sheet in plan.other} + reader_notes = _reader_notes(read) osv_documents: list[OsvDocument] = [] - statement_sheets: list[tuple[Sheet, SheetKind]] = [] - ok = True - previous_is_statement = False # N-2: слабое продолжение — только сразу после листа формы - 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)) - previous_is_statement = True - continue - if kind is SheetKind.OTHER and previous_is_statement and is_weak_continuation(sheet, self._rules): - kind = SheetKind.STATEMENT_CONTINUATION # O-9: лист с 1–2 кодами сразу после листа формы - statement_sheets.append((sheet, kind)) - continue - previous_is_statement = False - if kind is SheetKind.OTHER: - warnings.append(sheet_line(sheet, "skipped: нет признаков ОСВ или формы")) - continue - try: - document, sheet_warnings, sheet_ok = self._osv.parse_sheet(sheet, deadline) - except ParserError: - # T7.1: ответ с ошибкой называет листы форм, которые из-за неё остались неразобранными - warnings.extend( - sheet_line(form_sheet, f"форма не разобрана: книга завершилась ошибкой листа ОСВ «{sheet.name}»") - for form_sheet, _ in statement_sheets - ) - raise - 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, formats_unreadable=read.detected == "XLSB" - ) - warnings.extend(statement_warnings) - documents: list[OsvDocument | StatementDocument] = [*osv_documents, *statement_documents] + for sheet in sheets: # по порядку листов: ошибка листа ОСВ оставляет в ответе предупреждения до неё + if sheet.name in osv_names: + deadline.check(f"лист «{sheet.name}»") + osv_documents.append(self._osv.parse_sheet(sheet, deadline, reader_notes).document) + elif sheet.name in other_names: + other = make_warning(WarningCode.SHEET_OTHER, SHEET_OTHER_MESSAGE, rules, locator=f"sheet:{sheet.name}") + warnings.append(_file_warning(other)) + outcome = self._statements.parse(plan.statements, deadline, xlsb=read.detected == "XLSB") + names = [sheet.name for sheet in sheets] + warnings.extend(outcome.file_warnings) + warnings.sort( + key=lambda w: _sheet_position(w.locator, names) + ) # устойчиво: порядок листов, внутри листа — как был + documents: list[OsvDocument | StatementDocument] = [*osv_documents, *outcome.documents] if not documents: raise InvalidDocumentError(f"в книге нет листов ОСВ или форм: листов просмотрено {len(sheets)}") - return documents, ok and statements_ok + statuses = [DocStatus(document.status) for document in documents] + status = envelope_status(statuses, [_as_package(w) for w in warnings]) + return documents, status.value 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: + def _error(self, code: ErrorCode, message: str, warnings: list[FileWarning], started: float) -> ParseOutcome: fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)} if warnings: fields["warnings"] = warnings diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 057e69c..92b53e5 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -30,6 +30,7 @@ from decimal import Decimal from src.contract.result import OsvChecks from src.parser.classify import SUM_FIELDS, ClassifiedRow +from src.parser.osv_warnings import OSV_BALANCE_IDENTITY, OSV_PARENT_SUM, OSV_TOTAL, OsvNote from src.parser.rules import OsvCheckRules __all__ = [ @@ -216,23 +217,23 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str: return message -def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[str]]: - """Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось). +def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[OsvNote]]: + """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. """ identity = balance_identity(rows, exempt) parents = parent_sums(rows, exempt) matches = total_matches_root(rows) - warnings: list[str] = [] + warnings: list[OsvNote] = [] if identity: - warnings.append( - f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" - ) + text = f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}" + warnings.append(OsvNote(OSV_BALANCE_IDENTITY, text)) if parents: - warnings.append(f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}") + text = f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}" + warnings.append(OsvNote(OSV_PARENT_SUM, text)) if not matches: - warnings.append(_total_warning(rows)) + warnings.append(OsvNote(OSV_TOTAL, _total_warning(rows))) checks = OsvChecks( total_row_matches_root=matches, balance_identity_violations=identity, diff --git a/api/src/parser/cli.py b/api/src/parser/cli.py index 60a061d..13eb8fc 100644 --- a/api/src/parser/cli.py +++ b/api/src/parser/cli.py @@ -1,6 +1,6 @@ """Разбор одного файла из командной строки: `python -m src.parser.cli <файл> [--file-type XLSX|XLS|XLSB]` (из `api/`). -В stdout — JSON файла результата контракта v1.1 (тот же, что воркер кладёт в S3 под `results/…/result.json`; сборка +В stdout — JSON файла результата контракта v2.0 (тот же, что воркер кладёт в S3 под `results/…/result.json`; сборка — `core.worker.build_result`) для `done | needs_check`, либо `{"status": "error", "error": {...}}` для ошибки разбора. JSON детерминирован по форме: `ensure_ascii=False`, `indent=2`, порядок полей — порядок модели (`sort_keys` нет); суммы — строки. `job_id`/`file_id` — фиксированные нулевые заглушки, `service` — `tabular-parser` с версией по @@ -28,7 +28,7 @@ from typing import Any, NoReturn from src.contract import FileType, ResultStatus, ServiceInfo from src.core.config import Settings from src.core.worker import build_result -from src.parser import ParseOutcome, get_parser +from src.parser import ParseOutcome, get_parser, require_statements_version __all__ = ["main"] @@ -119,7 +119,11 @@ def main(argv: Sequence[str] | None = None) -> int: return EXIT_ERROR service = ServiceInfo.model_validate( - {"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default} + { + "name": Settings.model_fields["SERVICE_NAME"].default, + "version": Settings.model_fields["VERSION"].default, + "statements_version": require_statements_version(), + } ) result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics) sys.stdout.write(_dump(result.to_wire())) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 46b5bda..3ff809b 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -14,11 +14,12 @@ from enum import StrEnum from functools import lru_cache from typing import Literal +from arm_statements import Rules as StatementRules +from arm_statements import default_rules 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", @@ -31,6 +32,7 @@ __all__ = [ "first_data_row", "inn_from_cell", "is_header_repeat", + "is_osv_sheet", ] CollapsedGroup = Literal["opening", "closing"] @@ -154,13 +156,21 @@ _STATEMENT_KINDS = { } -def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind: - """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по - `statements.detect` (признаки формы ищутся по всему листу до таблицы).""" +def is_osv_sheet(sheet: Sheet, rules: Rules) -> bool: + """Признак ведомости (`sheet_markers`) в первых `SHEET_MARKER_ROWS` строках листа.""" 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 any(_search(rules.headers.sheet_markers, text) for text in head) + + +def classify_sheet(sheet: Sheet, rules: Rules, statement_rules: StatementRules | None = None) -> SheetKind: + """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по словарям + пакета `arm_statements` (`statements.forms`; `None` — словари пакета по умолчанию).""" + if is_osv_sheet(sheet, rules): return SheetKind.OSV - return _STATEMENT_KINDS[classify_statement_sheet(sheet, rules)] + # цикл импорта: forms → cells → number_format → numbers → detect (INN_LENGTHS, inn_from_cell) + from src.parser.statements.forms import classify_statement_sheet # noqa: PLC0415 + + return _STATEMENT_KINDS[classify_statement_sheet(sheet, statement_rules or default_rules())] # --- Заголовок таблицы --- diff --git a/api/src/parser/grid.py b/api/src/parser/grid.py index 9918f3d..747e240 100644 --- a/api/src/parser/grid.py +++ b/api/src/parser/grid.py @@ -11,7 +11,7 @@ from decimal import Decimal from functools import cached_property from typing import Literal -__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"] +__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "cell_ref", "column_letter"] CellKind = Literal["empty", "text", "number", "date", "bool"] CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None @@ -139,3 +139,13 @@ def column_letter(index: int) -> str: index, remainder = divmod(index - 1, _ALPHABET_SIZE) letters.append(chr(ord("A") + remainder)) return "".join(reversed(letters)) + + +def cell_ref(sheet_name: str, row: int, col: int) -> str: + """Адрес ячейки `<лист>!`; имя листа в одинарных кавычках по правилу Excel (апостроф внутри удваивается). + + Кавычки нужны, если в имени есть что-то кроме букв, цифр и `_` или оно начинается с цифры.""" + plain = sheet_name != "" and all(c.isalpha() or c.isdigit() or c == "_" for c in sheet_name) + if not plain or sheet_name[0].isdigit(): + sheet_name = "'" + sheet_name.replace("'", "''") + "'" + return f"{sheet_name}!{column_letter(col)}{row}" diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index e5866c1..96a7185 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -2,7 +2,7 @@ Стратегии — чистые функции над `BodyRow` (строки данных без листа): `outline` → `indent` → `account_pattern` → `sums`. Каждая возвращает уровни `{row: level}` или `None`, если неприменима; первая, давшая непротиворечивое дерево -(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`hierarchy=<имя>` в `metrics.warnings`). +(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`OSV_HIERARCHY_BY_<ИМЯ>` в `warnings[]` документа). Уровни и родители считаются по порядку строк и уровням, а не по номерам и текстам: два одноимённых контрагента под разными субсчетами получают разных родителей (Review Focus 3). Строка «Итого» в стратегии не передаётся: она — diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 252e66f..d4d1cc2 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -1,16 +1,17 @@ """Конвейер листа ОСВ: лист → документ `OsvDocument`, предупреждения, признак «контрольные суммы сошлись». Книгу (лимиты, чтение, дедлайн, вид листа, статус и карта ошибок) разбирает `book.BookParser`. Здесь: заголовок -таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4. Имя листа -только подставляется в `locator` и сообщения, поиск по нему не ведётся. +таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4 → статус +документа и его предупреждения с кодами `OSV_*` (контракт 2.0). Имя листа только подставляется в `locator` и +сообщения, поиск по нему не ведётся. """ import datetime from collections.abc import Iterable, Sequence -from dataclasses import replace +from dataclasses import dataclass, replace -from src.contract import OsvDocument -from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow +from src.contract import OsvDocument, ResultWarning +from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow 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 @@ -20,24 +21,24 @@ from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header from src.parser.hierarchy import resolve_levels from src.parser.inn_rows import InnGlue, glue_inn_rows +from src.parser.osv_warnings import ( + OSV_HEADER, + OSV_INN_ROWS, + OSV_NO_DATA_ROWS, + OSV_ROWS, + OsvNote, + hierarchy_note, + notes_of, +) from src.parser.rules import Rules -__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"] +__all__ = ["OsvParser", "SheetResult", "data_rows"] LOCATOR_PREFIX = "sheet:" INN_COLUMN_KEY = "inn" NO_DATA_ROWS_WARNING = "нет строк данных" -def sheet_line(sheet: Sheet, text: str) -> str: - return f"sheet={sheet.name} {text}" - - -def unique(items: Iterable[str]) -> list[str]: - """Без повторов, в порядке первого появления.""" - return list(dict.fromkeys(items)) - - def _columns(header: TableHeader) -> dict[str, str]: """Диагностика `columns`: шесть колонок сумм и, если найдена, колонка ИНН (буквы — только здесь).""" columns = header.columns.as_diagnostics() @@ -120,6 +121,14 @@ def data_rows(document: OsvDocument) -> int: return sum(1 for row in document.rows if row.group_type != "TOTAL") +def _unique_notes(notes: Iterable[OsvNote]) -> list[OsvNote]: + """Без повторов по паре (код, текст), в порядке первого появления (T6.1).""" + seen: dict[tuple[str, str], OsvNote] = {} + for note in notes: + seen.setdefault((note.code, note.message), note) + return list(seen.values()) + + def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError: """Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется.""" reason = exc.message.removeprefix(f"лист «{sheet.name}»: ") @@ -140,15 +149,26 @@ def _check_inn_owners(glue: InnGlue, classified: list[ClassifiedRow]) -> InnGlue return replace(glue, warnings=[*glue.warnings, *extra], ok=False) +@dataclass(frozen=True) +class SheetResult: + """Итог листа ОСВ: документ (со статусом и предупреждениями), его заметки `OSV_*` (первая — иерархия) и признак + «контрольные суммы сошлись».""" + + document: OsvDocument + notes: tuple[OsvNote, ...] + ok: bool + + class OsvParser: """Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`.""" def __init__(self, rules: Rules) -> None: self._rules = rules - def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]: - """Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак - «контрольные суммы сошлись»; нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом. + def parse_sheet(self, sheet: Sheet, deadline: Deadline, reader_notes: Sequence[OsvNote] = ()) -> SheetResult: + """Документ листа ОСВ, его заметки (первая — иерархия) и признак «контрольные суммы сошлись»; + нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом. `reader_notes` — заметки читателя книги + (xlsb без outline): дописываются в конец предупреждений документа. """ rules = self._rules step = "заголовок таблицы" @@ -191,16 +211,31 @@ class OsvParser: glue = _check_inn_owners(glue, classified) doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") - checks, check_warnings = run_checks(classified, rules.checks) - document = self._document( - sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks - ) - warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *glue.warnings] + checks, check_notes = run_checks(classified, rules.checks) + notes = [ + hierarchy_note(levels.strategy), + *notes_of(OSV_HEADER, doc.warnings), + *notes_of(OSV_ROWS, body_warnings), + *notes_of(OSV_INN_ROWS, glue.warnings), + ] if not any(item.group_type != "TOTAL" for item in classified): - warnings.append(NO_DATA_ROWS_WARNING) - warnings.extend(check_warnings) + notes.append(OsvNote(OSV_NO_DATA_ROWS, NO_DATA_ROWS_WARNING)) + notes.extend(check_notes) + notes.extend(reader_notes) + unique_notes = tuple(_unique_notes(notes)) # Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check - return document, unique(warnings), _is_ok(checks) and glue.ok + ok = _is_ok(checks) and glue.ok + document = self._document( + sheet=sheet, + header=header, + doc=doc, + schema=schema.groups, + rows=classified, + checks=checks, + status="done" if ok else "needs_check", + notes=unique_notes, + ) + return SheetResult(document, unique_notes, ok) # --- внутреннее --- @@ -213,10 +248,14 @@ class OsvParser: schema: list[OsvGroup], rows: list[ClassifiedRow], checks: OsvChecks, + status: DocumentStatus, + notes: Sequence[OsvNote], ) -> OsvDocument: return OsvDocument( document_type="OSV", locator=f"{LOCATOR_PREFIX}{sheet.name}", + status=status, + warnings=[ResultWarning(code=note.code, severity="info", message=note.message) for note in notes], header=_header(doc), group_schema=schema, columns=_columns(header), diff --git a/api/src/parser/osv_warnings.py b/api/src/parser/osv_warnings.py new file mode 100644 index 0000000..22ad46a --- /dev/null +++ b/api/src/parser/osv_warnings.py @@ -0,0 +1,59 @@ +"""Предупреждения листа ОСВ с кодами `OSV_*` (P4-12): код — по источнику заметки, уровень info. + +Заметки становятся `warnings` документа ОСВ (контракт 2.0, `severity = info`). Заметка читателя «xls без кодовой +страницы» — не код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета (P4-13, R-2); здесь её нет. +""" + +from collections.abc import Iterable +from dataclasses import dataclass + +__all__ = [ + "OSV_BALANCE_IDENTITY", + "OSV_HEADER", + "OSV_HIERARCHY_PREFIX", + "OSV_INN_ROWS", + "OSV_NO_DATA_ROWS", + "OSV_PARENT_SUM", + "OSV_ROWS", + "OSV_TOTAL", + "OSV_XLSB_NO_OUTLINE", + "HIERARCHY_TEXTS", + "OsvNote", + "hierarchy_note", + "notes_of", +] + +OSV_HIERARCHY_PREFIX = "OSV_HIERARCHY_" +OSV_HEADER = "OSV_HEADER" +OSV_ROWS = "OSV_ROWS" +OSV_INN_ROWS = "OSV_INN_ROWS" +OSV_NO_DATA_ROWS = "OSV_NO_DATA_ROWS" +OSV_BALANCE_IDENTITY = "OSV_BALANCE_IDENTITY" +OSV_PARENT_SUM = "OSV_PARENT_SUM" +OSV_TOTAL = "OSV_TOTAL" +OSV_XLSB_NO_OUTLINE = "OSV_XLSB_NO_OUTLINE" # заметка читателя xlsb: книга дописывает её в каждый документ ОСВ + + +@dataclass(frozen=True) +class OsvNote: + code: str + message: str + + +HIERARCHY_TEXTS = { + "outline": "по группировке листа (outline)", + "indent": "по отступам (indent)", + "account_pattern": "по кодам счетов (account_pattern)", + "sums": "по суммам (sums)", + "trivial": "плоский список (trivial)", +} + + +def hierarchy_note(strategy: str) -> OsvNote: + """Стратегия иерархии: `outline|indent|account_pattern|sums|trivial` → `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`.""" + text = HIERARCHY_TEXTS.get(strategy, strategy) + return OsvNote(f"{OSV_HIERARCHY_PREFIX}BY_{strategy.upper()}", f"иерархия строк — {text}") + + +def notes_of(code: str, messages: Iterable[str]) -> list[OsvNote]: + return [OsvNote(code, message) for message in messages] diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index 372db75..89667a4 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -1,7 +1,8 @@ """Чтение книги Excel в модель `Sheet`: формат определяется по сигнатуре байтов, а не по `file_type` запроса. PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8). -Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`; +Несовпадение с `file_type` — строка `BookRead.mismatch` (книга делает из неё предупреждение файла), чтение по +сигнатуре. Иная сигнатура → `UnsupportedFormatError`; исключение библиотеки чтения → `UnreadableError` с причиной одной строкой. Лимиты фазы чтения (`_limits.py`, rulings R-23, R-25) проверяются до библиотеки чтения: распакованный размер zip @@ -26,7 +27,7 @@ from src.parser.readers._limits import ( check_xlsx_merged_area, check_zip_uncompressed_size, ) -from src.parser.readers.xls import NO_CODEPAGE_WARNING, has_no_codepage, read_xls +from src.parser.readers.xls import has_no_codepage, read_xls from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb from src.parser.readers.xlsx import read_xlsx @@ -51,23 +52,32 @@ def default_uncompressed_limit() -> int: class BookRead(NamedTuple): - """Результат чтения: листы, предупреждения и формат, определённый по содержимому (а не заявленный `file_type`).""" + """Результат чтения. + + `warnings` — заметки читателя для листов ОСВ (сейчас только `OUTLINE_UNAVAILABLE_WARNING` у xlsb); `detected` — + формат по содержимому (а не заявленный `file_type`); `mismatch` — строка несовпадения «file_type=…, но содержимое + …: прочитано как …» либо `None`; `no_codepage` — xls без записи CODEPAGE (строки + прочитаны как cp1251); `no_outline` — xlsb (outline level недоступен). + """ sheets: tuple[Sheet, ...] warnings: list[str] detected: FileType + mismatch: str | None = None + no_codepage: bool = False + no_outline: bool = False def read_book( data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None ) -> tuple[tuple[Sheet, ...], list[str]]: - """Листы и предупреждения чтения; см. `read_book_detected`.""" + """Листы и заметки читателя (`BookRead.warnings`); см. `read_book_detected`.""" result = read_book_detected(data, file_type, max_uncompressed_bytes=max_uncompressed_bytes) return result.sheets, result.warnings def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None) -> BookRead: - """Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb). + """Листы книги, формат по содержимому, несовпадение `file_type`, заметки читателя (xlsb, xls без CODEPAGE). `max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`. Превышение лимитов чтения — `UnsupportedFormatError`. @@ -76,8 +86,9 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt detected = _detect(data, file_type, limit) content, reader = _READERS[detected] warnings: list[str] = [] - if detected != file_type: - warnings.append(f"file_type={file_type}, но содержимое {content}: прочитано как {detected}") + mismatch = ( + f"file_type={file_type}, но содержимое {content}: прочитано как {detected}" if detected != file_type else None + ) try: sheets = reader(data) except ParserError: @@ -87,9 +98,8 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt check_merged_area(sheets) if detected == "XLSB": warnings.append(OUTLINE_UNAVAILABLE_WARNING) - if detected == "XLS" and has_no_codepage(data): - warnings.append(NO_CODEPAGE_WARNING) - return BookRead(sheets, warnings, detected) + no_codepage = detected == "XLS" and has_no_codepage(data) + return BookRead(sheets, warnings, detected, mismatch, no_codepage, no_outline=detected == "XLSB") def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType: diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 1261bae..f12a0b6 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -10,12 +10,9 @@ """ 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 @@ -35,11 +32,7 @@ __all__ = [ "Pattern", "PeriodRule", "PeriodRules", - "RelationRule", "Rules", - "StatementCheckRules", - "StatementHeaderRules", - "StatementPeriodRules", "first_match", "load_rules", "normalize_text", @@ -53,28 +46,12 @@ GROUPS_FILE = "osv_groups.yaml" PERIODS_FILE = "periods.yaml" DATA_TYPES_FILE = "data_types.yaml" CHECKS_FILE = "osv_checks.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 # Код счёта в словаре проверок: цифры, при необходимости с субсчетами через точку @@ -86,9 +63,6 @@ _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") @@ -367,242 +341,6 @@ 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 - #: OQ-138: доля (в процентах) от наибольшего |числа| соотношения, в пределах которой жёсткое нарушение — округление - rounding_tolerance_percent: Decimal = Decimal("0") - 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_rounding_tolerance(self) -> "StatementCheckRules": - if not Decimal("0") <= self.rounding_tolerance_percent <= Decimal("100"): - raise ValueError("rounding_tolerance_percent: ожидается доля в процентах от 0 до 100") - return self - - @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 @@ -610,22 +348,18 @@ class Rules: checks: OsvCheckRules 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` с файлом и ключом. + Словари форм ББ/ОФР — в пакете `arm_statements` (`default_rules()`), здесь их нет. + """ return Rules( headers=_load(path, HEADERS_FILE, OsvHeaderRules), groups=_load(path, GROUPS_FILE, OsvGroupRules), checks=_load(path, CHECKS_FILE, OsvCheckRules), 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/adapter.py b/api/src/parser/statements/adapter.py new file mode 100644 index 0000000..3998273 --- /dev/null +++ b/api/src/parser/statements/adapter.py @@ -0,0 +1,231 @@ +"""Адаптер книги -> `FormCandidate` (план 4, задача 5; спека «Шаг 1 — многостраничная форма», §11.2 п. 2–4). + +`plan_sheets` раскладывает листы по видам (ОСВ / форма / продолжение / прочее), `build_candidates` собирает из листов +форм кандидатов для пакета: блок формы -> кандидат, лист-продолжение присоединяется к открытой форме (`segment_breaks` +— границы листов в общей сетке) либо становится отдельным кандидатом с предупреждением. +""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field + +from arm_statements import Cell as StatementCell +from arm_statements import ( + DocumentType, + Evidence, + FormCandidate, + HeaderCells, + Source, + StatementWarning, + WarningCode, + continuation_type, + make_warning, + sheet_joins, +) +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.statements.cells import grid_rows +from src.parser.statements.forms import ( + StatementRules, + classify_statement_sheet, + code_rows, + codes_of, + continuation_marker, + find_blocks, + is_weak_continuation, + sheet_amount_columns, + sheet_code_column, +) +from src.parser.statements.header_cells import header_cells + +__all__ = ["SheetPlan", "build_candidates", "plan_sheets"] + +MAX_FORMAT_LENGTH = 30 +_OPEN_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) + +_Row = tuple[StatementCell | None, ...] + + +@dataclass(frozen=True) +class SheetPlan: + osv: tuple[Sheet, ...] + statements: tuple[tuple[Sheet, SheetKind], ...] + other: tuple[Sheet, ...] + + +def plan_sheets( + sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules, deadline: Deadline +) -> SheetPlan: + """Листы по порядку книги: ОСВ; форма; продолжение (в том числе слабое — лист из 1–2 кодов сразу после листа + формы или продолжения); прочее. Лист ОСВ или «прочее» прерывает «сразу после».""" + osv: list[Sheet] = [] + statements: list[tuple[Sheet, SheetKind]] = [] + other: list[Sheet] = [] + last_type: DocumentType | None = None # тип последнего блока последнего листа формы; None — формы «сразу до» нет + for sheet in sheets: + deadline.check(f"лист «{sheet.name}»: вид листа") + if is_osv(sheet): + osv.append(sheet) + last_type = None + continue + kind = classify_statement_sheet(sheet, rules) + if kind == "statement": + statements.append((sheet, SheetKind.STATEMENT)) + blocks = find_blocks(sheet, rules) + last_type = blocks[-1].type_hint if blocks else None + elif kind == "continuation": + statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) + elif last_type is not None and is_weak_continuation(sheet, rules, last_type): + statements.append((sheet, SheetKind.STATEMENT_CONTINUATION)) + else: + other.append(sheet) + last_type = None + return SheetPlan(tuple(osv), tuple(statements), tuple(other)) + + +@dataclass +class _Draft: + """Кандидат в сборке.""" + + type_hint: DocumentType | None + sheet_names: list[str] + evidence: Evidence + header: HeaderCells + rows: list[_Row] + form_amount_columns: int = 0 + segment_breaks: list[int] = field(default_factory=list) + formats: set[str] = field(default_factory=set) + warnings: list[StatementWarning] = field(default_factory=list) + + @property + def locator(self) -> str: + return "sheet:" + ",".join(self.sheet_names) + + +def _shorten(fmt: str) -> str: + return fmt if len(fmt) <= MAX_FORMAT_LENGTH else fmt[: MAX_FORMAT_LENGTH - 1] + "…" + + +def _form_block_drafts(sheet: Sheet, rules: StatementRules) -> list[_Draft]: + drafts: list[_Draft] = [] + for block in find_blocks(sheet, rules): + formats: set[str] = set() + col = sheet_code_column(sheet, rules, block.first_row, block.last_row) + rows_with_codes = [] if col is None else code_rows(sheet, rules, col, block.first_row, block.last_row) + header_last = max(block.first_row, rows_with_codes[0] - 1) if rows_with_codes else block.last_row + header, evidence = header_cells(sheet, rules, block, header_last, formats) + amounts = 0 if col is None else sheet_amount_columns(sheet, rules, col, block.first_row, block.last_row) + drafts.append( + _Draft( + type_hint=block.type_hint, + sheet_names=[sheet.name], + evidence=evidence, + header=header, + rows=grid_rows(sheet, block.first_row, block.last_row, sheet.n_cols, formats), + form_amount_columns=amounts, + formats=formats, + ) + ) + return drafts + + +def _separate(sheet: Sheet, type_hint: DocumentType | None, rules: StatementRules) -> _Draft: + """Отдельный кандидат из листа-продолжения; число колонок сумм считается, как у блока формы: следующий лист без + маркера сравнивается с ним, а не с нулём.""" + formats: set[str] = set() + col = sheet_code_column(sheet, rules, 1, sheet.n_rows) + if col is None and type_hint is not None: + col = sheet_code_column(sheet, rules, 1, sheet.n_rows, relaxed=type_hint) + amounts = 0 if col is None else sheet_amount_columns(sheet, rules, col, 1, sheet.n_rows) + return _Draft( + type_hint=type_hint, + sheet_names=[sheet.name], + evidence=Evidence(), + header=HeaderCells(), + rows=grid_rows(sheet, 1, sheet.n_rows, sheet.n_cols, formats), + form_amount_columns=amounts, + formats=formats, + ) + + +def _sheet_joins(sheet: Sheet, form: _Draft, rules: StatementRules) -> bool: + """Лист без маркера присоединяется по кодам и числу колонок сумм (как сегмент шага 2 пакета).""" + form_type = form.type_hint + assert form_type is not None # noqa: S101 - открытая форма всегда с типом + col = sheet_code_column(sheet, rules, 1, sheet.n_rows) + if col is None: + col = sheet_code_column(sheet, rules, 1, sheet.n_rows, relaxed=form_type) + if col is None: + return False + codes = codes_of(sheet, rules, col, 1, sheet.n_rows) + amounts = sheet_amount_columns(sheet, rules, col, 1, sheet.n_rows) + return sheet_joins(form_type, codes, amounts, form.form_amount_columns, rules) + + +def _continuation(sheet: Sheet, drafts: list[_Draft], rules: StatementRules) -> None: + open_form = next((draft for draft in reversed(drafts) if draft.type_hint in _OPEN_TYPES), None) + marker = continuation_marker(sheet, rules) + marker_type = None if marker is None else continuation_type(marker.text, rules) + if open_form is None: + drafts.append(_separate(sheet, marker_type, rules)) + return + locator = f"sheet:{sheet.name}" + if marker_type is not None and marker_type != open_form.type_hint: + separate = _separate(sheet, marker_type, rules) + message = ( + f"лист «{sheet.name}»: тип формы в маркере продолжения ({marker_type}) не совпадает с типом " + f"открытой формы ({open_form.type_hint})" + ) + separate.warnings.append(make_warning(WarningCode.CONTINUATION_TYPE_CONFLICT, message, rules, locator=locator)) + drafts.append(separate) + return + if marker is None and not _sheet_joins(sheet, open_form, rules): + separate = _separate(sheet, None, rules) + message = ( + f"лист «{sheet.name}» не присоединён к форме «{open_form.sheet_names[0]}»: коды или число колонок " + "сумм не подходят" + ) + separate.warnings.append(make_warning(WarningCode.SHEET_NOT_JOINED, message, rules, locator=locator)) + drafts.append(separate) + return + open_form.segment_breaks.append(len(open_form.rows)) + open_form.rows.extend(grid_rows(sheet, 1, sheet.n_rows, sheet.n_cols, open_form.formats)) + open_form.sheet_names.append(sheet.name) + + +def _finish(draft: _Draft, rules: StatementRules, *, xlsb: bool) -> FormCandidate: + width = max((len(row) for row in draft.rows), default=0) + rows = tuple(row + (None,) * (width - len(row)) for row in draft.rows) + warnings = list(draft.warnings) + locator = draft.locator + if xlsb: + message = "формат чисел xlsb не читается: значения как в ячейках" + warnings.append(make_warning(WarningCode.XLSB_NO_FORMATS, message, rules, locator=locator)) + if draft.formats: + shown = ", ".join(f"«{_shorten(fmt)}»" for fmt in sorted(draft.formats)) + message = f"формат чисел не разобран, значения как в ячейках: {shown}" + warnings.append(make_warning(WarningCode.NUMBER_FORMAT_UNPARSED, message, rules, locator=locator)) + return FormCandidate( + source=Source.EXCEL, + locator=locator, + type_hint=draft.type_hint, + evidence=draft.evidence, + header=draft.header, + rows=rows, + segment_breaks=tuple(draft.segment_breaks), + warnings=tuple(warnings), + ) + + +def build_candidates( + sheets: Sequence[tuple[Sheet, SheetKind]], rules: StatementRules, deadline: Deadline, *, xlsb: bool +) -> list[FormCandidate]: + """Кандидаты форм из листов `STATEMENT` / `STATEMENT_CONTINUATION` в порядке книги.""" + drafts: list[_Draft] = [] + for sheet, kind in sheets: + deadline.check(f"лист «{sheet.name}»: форма") + if kind is SheetKind.STATEMENT: + drafts.extend(_form_block_drafts(sheet, rules)) + elif kind is SheetKind.STATEMENT_CONTINUATION: + _continuation(sheet, drafts, rules) + return [_finish(draft, rules, xlsb=xlsb) for draft in drafts] diff --git a/api/src/parser/statements/cells.py b/api/src/parser/statements/cells.py new file mode 100644 index 0000000..2e27332 --- /dev/null +++ b/api/src/parser/statements/cells.py @@ -0,0 +1,70 @@ +"""Ячейка Excel -> `arm_statements.Cell` (план 4, P4-3…P4-5): текст как показывает Excel, значение, ссылка. + +Адаптер не нормализует: `value` числа — показываемое число (диапазон `numeric(18,2)` проверяет пакет), `text` — то, что +видит аналитик. Неразобранные числовые форматы собираются в `unparsed_formats` для предупреждения. +""" + +import datetime +from collections.abc import Sequence + +from arm_statements import Cell as StatementCell +from src.parser.grid import Cell, Sheet, cell_ref +from src.parser.statements.number_format import display_amount, general_text + +__all__ = ["excel_cell", "grid_rows"] + +_CONFIDENCE = 100.0 +_TRUE_TEXT = "ИСТИНА" +_FALSE_TEXT = "ЛОЖЬ" + + +def _number_cell(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object]: + raw = cell.value + assert raw is not None and not isinstance(raw, str | bool | datetime.date) # noqa: S101 - kind == "number" + if cell.number_format is not None: + shown = display_amount(raw, cell.number_format) + if shown.kind == "value": + return str(shown.text), shown.value + if shown.kind == "unparsed": + unparsed_formats.add(cell.number_format) + return general_text(raw), raw + + +def _text_and_value(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object] | None: + match cell.kind: + case "number": + return _number_cell(cell, unparsed_formats) + case "date": + value = cell.value + assert isinstance(value, datetime.date) # noqa: S101 - kind == "date" + return value.strftime("%d.%m.%Y"), value.date() if isinstance(value, datetime.datetime) else value + case "bool": + return (_TRUE_TEXT if cell.value else _FALSE_TEXT), bool(cell.value) + case "text": + return cell.text, None + case _: + return None + + +def excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> StatementCell | None: + """Ячейка листа для пакета; пустая и не-левая-верхняя позиция объединённого диапазона -> `None`.""" + if sheet.merged_origin(row, col) != (row, col): + return None + converted = _text_and_value(sheet.cell(row, col), unparsed_formats) + if converted is None: + return None + text, value = converted + return StatementCell(text=text, value=value, confidence=_CONFIDENCE, ref=cell_ref(sheet.name, row, col)) # type: ignore[arg-type] + + +def grid_rows( + sheet: Sheet, first_row: int, last_row: int, width: int, unparsed_formats: set[str] +) -> list[tuple[StatementCell | None, ...]]: + """Плотная сетка строк `first_row..last_row` включительно; позиция = `col - 1`, длина строки = `width`.""" + rows: list[tuple[StatementCell | None, ...]] = [] + for row in range(first_row, last_row + 1): + cells: Sequence[StatementCell | None] = [ + excel_cell(sheet, row, col, unparsed_formats) for col in range(1, width + 1) + ] + rows.append(tuple(cells)) + return rows diff --git a/api/src/parser/statements/checks.py b/api/src/parser/statements/checks.py deleted file mode 100644 index 563141c..0000000 --- a/api/src/parser/statements/checks.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Проверка соотношений §7 по колонкам (design D-7, спека D-7). - -Правила из `statement_checks.yaml`; отсутствующий код = 0. Правая часть вычисляется как записано -и с `abs_terms` по модулю (1С минус vs 66н скобки). Допуск `tolerance` для всех правил. -Жёсткие нарушения → `relations_failed`, мягкие → `soft_relations_failed`. -Правило проверяется, если целевой код в данных (подстроки всегда). -OQ-138: жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего |числа| соотношения — округление, -уходит в мягкие. -AURORA-1527: «родитель = Σ подстрок» по колонке — только если хотя бы одна подстрока в ней непуста (amount не null). -M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же первыми тремя цифрами. -""" - -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") -_HUNDRED = Decimal("100") -_PARENT_LEN = 4 -_GROUP_LEN = 3 # первые цифры кода, общие у строки и её подстрок (1230 → 12301…12314) - - -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 _filled(lines: Sequence[StatementLine], column_count: int) -> list[set[str]]: - """Коды строк, у которых в колонке есть значение (`amount` не null; ноль — значение).""" - columns: list[set[str]] = [set() for _ in range(column_count)] - for item in lines: - for value in item.values: - if 0 <= value.column_index < column_count and value.amount is not None: - columns[value.column_index].add(item.line_code) - 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] - parents = {code for code in codes if len(code) == _PARENT_LEN} - children: dict[str, list[str]] = {} - last_parent: str | None = None - for code in codes: # порядок строк документа - if len(code) == _PARENT_LEN: - last_parent = code - continue - if len(code) != _PARENT_LEN + 1: - continue - # M-9: 12310 под 1230 — по порядку строк; без подходящей строки выше — по префиксу (11101 → 1110) - if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]: - parent = last_parent - elif code[:_PARENT_LEN] in parents: - parent = code[:_PARENT_LEN] - else: - continue - children.setdefault(parent, []).append(code) - return [ - RelationRule(form=form, relation=f"{parent} = {' + '.join(kids)}", strict=False) - for parent, kids in children.items() - if low <= int(parent) <= high - ] - - -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] = [] - filled = _filled(lines, column_count) - 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 - # AURORA-1527: детализации нет (все подстроки родителя в колонке пусты) — соотношение не проверяется - if is_subline and not any(code in filled[column_index] for _, code in rule.terms): - 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) - # база — наибольшее по модулю число соотношения: итог может быть малой разностью больших слагаемых - scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)]) - rounding = scale * config.rounding_tolerance_percent / _HUNDRED - if rule.strict and abs(expected - actual) <= rounding: - soft.append(failed) - warnings.append( - f"соотношение «{rule.relation}» сошлось в пределах округления (колонка {column_index}): " - f"{expected} ≈ {actual}" - ) - continue - 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 deleted file mode 100644 index 69b07c8..0000000 --- a/api/src/parser/statements/columns.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Заголовок таблицы формы, колонка кодов, колонка наименований, колонки-периоды (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 _short_value_columns( # noqa: PLR0913, PLR0917 - сигнатура как у _name_column - sheet: Sheet, rules: Rules, code_col: int, name_col: int | None, first_row: int, last_row: int -) -> tuple[int, ...]: - """N-4: без шапки «Пояснения» узнаются по содержимому — левее кодов, не колонка названий, в строках с кодами только - короткие значения («5.1», «2»).""" - short: dict[int, bool] = {} - 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 and col != name_col: - short[col] = short.get(col, True) and len(cell.text) <= _SHORT_TEXT - return tuple(col for col, is_short in sorted(short.items()) if is_short) - - -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) - notes = _short_value_columns(sheet, rules, code_col, name_col, first_row, last_row) - return TableLayout(None, None, code_col, name_col, periods, (), first_row, notes) - 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 deleted file mode 100644 index d91c488..0000000 --- a/api/src/parser/statements/detect.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Блоки форм на листе и вид листа форм (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/forms.py b/api/src/parser/statements/forms.py new file mode 100644 index 0000000..6ac88c9 --- /dev/null +++ b/api/src/parser/statements/forms.py @@ -0,0 +1,223 @@ +"""Блоки форм ББ/ОФР на листе и вид листа на словарях `arm_statements` (план 4, задача 4; design D-2, D-3). + +Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были +строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская +отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или +столбцом кодов — продолжение. Тексты под шаблоны словаря нормализует пакет: метки — `normalize_label`, подпись «Код» +и относительные периоды — `normalize_caption`. +""" + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Literal + +from arm_statements import ( + Cell, + DocumentType, + amount_columns, + code_column, + is_continuation, + normalize_caption, + normalize_label, + parse_code, + parse_date, + type_hint, +) +from arm_statements import Rules as StatementRules +from src.parser.grid import Sheet +from src.parser.statements.cells import excel_cell, grid_rows + +__all__ = [ + "FormBlock", + "StatementRules", + "StatementSheetKind", + "caption_matches", + "classify_statement_sheet", + "code_rows", + "codes_of", + "continuation_marker", + "find_blocks", + "is_weak_continuation", + "matches", + "row_cells", + "sheet_amount_columns", + "sheet_code_column", + "sheet_grid", +] + +StatementSheetKind = Literal["statement", "continuation", "other"] + +_WEAK_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) + + +@dataclass(frozen=True) +class FormBlock: + """Блок формы: `title`/`okud` — `(row, col)` ячеек-признаков.""" + + type_hint: DocumentType + title: tuple[int, int] | None + okud: tuple[int, int] | None + first_row: int + last_row: int + + +def matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool: + """`search` по тексту, нормализованному как метки шапки (`normalize_label`).""" + label = normalize_label(text) + return any(pattern.search(label) for pattern in patterns) + + +def caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool: + """`search` по тексту, нормализованному как подписи колонок (`normalize_caption`).""" + caption = normalize_caption(text) + return any(pattern.search(caption) for pattern in patterns) + + +def row_cells(sheet: Sheet, row: int) -> list[tuple[int, Cell]]: + """Непустые ячейки строки `(col, cell)` по возрастанию col; покрытые объединением позиции пропускаются.""" + found: list[tuple[int, Cell]] = [] + for col, _ in sheet.row_cells(row): + cell = excel_cell(sheet, row, col, set()) + if cell is not None: + found.append((col, cell)) + return found + + +def sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]: + """Сетка строк `first_row..last_row` на всю ширину листа; неразобранные форматы здесь не собираются.""" + return grid_rows(sheet, first_row, last_row, sheet.n_cols, set()) + + +def sheet_code_column( + sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType | None = None +) -> int | None: + """Столбец кодов (1-based) по правилу шага 2 пакета; `relaxed` — ослабленный поиск в отрезке формы.""" + found = code_column(sheet_grid(sheet, first_row, last_row), rules, relaxed=relaxed) + return None if found is None else found + 1 + + +def code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]: + """Строки, где ячейка столбца `col` — код строки формы.""" + found: list[int] = [] + for row in range(first_row, last_row + 1): + cell = excel_cell(sheet, row, col, set()) + if cell is not None and parse_code(cell.text, rules).code is not None: + found.append(row) + return found + + +def codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]: + found: list[str] = [] + for row in range(first_row, last_row + 1): + cell = excel_cell(sheet, row, col, set()) + code = None if cell is None else parse_code(cell.text, rules).code + if code is not None: + found.append(code) + return found + + +def sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int: + """Число столбцов правее столбца кодов, где в строке с кодом есть число (как колонки сегмента без заголовка).""" + return len(amount_columns(sheet_grid(sheet, first_row, last_row), code_col - 1, rules)) + + +@dataclass(frozen=True) +class _Candidate: + row: int + title: tuple[int, int, str] | None + okud: tuple[int, int, str] | None + + +def _candidates(sheet: Sheet, rules: StatementRules) -> list[_Candidate]: + found: list[_Candidate] = [] + for row in range(1, sheet.n_rows + 1): + title: tuple[int, int, str] | None = None + okud: tuple[int, int, str] | None = None + cells = row_cells(sheet, row) + # строка-шапка таблицы (есть подпись «Код»): «Пояснения» и т. п. — подписи колонок, не заголовок формы + is_table_header = any(caption_matches([rules.headers.code_caption], cell.text) for _, cell in cells) + for col, cell in cells: + if title is None and not is_table_header and type_hint(None, cell.text, rules) is not None: + title = (row, col, cell.text) + # маркер продолжения («Форма 0710001 с.2») — не заголовок нового блока по ОКУД + if okud is None and not is_continuation(cell.text, rules) and type_hint(cell.text, None, rules) is not None: + okud = (row, col, cell.text) + if title is not None or okud is not None: + found.append(_Candidate(row, title, okud)) + return found + + +def find_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]: + """Блоки форм сверху вниз; тип блока — по ОКУД, иначе по заголовку.""" + candidates = _candidates(sheet, rules) + if not candidates: + return [] + col = sheet_code_column(sheet, rules, 1, sheet.n_rows) + with_codes = code_rows(sheet, rules, col, 1, sheet.n_rows) if col is not None else [] + groups: list[list[_Candidate]] = [] + for candidate in candidates: + if groups and not any(groups[-1][0].row < row < candidate.row for row in with_codes): + groups[-1].append(candidate) + else: + groups.append([candidate]) + blocks: list[FormBlock] = [] + for index, group in enumerate(groups): + title = next((item.title for item in group if item.title is not None), None) + okud = next((item.okud for item in group if item.okud is not None), None) + kind = type_hint(None if okud is None else okud[2], None if title is None else title[2], rules) + if kind is None: # группа всегда несёт заголовок или ОКУД с типом; защита от пустой группы + continue + is_last = index == len(groups) - 1 + blocks.append( + FormBlock( + type_hint=kind, + title=None if title is None else (title[0], title[1]), + okud=None if okud is None else (okud[0], okud[1]), + first_row=1 if index == 0 else group[0].row, + last_row=sheet.n_rows if is_last else groups[index + 1][0].row - 1, + ) + ) + return blocks + + +def continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None: + """Первая ячейка-маркер продолжения («Форма 0710001 с.2»); маркер с ОКУД вне словаря — тоже маркер.""" + for row in range(1, sheet.n_rows + 1): + for _, cell in row_cells(sheet, row): + if is_continuation(cell.text, rules): + return cell + return None + + +def _has_table_header(sheet: Sheet, rules: StatementRules) -> bool: + relative = [pattern for _, pattern in rules.periods.relative] + for row in range(1, sheet.n_rows + 1): + cells = row_cells(sheet, row) + caption = next((col for col, cell in cells if caption_matches([rules.headers.code_caption], cell.text)), None) + if caption is None: + continue + for col, cell in cells: + if col > caption and (parse_date(cell.text, rules) is not None or caption_matches(relative, cell.text)): + return True + return False + + +def classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind: + if find_blocks(sheet, rules): + return "statement" + if ( + continuation_marker(sheet, rules) is not None + or _has_table_header(sheet, rules) + or sheet_code_column(sheet, rules, 1, sheet.n_rows) is not None + ): + return "continuation" + return "other" + + +def is_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool: + """Лист с кодами отрезка формы, но меньше `min_codes` (одна строка-продолжение). Годится лишь после листа формы: + решает вызывающий; первая страница формы с таким числом кодов формой не считается.""" + if form_type not in _WEAK_TYPES: + return False + return sheet_code_column(sheet, rules, 1, sheet.n_rows, relaxed=form_type) is not None diff --git a/api/src/parser/statements/header.py b/api/src/parser/statements/header.py deleted file mode 100644 index 70579cc..0000000 --- a/api/src/parser/statements/header.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Шапка формы по меткам словаря (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/header_cells.py b/api/src/parser/statements/header_cells.py new file mode 100644 index 0000000..8072a92 --- /dev/null +++ b/api/src/parser/statements/header_cells.py @@ -0,0 +1,160 @@ +"""Шапка формы по меткам словаря пакета: ячейки-значения `Cell` для `arm_statements.parse_header` (план 4, задача 4). + +Значение — правее метки в той же строке, иначе в следующей. Особые правила: ИНН ниже метки «аудитор» не берётся; +организация — после «Организация», а при пустом значении — ближайшая строка выше с признаком ОПФ (НПЗ); дата — из +подзаголовка формы либо из тройки «число | месяц | год»; единицы — ячейка как в файле. Разбор значений — в пакете. +""" + +import re + +from arm_statements import Cell, Evidence, HeaderCells, parse_date +from arm_statements import Rules as StatementRules +from src.parser.grid import Sheet +from src.parser.statements.cells import excel_cell +from src.parser.statements.forms import FormBlock, matches + +__all__ = ["header_cells"] + +_INN_LENGTHS = (10, 12) +_NOT_DIGIT = re.compile(r"\D") +_DIGITS = re.compile(r"\d+") +_FULL_CONFIDENCE = 100.0 +_TRIPLE = 3 # число, месяц, год + +_Line = list[tuple[int, Cell]] + + +class _Header: + def __init__(self, sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, formats: set[str]) -> None: + self.rules = rules + self.first_row = first_row + self.last_row = last_row + self.lines: dict[int, _Line] = {} + for row in range(first_row, last_row + 1): + line: _Line = [] + for col, _ in sheet.row_cells(row): + cell = excel_cell(sheet, row, col, formats) + if cell is not None: + line.append((col, cell)) + self.lines[row] = line + self.auditor_from = self._auditor_from() + + def _auditor_from(self) -> int: + """Первая строка блока аудитора; ниже неё ИНН/названия — не организации.""" + patterns = self.rules.headers.auditor + rows = [row for row, line in self.lines.items() if any(matches(patterns, cell.text) for _, cell in line)] + return min(rows) if rows else self.last_row + 1 + + def labels(self, key: str, last_row: int | None = None) -> list[tuple[int, int]]: + patterns = self.rules.headers.labels.get(key, ()) + stop = self.last_row if last_row is None else last_row + return [ + (row, col) + for row, line in self.lines.items() + if row <= stop + for col, cell in line + if matches(patterns, cell.text) + ] + + def right(self, row: int, col: int) -> _Line: + return [(c, cell) for c, cell in self.lines.get(row, []) if c > col] + + def right_or_below(self, row: int, col: int) -> Cell | None: + right = self.right(row, col) + if right: + return right[0][1] + below = self.lines.get(row + 1, []) + return below[0][1] if below else None + + def labelled(self, key: str, last_row: int | None = None) -> Cell | None: + for row, col in self.labels(key, last_row): + value = self.right_or_below(row, col) + if value is not None: + return value + return None + + def organization(self) -> Cell | None: + legal_form = self.rules.headers.legal_form + okpo = self.rules.headers.labels.get("okpo", ()) + for row, col in self.labels("organization", self.auditor_from - 1): + value = self.right_or_below(row, col) + if value is not None and not matches(okpo, value.text) and not value.text.isdigit(): + return value + for above in range(row - 1, self.first_row - 1, -1): + found = next((cell for _, cell in self.lines[above] if matches(legal_form, cell.text)), None) + if found is not None: + return found + return None + + def inn(self) -> Cell | None: + for row, col in self.labels("inn", self.auditor_from - 1): + for _, cell in self.right(row, col): + if len(_NOT_DIGIT.sub("", cell.text)) in _INN_LENGTHS: + return cell + return None + + def okopf_okfs(self) -> tuple[Cell | None, Cell | None]: + """«по ОКОПФ / ОКФС» с двумя числами правее — в той же строке или строкой выше (у 1С коды стоят выше).""" + for row, col in self.labels("okopf_okfs"): + for candidate in (row, row - 1): + numbers = [cell for _, cell in self.right(candidate, col) if _DIGITS.fullmatch(cell.text)] + if numbers: + return numbers[0], numbers[1] if len(numbers) > 1 else None + return self.labelled("okopf"), self.labelled("okfs") + + def units(self) -> Cell | None: + okei = self.rules.headers.labels.get("okei", ()) + for row, col in self.labels("units"): + for _, cell in self.right(row, col): + if not matches(okei, cell.text) and not cell.text.isdigit(): + return cell + return None + + def report_date(self, title_row: int | None) -> Cell | None: + if title_row is not None: + # Подзаголовок — только строка сразу под заголовком формы («на 31 декабря 2024 г.»); глубже лежат + # «Форма по ОКУД» и тройка даты; голое число («2025» из тройки) подзаголовком не считается + for _, cell in self.lines.get(title_row + 1, []): + if not cell.text.isdigit() and parse_date(cell.text, self.rules) is not None: + return cell + for row, col in self.labels("date"): + parts = self.right(row, col) + if len(parts) >= _TRIPLE: + found = self._triple(parts[0][1], parts[1][1], parts[2][1]) + if found is not None: + return found + return None + + def _triple(self, day: Cell, month: Cell, year: Cell) -> Cell | None: + if not day.text.isdigit() or not year.text.isdigit(): + return None + if month.text.isdigit(): + text = f"{day.text.zfill(2)}.{month.text.zfill(2)}.{year.text}" + else: + text = f"{day.text} {month.text} {year.text}" + if parse_date(text, self.rules) is None: + return None + return Cell(text=text, value=None, confidence=_FULL_CONFIDENCE, ref=day.ref) + + +def header_cells( + sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str] +) -> tuple[HeaderCells, Evidence]: + """Ячейки шапки блока в строках `block.first_row..last_row` и признаки формы (`Evidence`).""" + header = _Header(sheet, rules, block.first_row, last_row, unparsed_formats) + okopf, okfs = header.okopf_okfs() + title_cell = None if block.title is None else excel_cell(sheet, *block.title, unparsed_formats) + okud_cell = None if block.okud is None else excel_cell(sheet, *block.okud, unparsed_formats) + cells = HeaderCells( + organization_name=header.organization(), + inn=header.inn(), + report_date=header.report_date(None if block.title is None else block.title[0]), + units=header.units(), + okud=okud_cell, + okpo=header.labelled("okpo"), + okopf=okopf, + okfs=okfs, + okei=header.labelled("okei"), + address=header.labelled("address", min(last_row, header.auditor_from - 1)), + ) + return cells, Evidence(okud=okud_cell, title=title_cell) diff --git a/api/src/parser/statements/lines.py b/api/src/parser/statements/lines.py deleted file mode 100644 index 4a0ef2d..0000000 --- a/api/src/parser/statements/lines.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Строки формы с кодом и их значения (design D-6). - -Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них -есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — -`statement_money`: показ по числовому формату ячейки (#42), иначе `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 Cell, Sheet -from src.parser.numbers import MoneyParse, to_money -from src.parser.rules import Rules -from src.parser.statements.columns import TableLayout, cell_label, is_numbering_row, line_code -from src.parser.statements.number_format import display_amount - -__all__ = ["LinesResult", "collect_lines", "statement_money"] - -_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 - unparsed_formats: set[str] = field(default_factory=set) # числовые форматы, которые не удалось разобрать (#42) - warnings: list[str] = field(default_factory=list) - - -def statement_money(cell: Cell, unparsed_formats: set[str] | None = None) -> MoneyParse: - """Значение ячейки ББ/ОФР — как его показывает документ (#42): число с числовым форматом проходит через - `display_amount`; формат не разобран — сырое значение, формат попадает в `unparsed_formats`.""" - if cell.kind == "number" and cell.number_format and isinstance(cell.value, Decimal): - shown = display_amount(cell.value, cell.number_format) - if shown.kind == "value": - return MoneyParse(shown.value, None) - if shown.kind == "unparsed" and unparsed_formats is not None: - unparsed_formats.add(cell.number_format) - return to_money(cell) - - -def _value( - sheet: Sheet, row: int, col: int, column_index: int, unparsed_formats: set[str] -) -> tuple[StatementValue, bool]: - """Значение ячейки и признак «число с дробной частью».""" - cell = sheet.cell(*sheet.merged_origin(row, col)) - parsed = statement_money(cell, unparsed_formats) - 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 - row_values = ( - statement_money(sheet.cell(row, column.col), result.unparsed_formats).value for column in layout.periods - ) - if any(_is_sum(value) for value in row_values): - 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, result.unparsed_formats) - 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 deleted file mode 100644 index 9ae6d4d..0000000 --- a/api/src/parser/statements/merge.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Черновик документа формы и присоединение листов-продолжений (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 - unparsed_formats: set[str] = field(default_factory=set) - 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/number_format.py b/api/src/parser/statements/number_format.py index fe1367d..80c18b3 100644 --- a/api/src/parser/statements/number_format.py +++ b/api/src/parser/statements/number_format.py @@ -27,9 +27,9 @@ from functools import lru_cache from typing import Literal, NamedTuple from src.parser.grid import Cell -from src.parser.numbers import quantize_money, to_money +from src.parser.numbers import to_money -__all__ = ["FormatResult", "display_amount"] +__all__ = ["FormatResult", "display_amount", "general_text"] _PLACEHOLDERS = "0#?" _MAX_PARTS = 4 @@ -43,16 +43,16 @@ _GENERAL = "general" class FormatResult(NamedTuple): - """`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения); - `unparsed` — формат не разобран.""" + """`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения) и его + текст `text`, как показывает Excel; `unparsed` — формат не разобран.""" kind: Literal["raw", "value", "unparsed"] value: Decimal | None = None + text: str | None = None _RAW = FormatResult("raw") _UNPARSED = FormatResult("unparsed") -_NO_VALUE = FormatResult("value", None) _EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр @@ -99,6 +99,9 @@ class _Section: literal: str # показываемый текст (для секций без цифровых плейсхолдеров) scale: int # число масштабирующих запятых decimals: int # число плейсхолдеров после точки + grouping: bool = False # есть разделитель групп разрядов + prefix: str = "" # литералы до первого плейсхолдера + suffix: str = "" # литералы после первого плейсхолдера def _split_sections(fmt: str) -> list[str] | None: @@ -130,16 +133,24 @@ def _split_sections(fmt: str) -> list[str] | None: return sections +def _put(chars: str, literal: list[str], prefix: list[str], suffix: list[str], kinds: list[str]) -> None: + """Литерал секции: общий текст и его положение относительно первого цифрового плейсхолдера.""" + literal.append(chars) + (suffix if "p" in kinds else prefix).append(chars) + + def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - линейный разбор символов формата condition: _Condition | None = None literal: list[str] = [] + prefix: list[str] = [] + suffix: list[str] = [] kinds: list[str] = [] # "p" — плейсхолдер, "," — запятая, "." — точка, прочее не фиксируем index = 0 while index < len(text): char = text[index] if char == '"': end = text.index('"', index + 1) - literal.append(text[index + 1 : end]) + _put(text[index + 1 : end], literal, prefix, suffix, kinds) index = end + 1 continue if char == "[": @@ -155,7 +166,7 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли index = end + 1 # цвет, [$-419] и прочее оформление игнорируются continue if char == "\\": - literal.append(text[index + 1 : index + 2]) + _put(text[index + 1 : index + 2], literal, prefix, suffix, kinds) index += 2 continue if char in "_*": @@ -170,20 +181,23 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли elif char in ",.": kinds.append(char) elif char in _PLAIN_CHARS: - literal.append(char) + _put(char, literal, prefix, suffix, kinds) else: # %, E+, /, @, буквы дат и любые неизвестные знаки return None index += 1 - return _build_section(condition, literal, kinds) + return _build_section(condition, literal, kinds, "".join(prefix), "".join(suffix)) -def _build_section(condition: _Condition | None, literal: list[str], kinds: list[str]) -> _Section | None: +def _build_section( + condition: _Condition | None, literal: list[str], kinds: list[str], prefix: str, suffix: str +) -> _Section | None: if "g" in kinds: return _Section(condition, True, False, "", 0, 0) if kinds == ["g"] else None last = max((i for i, kind in enumerate(kinds) if kind == "p"), default=-1) if last < 0: return _Section(condition, False, False, "".join(literal).strip(), 0, 0) scale = 0 + grouping = False position = 0 while position < len(kinds): if kinds[position] != ",": @@ -195,11 +209,13 @@ def _build_section(condition: _Condition | None, literal: list[str], kinds: list follows_placeholder = position > 0 and kinds[position - 1] == "p" if follows_placeholder and (end == len(kinds) or kinds[end] != "p"): scale += end - position + elif follows_placeholder: + grouping = True position = end decimals = 0 if "." in kinds: decimals = sum(1 for kind in kinds[kinds.index(".") :] if kind == "p") - return _Section(condition, False, True, "", scale, decimals) + return _Section(condition, False, True, "", scale, decimals, grouping, prefix, suffix) @lru_cache(maxsize=256) @@ -239,23 +255,53 @@ def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | No return None -def _scaled(raw: Decimal, section: _Section) -> FormatResult: +_WIDE = Context(prec=60, rounding=ROUND_HALF_UP) # без потери цифр у чисел длиннее 15 знаков; диапазон — дело пакета +_GROUP_SIZE = 3 +_PRECISION_LIMIT = Decimal(10) ** _EXCEL_DIGITS + + +def _group_digits(digits: str) -> str: + head = len(digits) % _GROUP_SIZE or _GROUP_SIZE + parts = [digits[:head], *(digits[i : i + _GROUP_SIZE] for i in range(head, len(digits), _GROUP_SIZE))] + return " ".join(parts) + + +def general_text(raw: Decimal) -> str: + """Текст числа без формата: целое без дробной части, иначе без хвостовых нулей; запятая, без групп и экспоненты.""" + if raw == raw.to_integral_value(): + return format(int(raw), "d") + return format(raw.normalize(), "f").replace(".", ",") + + +def _section_text(magnitude: Decimal, raw_negative: bool, section: _Section, sign: bool) -> str: + rounded = magnitude.quantize(Decimal(1).scaleb(-section.decimals), context=_WIDE) + whole, _, fraction = format(rounded, "f").partition(".") + body = _group_digits(whole) if section.grouping else whole + if section.decimals: + body = f"{body},{fraction}" + minus = "-" if raw_negative and sign else "" + return f"{minus}{section.prefix}{body}{section.suffix}" + + +def _scaled(raw: Decimal, section: _Section, sign: bool) -> FormatResult: digits = min(section.decimals, _MAX_DECIMALS) + # 15 значащих цифр, как в Excel, но длинные целые (16+ цифр) показываем без потери цифр + precise = _excel_precision(raw) if abs(raw) < _PRECISION_LIMIT else raw try: - magnitude = abs(_excel_precision(raw)).scaleb(-_SCALE_STEP * section.scale) - rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP) - except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2) + magnitude = abs(precise).scaleb(-_SCALE_STEP * section.scale, context=_WIDE) + rounded = magnitude.quantize(Decimal(1).scaleb(-digits), context=_WIDE) + text = _section_text(magnitude, raw < 0, section, sign) + except InvalidOperation: return _UNPARSED - signed = -rounded if raw < 0 else rounded # знак от сырого значения (R-3) - money = quantize_money(signed, str(raw)) - return _UNPARSED if money.warning is not None else FormatResult("value", money.value) + value = (-rounded if raw < 0 else rounded).quantize(Decimal("0.01"), context=_WIDE) # знак от сырого (R-3) + return FormatResult("value", value, text) def _literal_result(section: _Section) -> FormatResult: parsed = to_money(Cell(section.literal, "text")) if parsed.warning is not None: return _UNPARSED - return _NO_VALUE if parsed.value is None else FormatResult("value", parsed.value) + return FormatResult("value", parsed.value, section.literal) def display_amount(raw: Decimal, fmt: str) -> FormatResult: @@ -268,4 +314,7 @@ def display_amount(raw: Decimal, fmt: str) -> FormatResult: return _RAW if section is None: return _UNPARSED - return _scaled(raw, section) if section.has_digits else _literal_result(section) + if not section.has_digits: + return _literal_result(section) + single = len(sections) == 1 and section.condition is None + return _scaled(raw, section, single) diff --git a/api/src/parser/statements/pipeline.py b/api/src/parser/statements/pipeline.py new file mode 100644 index 0000000..ccf85c4 --- /dev/null +++ b/api/src/parser/statements/pipeline.py @@ -0,0 +1,53 @@ +"""Конвейер форм книги: кандидаты адаптера → шаги 2–8 пакета `arm_statements` → документы контракта 2.0. + +Своего перевода в контракт здесь нет: документ формы — `to_contract` пакета, предупреждение файла — +`warning_to_contract(..., file_level=True)`. Изоляцию кандидатов друг от друга не делаем: исключение пакета — ошибка +книги (`RECOGNITION_FAILED` в `BookParser`). +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from arm_statements import Rules as StatementRules +from arm_statements import StatementWarning, normalize_candidate, to_contract, warning_to_contract +from src.contract import FileWarning, StatementDocument +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.statements.adapter import build_candidates + +__all__ = ["StatementPipeline", "StatementsOutcome"] + + +@dataclass(frozen=True, slots=True) +class StatementsOutcome: + """Документы форм в порядке кандидатов и предупреждения файла (в порядке кандидатов).""" + + documents: tuple[StatementDocument, ...] + file_warnings: tuple[FileWarning, ...] + + +class StatementPipeline: + """Листы форм книги → документы контракта v2.0.""" + + def __init__(self, rules: StatementRules) -> None: + self._rules = rules + + def parse(self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, xlsb: bool) -> StatementsOutcome: + documents: list[StatementDocument] = [] + file_warnings: list[FileWarning] = [] + for candidate in build_candidates(sheets, self._rules, deadline, xlsb=xlsb): + deadline.check(f"форма «{candidate.locator}»") + result = normalize_candidate(candidate, self._rules) + if result.document is not None: + documents.append(StatementDocument.model_validate(to_contract(result.document))) + else: + # Документа нет: предупреждения адаптера (формат xlsb, числовой формат, склейка) пакет не переносит — + # они остаются предупреждениями файла со своим locator + file_warnings.extend(_file_warning(w) for w in candidate.warnings) + file_warnings.extend(_file_warning(w) for w in result.file_warnings) + return StatementsOutcome(tuple(documents), tuple(file_warnings)) + + +def _file_warning(warning: StatementWarning) -> FileWarning: + return FileWarning.model_validate(warning_to_contract(warning, file_level=True)) diff --git a/api/src/parser/statements/statement.py b/api/src/parser/statements/statement.py deleted file mode 100644 index 75357b8..0000000 --- a/api/src/parser/statements/statement.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Конвейер форм (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 FormType, 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", "XLSB_FORMAT_WARNING", "LOCATOR_PREFIX", "LOCATOR_SEPARATOR", "StatementParser"] - -LOCATOR_PREFIX = "sheet:" -LOCATOR_SEPARATOR = "," -CONTINUATION_WARNING = "страница-продолжение без шапки: загрузите форму одним файлом" -SKIPPED_COLUMN_WARNING = "колонка без периода пропущена" -XLSB_FORMAT_WARNING = "формат чисел xlsb не читается: значения как в файле" -MAX_FORMAT_LENGTH = 30 - - -def _shorten(fmt: str) -> str: - return fmt if len(fmt) <= MAX_FORMAT_LENGTH else fmt[: MAX_FORMAT_LENGTH - 1] + "…" - - -def _line_of(draft: Draft, text: str) -> str: - return f"sheet={draft.sheet_names[0]} {text}" - - -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, *, formats_unreadable: bool = False - ) -> tuple[list[StatementDocument], list[str], bool]: - """`formats_unreadable` — книга xlsb: числовые форматы недоступны, каждый документ с колонкой кодов получает - предупреждение «значения как в файле» (R-7).""" - 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 - if formats_unreadable: - for draft in drafts: - if draft.kind != "DISCLOSURE" and draft.has_code_column: - draft.warnings.append(_line_of(draft, XLSB_FORMAT_WARNING)) - 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) - weak_codes = self._sheet_codes(sheet, layout) if weak else [] - if weak: - blocker = self._weak_blocker(open_draft, layout, marker_kind, weak_codes) - if blocker is not None: - minimum = self._rules.statement_headers.min_codes - warnings.append( - _line(sheet, f"лист с кодами форм ({len(weak_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 - open_draft.unparsed_formats |= result.unparsed_formats - warnings.extend(_line(sheet, text) for text in result.warnings) - if weak: # N-1: присоединение по 1–2 строкам видно аналитику - warnings.append( - _line(sheet, f"присоединён к документу как продолжение по {len(weak_codes)} кодовой строке") - ) - 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 _sheet_codes(self, sheet: Sheet, layout: TableLayout) -> list[str]: - cells = (line_code(sheet.cell(row, layout.code_col), self._rules) for row in range(1, sheet.n_rows + 1)) - return [code for code in cells if code is not None] - - def _weak_blocker( - self, open_draft: Draft | None, layout: TableLayout, marker_kind: FormType | None, codes: list[str] - ) -> str | None: - """Почему лист с 1–2 кодами нельзя присоединить (N-1): нет открытого документа, общие причины `join_blocker`, - коды не из диапазона формы открытого документа (лист «Справка» с кодом баланса после ОФР).""" - if open_draft is None: - return "нет открытого документа формы" - blocker = join_blocker(open_draft, layout, marker_kind) - if blocker is not None or open_draft.kind not in self._rules.statement_headers.code_ranges: - return blocker - low, high = self._rules.statement_headers.code_ranges[open_draft.kind] - foreign = [code for code in codes if not low <= int(code[:4]) <= high] - if foreign: - return f"коды {', '.join(foreign)} не из формы {open_draft.kind}" - return None - - 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 - draft.unparsed_formats = result.unparsed_formats - 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._unparsed_formats_warning(draft)) - 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 - ) - - @staticmethod - def _unparsed_formats_warning(draft: Draft) -> list[str]: - """Одно предупреждение на документ (R-7): числовые форматы, которые не удалось разобрать; значения взяты как - в ячейках.""" - if not draft.unparsed_formats: - return [] - shown = ", ".join(f"«{_shorten(fmt)}»" for fmt in sorted(draft.unparsed_formats)) - return [f"sheet={draft.sheet_names[0]} формат чисел не разобран, значения как в ячейках: {shown}"] - - 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/fixtures/test_osv_dkb.py b/api/tests/fixtures/test_osv_dkb.py index 8184bfc..2b86312 100644 --- a/api/tests/fixtures/test_osv_dkb.py +++ b/api/tests/fixtures/test_osv_dkb.py @@ -85,9 +85,7 @@ def _check_period(directory: str, outcome: ParseOutcome, documents: list[OsvDocu header = document.header assert header.period_year == expected.year, document.locator assert header.period_quarter == expected.quarter, document.locator - has_rolling_warning = any( - ROLLING_WARNING in warning for warning in (outcome.metrics.warnings or []) if outcome.metrics - ) + has_rolling_warning = any(ROLLING_WARNING in warning.message for warning in document.warnings) assert has_rolling_warning is expected.rolling, document.locator if expected.rolling: assert header.is_cumulative is None diff --git a/api/tests/fixtures/test_osv_property.py b/api/tests/fixtures/test_osv_property.py index dac1b3f..428fbcc 100644 --- a/api/tests/fixtures/test_osv_property.py +++ b/api/tests/fixtures/test_osv_property.py @@ -48,7 +48,6 @@ def test_real_osv_parses_done_with_invariants(osv_file: Path) -> None: else: # Пустая ОСВ (R-13): только «Итого» assert [row.group_type for row in document.rows] == ["TOTAL"] - assert outcome.metrics.warnings is not None - assert "нет строк данных" in outcome.metrics.warnings + assert "нет строк данных" in [warning.message for warning in document.warnings] assert seconds < MAX_PARSE_SECONDS, f"{osv_file.name}: {seconds:.2f} с" assert_valid_result(outcome) diff --git a/api/tests/fixtures/test_readers_real.py b/api/tests/fixtures/test_readers_real.py index e65f0c5..b4c9c94 100644 --- a/api/tests/fixtures/test_readers_real.py +++ b/api/tests/fixtures/test_readers_real.py @@ -7,7 +7,7 @@ from typing import cast import pytest from src.contract import FileType -from src.parser.readers import read_book +from src.parser.readers import read_book, read_book_detected READ_SECONDS_LIMIT = 5.0 NAMED_XLS = "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls" @@ -43,7 +43,7 @@ def test_named_xls_outline_level(fixtures_dir: Path) -> None: @pytest.mark.fixtures def test_biff8_content_with_xlsx_file_type_is_read_as_xls(fixtures_dir: Path) -> None: - sheets, warnings = read_book((fixtures_dir / NAMED_XLS).read_bytes(), "XLSX") + read = read_book_detected((fixtures_dir / NAMED_XLS).read_bytes(), "XLSX") - assert warnings == ["file_type=XLSX, но содержимое BIFF8: прочитано как XLS"] - assert sheets[0].outline(NAMED_XLS_ROW) == NAMED_XLS_OUTLINE + assert read.mismatch == "file_type=XLSX, но содержимое BIFF8: прочитано как XLS" + assert read.sheets[0].outline(NAMED_XLS_ROW) == NAMED_XLS_OUTLINE diff --git a/api/tests/fixtures/test_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py index b200bf5..05655db 100644 --- a/api/tests/fixtures/test_rules_coverage.py +++ b/api/tests/fixtures/test_rules_coverage.py @@ -1,11 +1,11 @@ """Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`. -Запись — один `Pattern` словаря (`sheet_markers`, `form_titles`/`okud` форм, `groups`, `debit`, `credit`, -`service_columns`, +Запись — один `Pattern` словаря ОСВ (`sheet_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных, -`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи -находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически. +`currency_aliases`). Словари форм ББ/ОФР покрываются тестами пакета `arm_statements`, не этим. +Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи находятся обходом +моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически. Файлы — реальные ОСВ `osv/**`, пакет ДКБ `osv-dkb/**` (диапазон месяцев, «Итого развернутое», шапка без меток) и синтетика. @@ -26,11 +26,12 @@ from fixtures_helpers import ( ) from rules_coverage import DICTIONARIES, PATTERNLESS, MatchCounter, iter_patterns, uncovered +from arm_statements import default_rules 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 +from src.parser.statements.pipeline import StatementPipeline pytestmark = pytest.mark.fixtures @@ -44,10 +45,12 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]: rules = load_rules(DEFAULT_RULES_DIR) base = get_parser() assert isinstance(base, BookParser) + statement_rules = default_rules() parser = BookParser( rules, osv=OsvParser(rules), - statements=StatementParser(rules), + statements=StatementPipeline(statement_rules), + statement_rules=statement_rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds, ) @@ -77,7 +80,7 @@ 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)) diff --git a/api/tests/fixtures/test_statements_adapter_real.py b/api/tests/fixtures/test_statements_adapter_real.py new file mode 100644 index 0000000..97f61e0 --- /dev/null +++ b/api/tests/fixtures/test_statements_adapter_real.py @@ -0,0 +1,43 @@ +"""Адаптер книги -> `FormCandidate` на реальных выгрузках (план 4, задача 5).""" + +from pathlib import Path + +import pytest + +from arm_statements import DocumentType, default_rules +from src.parser.deadline import Deadline +from src.parser.detect import is_osv_sheet +from src.parser.readers import read_book +from src.parser.rules import load_rules +from src.parser.statements.adapter import build_candidates, plan_sheets + +pytestmark = pytest.mark.fixtures +STATEMENTS = "statements-xlsx" +NPZ = "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" +UNAGRANDE_BALANCE = "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls" + + +def _candidates(path: Path): # noqa: ANN202 + sheets, _ = read_book(path.read_bytes(), path.suffix.lstrip(".").upper()) # type: ignore[arg-type] + osv_rules = load_rules() + rules = default_rules() + deadline = Deadline(600.0) + plan = plan_sheets(sheets, lambda sheet: is_osv_sheet(sheet, osv_rules), rules, deadline) + return build_candidates(plan.statements, rules, deadline, xlsb=False) + + +def test_npz_candidates(fixtures_dir: Path) -> None: + candidates = _candidates(fixtures_dir / STATEMENTS / NPZ) + assert [(c.type_hint, c.locator.count(",") + 1, len(c.segment_breaks)) for c in candidates] == [ + (DocumentType.BALANCE, 5, 4), + (DocumentType.INCOME, 3, 2), + ] + codes = {w.code for c in candidates for w in c.warnings} + assert not codes & {"SHEET_NOT_JOINED", "CONTINUATION_TYPE_CONFLICT"} + + +def test_unagrande_title_block_header(fixtures_dir: Path) -> None: + candidates = _candidates(fixtures_dir / STATEMENTS / UNAGRANDE_BALANCE) + header = candidates[0].header + assert header.okopf is not None and header.okopf.text == "12267" + assert header.inn is not None and "7728757141" in header.inn.text diff --git a/api/tests/fixtures/test_statements_property.py b/api/tests/fixtures/test_statements_property.py index f82553f..7dad1c8 100644 --- a/api/tests/fixtures/test_statements_property.py +++ b/api/tests/fixtures/test_statements_property.py @@ -17,29 +17,20 @@ 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 видна в результате: нарушенное соотношение, дубликаты кодов, нет колонок или шапки.""" + """Причина needs_check видна в результате: предупреждение error, нарушенное соотношение, пометка проверки + у строки, значения или шапки, нечитаемая или пропущенная строка.""" 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) + any(warning.severity == "error" for warning in document.warnings) + or (checks is not None and checks.relations_failed) + or document.header.needs_review_fields + or any( + line.needs_review + or line.line_status in ("unreadable", "missing") + or any(value.needs_review for value in line.values) + for line in document.lines + ) ) @@ -61,23 +52,26 @@ def test_statement_file_matches_manifest(fixtures_dir: Path, statement_file: Pat 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) + # повторы кодов строк законны (v2.0); ссылки на строки уникальны, значения ссылаются на колонки документа + refs = [line.ref for line in document.lines] + assert len(refs) == len(set(refs)), "ref уникальны" + column_indexes = {column.column_index for column in document.columns} + assert all(value.column_index in column_indexes 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.error_warnings is not None: + errors = {warning.code for warning in document.warnings if warning.severity == "error"} + assert errors == set(item.error_warnings) 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) + assert document.status == item.status, (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 diff --git a/api/tests/fixtures/test_statements_real.py b/api/tests/fixtures/test_statements_real.py new file mode 100644 index 0000000..3cc735f --- /dev/null +++ b/api/tests/fixtures/test_statements_real.py @@ -0,0 +1,66 @@ +"""Review Focus плана 4 на реальных книгах `../fixtures/statements-xlsx/**` (спека «Манифест ожиданий...»).""" + +from pathlib import Path +from typing import cast + +import pytest + +from src.contract import FileType, StatementDocument +from src.parser import get_parser + +pytestmark = pytest.mark.fixtures +STATEMENTS = "statements-xlsx" +TITLE_BLOCK_FILES = [ + "unagrande-co/2024-FY/Б.Б Лист 1.xls", + "unagrande-co/2024-FY/ОФР. Лист 1.xls", + "unagrande-group/2024-FY/ББ. Лист 1 .xls", + "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls", + "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls", +] +NPZ = "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx" +MEZHENINOVSKAYA = [ + "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx", + "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx", +] + + +def statement_docs(fixtures_dir: Path, key: str) -> list[StatementDocument]: + path = fixtures_dir / STATEMENTS / key + outcome = get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper())) + assert outcome.error is None, outcome.error + return [document for document in outcome.documents if isinstance(document, StatementDocument)] + + +def test_unagrande_title_codes_block(fixtures_dir: Path) -> None: + """Review Focus 1: ОКОПФ из блока кодов шапки не становится строкой формы.""" + for key in TITLE_BLOCK_FILES: + for doc in statement_docs(fixtures_dir, key): + assert doc.header.okopf == "12267", key + assert not any("12267" in (line.line_code_text or "") for line in doc.lines), key + + +def test_npz_documents(fixtures_dir: Path) -> None: + """Review Focus 2; спека «НПЗ «Реализация иного имущества»».""" + balance, income = statement_docs(fixtures_dir, NPZ) + assert (balance.status, income.status) == ("done", "done") + bad = {"SHEET_NOT_JOINED", "SEGMENT_COLUMNS_MISMATCH", "SEGMENT_WITHOUT_CODES", "HEADER_ROW_NOT_FOUND"} + assert not bad & {w.code for d in (balance, income) for w in d.warnings} + cols = {c.column_index for c in balance.columns} + assert all(v.column_index in cols for line in balance.lines for v in line.values) + income_cols = {c.column_index for c in income.columns} + assert all(v.column_index in income_cols for line in income.lines for v in line.values) + # Строки F34/F44 «Реализация иного имущества…» состоят только из прочерков (0 с форматом `[=0]"-"`, value None): + # по спеке строка без кода и без содержимого — не строка данных, пакет её не выдаёт (R-10; вопрос владельцу O-8.2: + # сценарий спеки «с суммами» не соответствует файлу, где суммы — прочерки). + assert any( + line.line_code == "2400" and line.ref.startswith("'Отчет о финансовых результата_2'") for line in income.lines + ) + + +def test_mezheninovskaya_numbering_and_signatures(fixtures_dir: Path) -> None: + """Review Focus 3: строка нумерации колонок «2» и подписи не становятся строками формы.""" + for key in MEZHENINOVSKAYA: + (doc,) = statement_docs(fixtures_dir, key) + assert doc.status == "done" and doc.header.inn == "7016006635", key + assert not any(line.line_code_text == "2" for line in doc.lines), key + assert not any(line.line_status in ("unreadable", "missing") for line in doc.lines), key diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py index 702df89..7df8aad 100644 --- a/api/tests/golden_helpers.py +++ b/api/tests/golden_helpers.py @@ -20,7 +20,7 @@ import pytest from src.contract import ResultStatus, ServiceInfo from src.core.config import Settings from src.core.worker import build_result -from src.parser import Parser +from src.parser import Parser, require_statements_version from src.parser.cli import STUB_ID, _error_payload, _file_type __all__ = [ @@ -51,7 +51,13 @@ 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")) +VOLATILE: tuple[str | tuple[str, str], ...] = ( + "job_id", + "file_id", + ("service", "version"), + ("service", "statements_version"), + ("metrics", "duration_ms"), +) MISSING = "нет снимка: выполните make golden-update ({path})" MAX_DIFF_CHARS = 6000 @@ -106,7 +112,11 @@ def render(parser: Parser, file: Path) -> dict[str, Any]: if outcome.status is ResultStatus.ERROR: return normalize_result(_error_payload(outcome)) service = ServiceInfo.model_validate( - {"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default} + { + "name": Settings.model_fields["SERVICE_NAME"].default, + "version": Settings.model_fields["VERSION"].default, + "statements_version": require_statements_version(), + } ) result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics) return normalize_result(result.to_wire()) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index e032829..8b170f5 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -10,7 +10,7 @@ Spec tabular-recognition-service: «Ack раньше результата», « обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия», «Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO), -«Реальные формы через воркер» (книга НПЗ → `done`, документы BALANCE и INCOME, `result_schema_version 1.2`). +«Реальные формы через воркер» (книга НПЗ → `done`, документы BALANCE и INCOME, `result_schema_version 2.0`). Изоляция: subject'ы прогона уникальны — контейнер стенда сообщения теста не получает (OQ-83); `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может @@ -304,7 +304,7 @@ async def test_unsupported_schema_version_gives_single_schema_mismatch_without_a async def test_real_osv_through_worker_gives_done_and_valid_result_file( core: Core, uploads: Uploads, fixtures_dir: Path ) -> None: - """«Реальная ОСВ через воркер»: реальный XLS в MinIO → ack → `done`, `result_schema_version 1.1`, файл по схеме.""" + """«Реальная ОСВ через воркер»: реальный XLS в MinIO → ack → `done`, `result_schema_version 2.0`, файл по схеме.""" request = _new_request(key_name="osv_66_2024.xls") request["file_type"] = "XLS" await uploads.put(request, (fixtures_dir / REAL_OSV_66).read_bytes(), XLS_CONTENT_TYPE) @@ -314,7 +314,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.2" + assert body["result_schema_version"] == "2.0" assert body["metrics"]["duration_ms"] >= 0 result_ref = body["result_s3"] assert result_ref["bucket"] == request["s3"]["bucket"] @@ -345,7 +345,7 @@ async def test_real_statements_through_worker_give_done_with_two_documents( 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.2" + assert body["result_schema_version"] == "2.0" result_ref = body["result_s3"] uploads.keys.append((result_ref["bucket"], result_ref["key"])) @@ -353,6 +353,7 @@ async def test_real_statements_through_worker_give_done_with_two_documents( assert schema_errors(stored) == [] assert RecognitionResult.model_validate(stored).to_wire() == stored assert stored["status"] == "done" + assert stored["schema_version"] == "2.0" and stored["service"]["statements_version"] balance, income = stored["documents"] assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"] assert balance["checks"]["relations_failed"] == [] and income["checks"]["relations_failed"] == [] diff --git a/api/tests/result_contract.py b/api/tests/result_contract.py index ba71f83..b1dc953 100644 --- a/api/tests/result_contract.py +++ b/api/tests/result_contract.py @@ -1,4 +1,4 @@ -"""Исход разбора → файл результата контракта v1.1 (как его собирает воркер) и проверка JSON-схемой `api/contracts/`. +"""Исход разбора → файл результата контракта v2.0 (как его собирает воркер) и проверка JSON-схемой `api/contracts/`. Общий помощник юнит-тестов парсера и property-теста на реальных файлах: `RecognitionResult.model_validate` и `recognition-result.schema.json` (Draft 2020-12, `$ref` — локальный реестр без сети) — оба вердикта обязательны. @@ -42,7 +42,7 @@ def result_payload(outcome: ParseOutcome) -> dict[str, Any]: "schema_version": CONTRACT_VERSION, "job_id": uuid.uuid4(), "file_id": uuid.uuid4(), - "service": {"name": "tabular-parser", "version": "test"}, + "service": {"name": "tabular-parser", "version": "test", "statements_version": "test"}, "status": outcome.status, "metrics": outcome.metrics, "documents": list(outcome.documents), @@ -55,7 +55,7 @@ def result_payload(outcome: ParseOutcome) -> dict[str, Any]: def schema_errors(payload: dict[str, Any]) -> list[str]: - """Нарушения JSON-схемы v1.1 (пустой список — файл валиден).""" + """Нарушения JSON-схемы v2.0 (пустой список — файл валиден).""" return [f"{list(error.absolute_path)}: {error.message}" for error in RESULT_VALIDATOR.iter_errors(payload)] diff --git a/api/tests/rules_coverage.py b/api/tests/rules_coverage.py index 192fe3b..f21e6de 100644 --- a/api/tests/rules_coverage.py +++ b/api/tests/rules_coverage.py @@ -20,13 +20,10 @@ DICTIONARIES = { "checks": "osv_checks.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", "osv_checks.yaml"}) +# Словарь без записей распознавания: список счетов проверок ОСВ — коды счетов, а не `Pattern` (в покрытии не участвует). +# Словари форм ББ/ОФР живут в пакете `arm_statements` и его покрытие не проверяет этот тест. +PATTERNLESS = frozenset({"osv_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 index b125199..d384bf0 100644 --- a/api/tests/statement_helpers.py +++ b/api/tests/statement_helpers.py @@ -20,6 +20,7 @@ class DocumentExpectation(BaseModel): reason: str | None = None sheets: int | None = None failed_relations: list[str] | None = None # None — не проверяется; список — точное множество relations_failed + error_warnings: list[str] | None = None # None — не проверяется; список — точное множество кодов уровня error class Expectation(BaseModel): diff --git a/api/tests/statements_manifest.yaml b/api/tests/statements_manifest.yaml index 4338a85..f0ea3b2 100644 --- a/api/tests/statements_manifest.yaml +++ b/api/tests/statements_manifest.yaml @@ -1,66 +1,69 @@ # Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx. # documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится). +# error_warnings — точное множество кодов предупреждений документа уровня error (P4-18); отсутствие поля — не проверяется. # Причина needs_check — данные файла (страница без шапки, шаблон без сумм), не дефект разбора. # Значения сверены прогоном парсера на файлах (R-13): расхождения с первоначальным планом помечены «по факту». # Значения ББ/ОФР — как их показывает числовой формат ячейки (#42): рубли с копейками из 1С приходят в тысячах; # мягкие нарушения (в пределах допуска округления, OQ-138) статус не меняют; набор — по факту разбора, см. golden. +# Страница 2 без шапки: организации и ИНН нет (NO_ORGANIZATION_NO_INN), а 1600 = 1700 не сходится, потому что актив +# на странице 1, а пассив на странице 2 (v2.0: проверка идёт по самой странице). 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"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-co/2024-FY/Б.Б. Лист 2.xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-co/2024-FY/ОФР. Лист 1.xls": organization: 'Акционерное общество "Унагранде Компани"' inn: "3249004697" documents: - - {type: INCOME, status: done, complete: true, failed_relations: []} # значения по формату в тыс. руб.; мягко расходится 2400 = 2300 - 2410 + 2420 (в пределах допуска, OQ-138) + - {type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []} # значения по формату в тыс. руб.; мягко расходится 2400 = 2300 - 2410 + 2420 (в пределах допуска, OQ-138) "unagrande-co/2024-FY/ОФР. Лист 2 .xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2024-FY/ББ. Лист 1 .xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-group/2024-FY/ББ. Лист 2 (2).xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2024-FY/ОФР. Лист 2.xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"]}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив", failed_relations: ["1600 = 1700"], error_warnings: []}] "unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: ["1600 = 1700"], error_warnings: [NO_ORGANIZATION_NO_INN]}] "unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls": organization: 'Акционерное общество "Унагранде Групп"' inn: "7728757141" documents: - - {type: INCOME, status: done, complete: true, failed_relations: []} # мягко расходятся 2400 = 2300 - 2410 + 2420 и 2410 = 2411 + 2412 (в пределах допуска, OQ-138) + - {type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []} # мягко расходятся 2400 = 2300 - 2410 + 2420 и 2410 = 2411 + 2412 (в пределах допуска, OQ-138) "unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN]}] "npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx": organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' inn: "2315995868" inn_optional_for: [INCOME] documents: - - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138) - - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) + - {type: BALANCE, status: done, complete: true, sheets: 5, failed_relations: [], error_warnings: []} # мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (сверка с итогом в самой выгрузке, OQ-138) + - {type: INCOME, status: done, complete: true, sheets: 3, failed_relations: [], error_warnings: []} # мягко расходятся 2400 и 2410 (OQ-138); лист с единственной строкой 2400 присоединён (O-9) "mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx": organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' inn: "7016006635" - documents: [{type: BALANCE, status: done, complete: true, failed_relations: []}] + documents: [{type: BALANCE, status: done, complete: true, failed_relations: [], error_warnings: []}] # колонки сумм M/Q/S; числа правее таблицы формы — NUMBERS_OUTSIDE_TABLE (info, Р-44) "mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx": organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"' inn: "7016006635" - documents: [{type: INCOME, status: done, complete: true, failed_relations: []}] + documents: [{type: INCOME, status: done, complete: true, failed_relations: [], error_warnings: []}] "templates/ББ до 01.01.2025.xlsx": documents: - - {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: []} - - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE"} + - {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN, PERIOD_UNKNOWN, SEGMENT_COLUMNS_MISMATCH]} + - {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE", error_warnings: []} "templates/ББ после 01.01.2025.xlsx": - documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [PERIOD_UNKNOWN]}] "templates/ОФР_до 01.01.2025.xlsx": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [NO_ORGANIZATION_NO_INN, PERIOD_UNKNOWN]}] "templates/ОФР_после 01.01.2025.xlsx": - documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: []}] + documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон", failed_relations: [], error_warnings: [PERIOD_UNKNOWN]}] diff --git a/api/tests/synthetic/generate_statements.py b/api/tests/synthetic/generate_statements.py index 6467ff7..e84690e 100644 --- a/api/tests/synthetic/generate_statements.py +++ b/api/tests/synthetic/generate_statements.py @@ -79,7 +79,7 @@ def _st01() -> list[SheetSpec]: return [sheet] -# Значения st02, согласованные со всеми жёсткими соотношениями ББ (statement_checks.yaml) в обеих колонках: +# Значения st02, согласованные со всеми жёсткими соотношениями ББ (словарь пакета arm_statements) в обеих колонках: # 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), diff --git a/api/tests/synthetic/test_statement_synthetic_files.py b/api/tests/synthetic/test_statement_synthetic_files.py index 69b1b2f..e61e749 100644 --- a/api/tests/synthetic/test_statement_synthetic_files.py +++ b/api/tests/synthetic/test_statement_synthetic_files.py @@ -15,7 +15,6 @@ 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 @@ -68,10 +67,9 @@ def one_statement(outcome: ParseOutcome) -> StatementDocument: 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 codes_of(doc: StatementDocument) -> dict[str, str]: + """Предупреждения документа: код → уровень (контракт 2.0: лежат в самом документе).""" + return {w.code: w.severity for w in doc.warnings} def by_code(doc: StatementDocument) -> dict[str, StatementLine]: @@ -80,12 +78,9 @@ def by_code(doc: StatementDocument) -> dict[str, StatementLine]: 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 get_line_value(line: StatementLine, position: int = 0) -> Decimal | None: + """Значение строки в колонке с порядковым номером `position` (`column_index` — столбец сетки, не порядок).""" + return line.values[position].amount # --- Состав набора и общие ожидания --- @@ -154,8 +149,8 @@ def test_st02_csv_like_format(fixtures_dir: Path) -> None: # 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}: Изменение, %"] + skipped = [w for w in doc.warnings if w.code == "COLUMN_SKIPPED"] + assert [(w.severity, "Изменение, %" in w.message) for w in skipped] == [("info", True)] # Проверяем код 1210 = -50 lines_by_code = by_code(doc) @@ -164,13 +159,10 @@ def test_st02_csv_like_format(fixtures_dir: Path) -> None: # T13.I1: данные согласованы со всеми жёсткими соотношениями — статус done, нарушений нет assert outcome.status is ResultStatus.DONE - assert doc.checks.relations_failed == [] + assert doc.checks is not None and 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}" - ) + assert codes_of(doc).get("UNITS_NOT_FOUND") == "info", doc.warnings def test_st03_no_code_column(fixtures_dir: Path) -> None: @@ -181,10 +173,9 @@ def test_st03_no_code_column(fixtures_dir: Path) -> None: 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}" - ) + assert codes_of(doc).get("CODE_COLUMN_NOT_FOUND") == "error", doc.warnings + assert doc.status == "needs_check" + assert doc.checks is None def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None: @@ -204,17 +195,15 @@ def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None: def test_st05_continuation_page(fixtures_dir: Path) -> None: - """st05: одинокая страница 2; needs_check, warning о continuation.""" + """st05: одинокая страница 2: пустая шапка (`NO_ORGANIZATION_NO_INN`, error) → документ и книга `needs_check`.""" 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}" + assert doc.status == "needs_check" + assert codes_of(doc).get("NO_ORGANIZATION_NO_INN") == "error", doc.warnings + assert doc.header.organization_name is None and doc.header.inn is None # Проверяем что есть коды codes = {line.line_code for line in doc.lines if line.line_code} diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 728c08f..c398702 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -13,7 +13,7 @@ from typing import cast import pytest from result_contract import assert_valid_result -from src.contract import FileType, OsvDocument, ResultStatus +from src.contract import FileType, FileWarning, OsvDocument, ResultStatus from src.contract.result import OsvRow from src.parser import ParseOutcome, get_parser from src.parser.readers.xls import NO_CODEPAGE_WARNING @@ -179,6 +179,12 @@ def one_document(outcome: ParseOutcome) -> OsvDocument: def warnings_of(outcome: ParseOutcome) -> list[str]: + """Тексты предупреждений документов ОСВ (контракт 2.0: лежат в самих документах), по порядку документов.""" + return [w.message for doc in documents(outcome) for w in doc.warnings] + + +def file_warnings_of(outcome: ParseOutcome) -> list[FileWarning]: + """Предупреждения файла (`metrics.warnings`).""" assert outcome.metrics is not None assert outcome.metrics.warnings is not None return outcome.metrics.warnings @@ -236,8 +242,10 @@ def test_header_status_and_hierarchy(synthetic_file: Path) -> None: assert outcome.status is expect.status, outcome.metrics docs = documents(outcome) assert len(docs) == expect.documents - hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w] - assert len(hierarchy_lines) == expect.documents, warnings_of(outcome) + hierarchy_code = f"OSV_HIERARCHY_BY_{expect.hierarchy.upper()}" + hierarchy_notes = [w for doc in docs for w in doc.warnings if w.code == hierarchy_code] + assert len(hierarchy_notes) == expect.documents, warnings_of(outcome) + assert all(w.severity == "info" and w.code.startswith("OSV_") for doc in docs for w in doc.warnings) variants = expect.variants or (expect.variant,) * expect.documents assert [doc.header.header_variant for doc in docs] == list(variants) for doc in docs: @@ -248,6 +256,7 @@ def test_header_status_and_hierarchy(synthetic_file: Path) -> None: assert doc.header.organization_name == expect.organization assert doc.rows[-1].group_type == "TOTAL" if expect.status is ResultStatus.DONE: + assert doc.status == "done" assert doc.checks is not None assert doc.checks.total_row_matches_root is True assert doc.checks.balance_identity_violations == [] @@ -357,8 +366,9 @@ def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None: docs = documents(outcome) assert [doc.locator for doc in docs] == ["sheet:ОСВ 60", "sheet:ОСВ 62"] assert [doc.header.account_code_raw for doc in docs] == ["60", "62"] - assert any("Справка" in warning and "skipped" in warning for warning in warnings_of(outcome)) - assert not any("ОСВ 60" in w and "skipped" in w or "ОСВ 62" in w and "skipped" in w for w in warnings_of(outcome)) + assert [(w.code, w.severity, w.locator) for w in file_warnings_of(outcome)] == [ + ("SHEET_OTHER", "info", "sheet:Справка") + ] assert outcome.metrics is not None assert outcome.metrics.rows == sum(1 for doc in docs for row in doc.rows if row.group_type != "TOTAL") @@ -466,7 +476,6 @@ def test_s12_statement_sheet_goes_to_statement_pipeline(fixtures_dir: Path) -> N ("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 @@ -476,11 +485,14 @@ def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> xlsx = parse_named(fixtures_dir, "s02_no_outline_indent.xlsx") xlsb_doc, xlsx_doc = one_document(xlsb), one_document(xlsx) - assert xlsb_doc.model_copy(update={"locator": ""}) == xlsx_doc.model_copy(update={"locator": ""}) + neutral = {"locator": "", "warnings": []} + assert xlsb_doc.model_copy(update=neutral) == xlsx_doc.model_copy(update=neutral) assert xlsb.status is xlsx.status is ResultStatus.DONE - assert XLSB_WARNING in warnings_of(xlsb) - assert [w for w in warnings_of(xlsb) if w != XLSB_WARNING] == warnings_of(xlsx) - assert any("hierarchy=indent" in w for w in warnings_of(xlsb)) + assert [(w.code, w.message) for w in xlsb_doc.warnings if w.code == "OSV_XLSB_NO_OUTLINE"] == [ + ("OSV_XLSB_NO_OUTLINE", XLSB_WARNING) + ] + assert [w for w in xlsb_doc.warnings if w.code != "OSV_XLSB_NO_OUTLINE"] == xlsx_doc.warnings + assert "OSV_HIERARCHY_BY_INDENT" in {w.code for w in xlsb_doc.warnings} assert xlsb.metrics is not None assert xlsx.metrics is not None assert xlsb.metrics.rows == xlsx.metrics.rows @@ -494,7 +506,6 @@ def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: P assert doc.locator == "sheet:ОСВ 60" 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 @@ -643,7 +654,10 @@ def test_s29_xls_without_codepage_is_read_as_cp1251(fixtures_dir: Path) -> None: assert outcome.status is ResultStatus.DONE assert doc.header.organization_name == ORG assert {ALFA, BETA, GAMMA} <= set(rows_text(doc)) - assert NO_CODEPAGE_WARNING in warnings_of(outcome) + assert [(w.code, w.severity, w.locator, w.message) for w in file_warnings_of(outcome)] == [ + ("XLS_NO_CODEPAGE", "info", None, NO_CODEPAGE_WARNING) + ] + assert "OSV_XLS_NO_CODEPAGE" not in {w.code for w in doc.warnings} def test_s30_val_without_dot_is_a_material_kol_vo_is_quantity(fixtures_dir: Path) -> None: diff --git a/api/tests/unit/parser/statements/test_adapter.py b/api/tests/unit/parser/statements/test_adapter.py new file mode 100644 index 0000000..0f5b82b --- /dev/null +++ b/api/tests/unit/parser/statements/test_adapter.py @@ -0,0 +1,177 @@ +"""Адаптер книги -> `FormCandidate`: план листов, склейка листов-продолжений, предупреждения (план 4, задача 5).""" + +import pytest +from gridbuilder import at, sheet_from_rows, with_formats + +from arm_statements import DocumentType, Severity, Source, default_rules +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind, is_osv_sheet +from src.parser.grid import Sheet +from src.parser.rules import load_rules +from src.parser.statements.adapter import build_candidates, plan_sheets +from src.parser.statements.forms import StatementRules + +NO_DEADLINE = Deadline(10_000.0) +STATEMENT = SheetKind.STATEMENT +CONTINUATION = SheetKind.STATEMENT_CONTINUATION +BALANCE_CODES = (1110, 1150, 1170, 1100) +INCOME_CODES = (2110, 2120, 2400) + + +@pytest.fixture(scope="module") +def rules() -> StatementRules: + return default_rules() + + +def _form(title: str, codes: tuple[int, ...], name: str, *, amounts: int = 3, marker: str | None = None) -> Sheet: + head = [[marker]] if marker else [[title]] + caption = at({1: "Показатель", 3: "Код", **{4 + i: f"На 3{i + 1}.12.2024" for i in range(amounts)}}) + body = [at({1: "Статья", 3: code, **{4 + i: 10 + i for i in range(amounts)}}) for code in codes] + return sheet_from_rows([*head, caption, *body], name=name) + + +def test_balance_with_marked_continuation_and_income(rules: StatementRules) -> None: + balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс") + marked = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2") + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР") + candidates = build_candidates( + [(balance, STATEMENT), (marked, CONTINUATION), (ofr, STATEMENT)], rules, NO_DEADLINE, xlsb=False + ) + assert [(c.type_hint, c.locator) for c in candidates] == [ + (DocumentType.BALANCE, "sheet:Баланс,Баланс (2)"), + (DocumentType.INCOME, "sheet:ОФР"), + ] + assert candidates[0].segment_breaks == (balance.n_rows,) + assert len({len(row) for row in candidates[0].rows}) == 1 + assert candidates[0].source is Source.EXCEL + assert len(candidates[0].rows) == balance.n_rows + marked.n_rows + + +def test_refs_of_cells(rules: StatementRules) -> None: + rows = [at({1: "Бухгалтерский баланс"})] + [[] for _ in range(24)] + rows.append(at({3: "Код", 4: "На 31.12.2024"})) + rows.append(at({1: "Статья", 3: 1110, 4: 1})) + rows += [at({1: "Статья", 3: code, 4: 1}) for code in (1150, 1170, 1100)] + assert rows[26] == at({1: "Статья", 3: 1110, 4: 1}) + for name, ref in (("Баланс", "Баланс!C27"), ("Бухгалтерский баланс. Лист 1", "'Бухгалтерский баланс. Лист 1'!C27")): + [candidate] = build_candidates([(sheet_from_rows(rows, name=name), STATEMENT)], rules, NO_DEADLINE, xlsb=False) + cells = [cell for row in candidate.rows for cell in row if cell is not None and cell.text == "1110"] + assert [cell.ref for cell in cells] == [ref] + + +def test_marker_type_conflict_is_separate_candidate(rules: StatementRules) -> None: + balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс") + other = _form("", INCOME_CODES, "Лист 2", marker="Форма 0710002 с.2") + first, second = build_candidates([(balance, STATEMENT), (other, CONTINUATION)], rules, NO_DEADLINE, xlsb=False) + assert first.warnings == () + assert second.type_hint is DocumentType.INCOME + assert [(w.code, w.severity) for w in second.warnings] == [("CONTINUATION_TYPE_CONFLICT", Severity.ERROR)] + assert second.segment_breaks == () + assert len(second.rows) == other.n_rows + + +def test_single_line_continuation_joins(rules: StatementRules) -> None: + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР") + tail = sheet_from_rows([at({1: "Чистая прибыль", 3: 2400, 4: 1, 5: 2, 6: 3})], name="ОФР (2)") + [candidate] = build_candidates([(ofr, STATEMENT), (tail, CONTINUATION)], rules, NO_DEADLINE, xlsb=False) + assert candidate.segment_breaks == (ofr.n_rows,) + assert candidate.warnings == () + + +def test_wider_sheet_with_three_codes_is_not_joined(rules: StatementRules) -> None: + balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс", amounts=3) + wide = sheet_from_rows( + [at({1: "Статья", 3: code, 4: 1, 5: 2, 6: 3, 7: 4}) for code in (1210, 1230, 1250)], name="Лист 2" + ) + first, second = build_candidates([(balance, STATEMENT), (wide, CONTINUATION)], rules, NO_DEADLINE, xlsb=False) + assert first.segment_breaks == () + assert second.type_hint is None + assert [(w.code, w.severity) for w in second.warnings] == [("SHEET_NOT_JOINED", Severity.ERROR)] + + +def test_lonely_continuation_has_no_warning(rules: StatementRules) -> None: + marked = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2") + [candidate] = build_candidates([(marked, CONTINUATION)], rules, NO_DEADLINE, xlsb=False) + assert candidate.type_hint is DocumentType.BALANCE + assert candidate.warnings == () + assert candidate.header.inn is None + assert len(candidate.rows) == marked.n_rows + + +def test_two_forms_on_one_sheet_share_locator(rules: StatementRules) -> None: + balance = [at({1: "Бухгалтерский баланс"}), at({3: "Код", 4: "На 31.12.2024"})] + balance += [at({1: "Статья", 3: code, 4: 1}) for code in BALANCE_CODES] + income = [at({1: "Отчет о финансовых результатах"}), at({3: "Код", 4: "За 2024"})] + income += [at({1: "Статья", 3: code, 4: 1}) for code in INCOME_CODES] + sheet = sheet_from_rows([*balance, *income], name="Формы") + candidates = build_candidates([(sheet, STATEMENT)], rules, NO_DEADLINE, xlsb=False) + assert [(c.type_hint, c.locator) for c in candidates] == [ + (DocumentType.BALANCE, "sheet:Формы"), + (DocumentType.INCOME, "sheet:Формы"), + ] + assert len(candidates[0].rows) == len(balance) + assert len(candidates[1].rows) == len(income) + + +def test_xlsb_and_unparsed_formats_warnings(rules: StatementRules) -> None: + balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс", amounts=1) + tail = _form("", (1210, 1230, 1250), "Баланс (2)", amounts=1, marker="Форма 0710001 с.2") + formats = {(row, 4): "0.0%" for row in range(3, balance.n_rows + 1)} + balance = with_formats(balance, formats) + tail = with_formats(tail, {(row, 4): "0.0%" for row in range(3, tail.n_rows + 1)}) + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР", amounts=1) + candidates = build_candidates( + [(balance, STATEMENT), (tail, CONTINUATION), (ofr, STATEMENT)], rules, NO_DEADLINE, xlsb=True + ) + codes = [[w.code for w in c.warnings] for c in candidates] + assert codes == [["XLSB_NO_FORMATS", "NUMBER_FORMAT_UNPARSED"], ["XLSB_NO_FORMATS"]] + assert "«0.0%»" in candidates[0].warnings[1].message + assert candidates[0].warnings[1].message.count("0.0%") == 1 + + +def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> None: + ofr = _form("Отчет о финансовых результатах", INCOME_CODES, "ОФР") + weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]], name="2400") + notes = sheet_from_rows([["Примечания"], ["Текст пояснения к отчетности"]], name="Примечания") + osv = sheet_from_rows([["Оборотно-сальдовая ведомость"]], name="ОСВ") + + def is_osv(sheet: Sheet) -> bool: + return sheet.name == "ОСВ" + + plan = plan_sheets([ofr, weak], is_osv, rules, NO_DEADLINE) + assert [(s.name, k) for s, k in plan.statements] == [("ОФР", STATEMENT), ("2400", CONTINUATION)] + plan = plan_sheets([osv, weak], is_osv, rules, NO_DEADLINE) + assert (plan.osv, plan.statements, plan.other) == ((osv,), (), (weak,)) + plan = plan_sheets([ofr, notes, weak], is_osv, rules, NO_DEADLINE) + assert [s.name for s in plan.other] == ["Примечания", "2400"] + + +def test_is_osv_sheet_by_marker() -> None: + rules = load_rules() + osv = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"]]) + assert is_osv_sheet(osv, rules) + assert not is_osv_sheet(sheet_from_rows([["Бухгалтерский баланс"]]), rules) + + +def test_unmarked_page_joins_separate_draft_without_open_form(rules: StatementRules) -> None: + """M-1: черновик продолжения без открытой формы несёт число колонок сумм — стр. 3 без маркера присоединяется.""" + page2 = _form("", (1210, 1230, 1600), "Баланс (2)", marker="Форма 0710001 с.2") + page3 = _form("", (1250, 1260, 1700), "Баланс (3)") + candidates = build_candidates([(page2, CONTINUATION), (page3, CONTINUATION)], rules, NO_DEADLINE, xlsb=False) + [candidate] = candidates + assert candidate.type_hint is DocumentType.BALANCE + assert candidate.segment_breaks == (page2.n_rows,) + assert candidate.warnings == () + + +def test_unmarked_page_joins_draft_after_type_conflict(rules: StatementRules) -> None: + """M-1: то же после CONTINUATION_TYPE_CONFLICT — следующий лист присоединяется к конфликтному черновику.""" + balance = _form("Бухгалтерский баланс", BALANCE_CODES, "Баланс") + conflict = _form("", INCOME_CODES, "Лист 2", marker="Форма 0710002 с.2") + page3 = _form("", (2200, 2300, 2410), "Лист 3") + first, second = build_candidates( + [(balance, STATEMENT), (conflict, CONTINUATION), (page3, CONTINUATION)], rules, NO_DEADLINE, xlsb=False + ) + assert first.warnings == () + assert [w.code for w in second.warnings] == ["CONTINUATION_TYPE_CONFLICT"] + assert second.segment_breaks == (conflict.n_rows,) diff --git a/api/tests/unit/parser/statements/test_cells.py b/api/tests/unit/parser/statements/test_cells.py new file mode 100644 index 0000000..bcca02e --- /dev/null +++ b/api/tests/unit/parser/statements/test_cells.py @@ -0,0 +1,71 @@ +"""Ячейка Excel -> `Cell` пакета: текст по формату, значение, ссылка, сетка (план 4, задача 3).""" + +import datetime +from decimal import Decimal + +from gridbuilder import M, at, sheet_from_rows, with_formats + +from arm_statements import Cell +from src.parser.statements.cells import excel_cell, grid_rows + + +def _balance(row: int, col: int, value: Decimal): # noqa: ANN202 + rows: list[list[object]] = [[] for _ in range(row - 1)] + rows.append(at({col: value})) + return sheet_from_rows(rows, name="Баланс") + + +def test_excel_cell_by_number_format() -> None: + sheet = with_formats(_balance(27, 4, Decimal("106311116.46")), {(27, 4): "#,##0,"}) + cell = excel_cell(sheet, 27, 4, set()) + assert cell == Cell(text="106 311", value=Decimal("106311"), confidence=100.0, ref="Баланс!D27") + + +def test_unparsed_format_keeps_raw_and_is_recorded() -> None: + formats: set[str] = set() + sheet = with_formats(sheet_from_rows([[Decimal("0.15")]]), {(1, 1): "0.0%"}) + cell = excel_cell(sheet, 1, 1, formats) + assert cell is not None + assert (cell.value, cell.text, formats) == (Decimal("0.15"), "0,15", {"0.0%"}) + + +def test_general_and_no_format_give_raw_value() -> None: + cell = excel_cell(sheet_from_rows([[Decimal("7016006635.0")]]), 1, 1, set()) + assert cell is not None + assert (cell.value, cell.text) == (Decimal("7016006635.0"), "7016006635") + + +def test_date_text_bool_and_merged() -> None: + sheet = sheet_from_rows( + [ + [datetime.date(2025, 9, 30), "Итого", True], + [M("Шапка", 2), False], + ] + ) + date_cell = excel_cell(sheet, 1, 1, set()) + assert date_cell is not None + assert (date_cell.text, date_cell.value) == ("30.09.2025", datetime.date(2025, 9, 30)) + text_cell = excel_cell(sheet, 1, 2, set()) + assert text_cell is not None + assert (text_cell.text, text_cell.value) == ("Итого", None) + yes, no = excel_cell(sheet, 1, 3, set()), excel_cell(sheet, 2, 3, set()) + assert yes is not None and no is not None + assert [(yes.text, yes.value), (no.text, no.value)] == [("ИСТИНА", True), ("ЛОЖЬ", False)] + origin = excel_cell(sheet, 2, 1, set()) + assert origin is not None + assert origin.ref == "Лист_1!A2" + assert excel_cell(sheet, 2, 2, set()) is None # не левая верхняя позиция merged + assert excel_cell(sheet, 5, 5, set()) is None # пустая + + +def test_grid_rows_dense_width() -> None: + sheet = sheet_from_rows([["a"], [None, "b", None, Decimal(5)], [], ["z"]]) + width, count = 6, 3 + rows = grid_rows(sheet, 1, count, width, set()) + assert len(rows) == count + assert all(len(row) == width for row in rows) + assert rows[0][0] is not None and rows[0][0].text == "a" + assert rows[1][1] is not None and rows[1][1].ref == "Лист_1!B2" + assert rows[1][3] is not None and rows[1][3].value == Decimal(5) + assert rows[2] == (None,) * 6 + assert rows[0][1:] == (None,) * 5 diff --git a/api/tests/unit/parser/statements/test_checks.py b/api/tests/unit/parser/statements/test_checks.py deleted file mode 100644 index 2a2f7ac..0000000 --- a/api/tests/unit/parser/statements/test_checks.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Соотношения §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") - - -# --- OQ-138 (решение владельца 06.10.2026): расхождение в пределах округления не портит статус --- - - -def test_rounding_within_relative_tolerance_is_soft_not_hard(rules: Rules) -> None: - """Итог 2 871 145 000 при сумме слагаемых 2 871 144 583.67: 416.33 ≤ 0,001 % итога — мягкое нарушение.""" - lines = [line("2110", "9033211892.66"), line("2120", "-6162067308.99"), line("2100", "2871145000")] - - checks, warnings = run_statement_checks(lines, 1, "INCOME", rules) - - assert checks.relations_failed == [] - assert [item.relation for item in checks.soft_relations_failed] == ["2100 = 2110 - 2120"] - assert any("в пределах округления" in warning for warning in warnings) - - -def test_discrepancy_above_relative_tolerance_stays_hard(rules: Rules) -> None: - """0,001 % от 1 000 000 = 10: расхождение 11 — жёсткое нарушение.""" - lines = [ - line("1150", "600000"), - line("1100", "600000"), - line("1210", "400011"), - line("1200", "400011"), - line("1600", "1000000"), - ] - - checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) - - assert "1600 = 1100 + 1200" in [item.relation for item in checks.relations_failed] # 1600 = 1700: пассива нет - - -def test_absolute_tolerance_still_applies_to_small_totals(rules: Rules) -> None: - """У малых итогов действует абсолютный допуск ±1: 0,001 % от 150 меньше единицы.""" - lines = [line("1150", "100"), line("1100", "100"), line("1210", "50"), line("1200", "50"), line("1600", "151")] - - checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) - - relations = [item.relation for item in [*checks.relations_failed, *checks.soft_relations_failed]] - assert "1600 = 1100 + 1200" not in relations # 1600 = 1700 нарушено: пассива нет - - -# --- M-9: подстроки привязываются к ближайшей четырёхзначной строке выше --- - - -def test_sublines_from_tenth_number_belong_to_parent_above(rules: Rules) -> None: - """У НПЗ под 1230 идут 12301…12314: 12310 начинается с «1231», но относится к 1230 по порядку строк.""" - lines = [line("1230", "20"), line("12301", "1"), line("12309", "4"), line("12310", "4"), line("12314", "5")] - - checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) - - assert [item.relation for item in checks.soft_relations_failed] == ["1230 = 12301 + 12309 + 12310 + 12314"] - assert checks.soft_relations_failed[0].actual == Decimal("14") - - -def test_rounding_base_is_the_largest_number_of_the_relation(rules: Rules) -> None: - """ОФР Унагранде Групп 3 кв. 2025: 2200 = −1 365 000 — малая разность больших слагаемых (2220 = −393 623 601.56); - расхождение 398.44 — округление относительно слагаемых (0,0001 %), а не итога (0,03 %).""" - lines = [ - line("2110", "392258982.50"), - line("2100", "392259000"), - line("2220", "-393623601.56"), - line("2200", "-1365000"), - ] - - checks, _ = run_statement_checks(lines, 1, "INCOME", rules) - - assert checks.relations_failed == [] - assert "2200 = 2100 - 2210 - 2220" in [item.relation for item in checks.soft_relations_failed] - - -# --- AURORA-1527: детализации нет — соотношение подстрок не проверяется --- - - -def test_sublines_all_empty_in_column_are_not_checked(rules: Rules) -> None: - lines = [line("1150", "90", "90"), line("11501", None, None), line("11502", None, None)] - checks, warnings = run_statement_checks(lines, 2, "BALANCE", rules) - assert [f for f in checks.soft_relations_failed if f.relation.startswith("1150 =")] == [] - assert not any("1150 =" in w for w in warnings) - - -def test_sublines_check_is_per_column(rules: Rules) -> None: - lines = [line("1150", "90", "90"), line("11501", None, "10"), line("11502", None, None)] - checks, _ = run_statement_checks(lines, 2, "BALANCE", rules) - failed = [f for f in checks.soft_relations_failed if f.relation == "1150 = 11501 + 11502"] - assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, Decimal("90"), Decimal("10"))] - - -def test_subline_zero_is_a_value(rules: Rules) -> None: - lines = [line("1150", "90"), line("11501", "0"), line("11502", None)] - checks, _ = run_statement_checks(lines, 1, "BALANCE", rules) - assert [f.relation for f in checks.soft_relations_failed if f.relation.startswith("1150 =")] == [ - "1150 = 11501 + 11502" - ] diff --git a/api/tests/unit/parser/statements/test_columns.py b/api/tests/unit/parser/statements/test_columns.py deleted file mode 100644 index edd0f99..0000000 --- a/api/tests/unit/parser/statements/test_columns.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Заголовок таблицы формы, колонка кодов, наименований и периодов (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 deleted file mode 100644 index d9c4c4e..0000000 --- a/api/tests/unit/parser/statements/test_detect.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Блоки форм на листе, тип формы, лист-продолжение (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_forms.py b/api/tests/unit/parser/statements/test_forms.py new file mode 100644 index 0000000..36d292d --- /dev/null +++ b/api/tests/unit/parser/statements/test_forms.py @@ -0,0 +1,141 @@ +"""Блоки форм, вид листа и столбцы на словарях `arm_statements` (план 4, задача 4).""" + +import pytest +from gridbuilder import at, sheet_from_rows + +from arm_statements import DocumentType, continuation_type, default_rules +from src.parser.statements.forms import ( + StatementRules, + caption_matches, + classify_statement_sheet, + codes_of, + continuation_marker, + find_blocks, + is_weak_continuation, + matches, + sheet_amount_columns, + sheet_code_column, +) + +CODE_COL = 3 + + +@pytest.fixture(scope="module") +def rules() -> StatementRules: + return default_rules() + + +def test_title_and_okud_without_codes_between_form_one_block(rules: StatementRules) -> None: + sheet = sheet_from_rows( + [ + ["Бухгалтерская отчетность"], + [], + [None, "Бухгалтерский баланс"], + [], + [*at({3: "Форма по ОКУД", 5: "0710001"})], + [*at({1: "Актив", 3: "Код", 4: "На 30.09.2025"})], + [*at({1: "Основные средства", 3: "1150", 4: 10})], + ] + ) + [block] = find_blocks(sheet, rules) + assert (block.type_hint, block.title, block.okud, block.first_row, block.last_row) == ( + DocumentType.BALANCE, + (3, 2), + (5, 5), + 1, + 7, + ) + + +def test_second_title_after_codes_opens_new_block(rules: StatementRules) -> None: + balance = [at({1: name, 3: code, 4: 10}) for name, code in (("а", 1110), ("б", 1150), ("в", 1170), ("г", 1600))] + income = [at({1: name, 3: code, 4: 10}) for name, code in (("д", 2110), ("е", 2120), ("ж", 2400))] + sheet = sheet_from_rows( + [["Бухгалтерский баланс"], *balance, ["Отчет о финансовых результатах"], *income], + ) + blocks = find_blocks(sheet, rules) + assert [(b.type_hint, b.first_row, b.last_row) for b in blocks] == [ + (DocumentType.BALANCE, 1, 5), + (DocumentType.INCOME, 6, 9), + ] + + +def test_okud_wins_over_title(rules: StatementRules) -> None: + blocks = find_blocks(sheet_from_rows([["Бухгалтерский баланс"], ["по ОКУД", "0710002"]]), rules) + assert [block.type_hint for block in blocks] == [DocumentType.INCOME] + + +def test_report_title_alone_is_not_a_form(rules: StatementRules) -> None: + assert find_blocks(sheet_from_rows([["Бухгалтерская отчетность"]]), rules) == [] + + +def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None: + sheet = sheet_from_rows([["Форма 0710001 с.2"], ["Пассив", "Код", "На 30.09.2025"], ["Капитал", "1310", 10]]) + assert find_blocks(sheet, rules) == [] + assert classify_statement_sheet(sheet, rules) == "continuation" + + +def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None: + titles = ["Коды", "0710001", 30, "09", 2025, 12345678, 7700000000, 12267, 16, 384] + codes = [1110, 1150, 1170, 1100, 1210, 1230] + rows = [at({6: value}) for value in titles] + rows += [at({3: code, 4: 10}) for code in codes] + sheet = sheet_from_rows(rows) + assert sheet_code_column(sheet, rules, 1, sheet.n_rows) == CODE_COL + assert codes_of(sheet, rules, CODE_COL, 1, sheet.n_rows) == [str(code) for code in codes] + + +def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: + rows = [at({1: "Актив", 3: "Код", 4: "На 30.09.2025", 7: "Изменение"})] + rows += [at({1: "Статья", 3: code, 4: 1, 5: 2, 6: 3, 7: "рост"}) for code in (1110, 1150, 1170, 1100)] + sheet = sheet_from_rows(rows) + assert sheet_amount_columns(sheet, rules, CODE_COL, 1, sheet.n_rows) == CODE_COL + + +def test_weak_and_other_sheets(rules: StatementRules) -> None: + weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]]) + assert classify_statement_sheet(weak, rules) == "other" + assert is_weak_continuation(weak, rules, DocumentType.INCOME) + assert not is_weak_continuation(weak, rules, DocumentType.BALANCE) + notes = sheet_from_rows([["Примечания"], ["Текст пояснения к отчетности"]]) + assert classify_statement_sheet(notes, rules) == "other" + assert not is_weak_continuation(notes, rules, DocumentType.INCOME) + + +def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None: + rows = [["Форма 0710000 с.2"]] + [at({1: "Статья", 3: code, 4: 10}) for code in (1110, 1150, 1170)] + sheet = sheet_from_rows(rows) + assert find_blocks(sheet, rules) == [] + assert classify_statement_sheet(sheet, rules) == "continuation" + marker = continuation_marker(sheet, rules) + assert marker is not None + assert marker.text == "Форма 0710000 с.2" + assert continuation_type(marker.text, rules) is None + + +def test_labels_with_punctuation_match(rules: StatementRules) -> None: + assert matches(rules.headers.labels["address"], "Местонахождение (адрес)") + assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС") + assert matches(rules.headers.legal_form, "АО «Пример»") + assert caption_matches([rules.headers.code_caption], "Код") + + +def test_column_caption_in_table_header_is_not_a_form_title(rules: StatementRules) -> None: + rows = [ + ["Форма 0710001 с.2"], + at({1: "Наименование", 2: "Пояснения", 3: "Код", 4: "На 30.09.2025"}), + at({1: "Капитал", 3: 1310, 4: 10}), + ] + sheet = sheet_from_rows(rows) + assert find_blocks(sheet, rules) == [] + assert classify_statement_sheet(sheet, rules) == "continuation" + + +def test_real_title_above_same_table_header_stays_a_block(rules: StatementRules) -> None: + rows = [ + ["Бухгалтерский баланс"], + at({1: "Наименование", 2: "Пояснения", 3: "Код", 4: "На 30.09.2025"}), + at({1: "Капитал", 3: 1310, 4: 10}), + ] + [block] = find_blocks(sheet_from_rows(rows), rules) + assert block.type_hint is DocumentType.BALANCE diff --git a/api/tests/unit/parser/statements/test_header.py b/api/tests/unit/parser/statements/test_header.py deleted file mode 100644 index 32ec74a..0000000 --- a/api/tests/unit/parser/statements/test_header.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Шапка формы по меткам (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_header_cells.py b/api/tests/unit/parser/statements/test_header_cells.py new file mode 100644 index 0000000..d9c35b8 --- /dev/null +++ b/api/tests/unit/parser/statements/test_header_cells.py @@ -0,0 +1,107 @@ +"""Шапка-ячейки формы на словарях `arm_statements` (план 4, задача 4).""" + +import pytest +from gridbuilder import at, sheet_from_rows + +from arm_statements import default_rules, parse_header +from src.parser.statements.forms import StatementRules, find_blocks +from src.parser.statements.header_cells import header_cells + + +@pytest.fixture(scope="module") +def rules() -> StatementRules: + return default_rules() + + +def _cells(rows: list[list[object]], rules: StatementRules, name: str = "Баланс"): # noqa: ANN202 + sheet = sheet_from_rows(rows, name=name) + [block] = find_blocks(sheet, rules) + return header_cells(sheet, rules, block, block.last_row, set()) + + +def test_header_cells_of_1c_block(rules: StatementRules) -> None: + cells, evidence = _cells( + [ + [*at({3: "Бухгалтерский баланс"})], + [*at({3: "на 30 сентября 2025 г."})], + [*at({5: "Форма по ОКУД", 6: "0710001"})], + [*at({3: "Организация", 4: "АО «Пример»", 5: "по ОКПО", 6: "12345678"})], + [*at({3: "ИНН", 4: "7700000000"})], + [*at({3: "Организационно-правовая форма", 5: "по ОКОПФ / ОКФС", 6: "12267", 7: "16"})], + [*at({3: "Единица измерения:", 4: "в тыс. рублей", 5: "по ОКЕИ", 6: "384"})], + ], + rules, + ) + assert cells.organization_name is not None + assert cells.organization_name.text == "АО «Пример»" + assert cells.inn is not None + assert cells.inn.ref == "Баланс!D5" + assert cells.okopf is not None + assert cells.okfs is not None + assert (cells.okopf.text, cells.okfs.text) == ("12267", "16") + assert cells.report_date is not None + assert cells.report_date.text == "на 30 сентября 2025 г." + assert evidence.okud is not None + assert evidence.title is not None + assert (evidence.okud.text, evidence.title.text) == ("0710001", "Бухгалтерский баланс") + head = parse_header(cells, rules) + assert head.units.value == "тыс. руб." + assert str(head.report_date.value) == "2025-09-30" + + +def test_auditor_inn_is_not_taken(rules: StatementRules) -> None: + cells, _ = _cells( + [ + ["Бухгалтерский баланс"], + [*at({1: "Организация", 2: "ООО «Пример»"})], + [*at({1: "ИНН", 2: "7700000000"})], + [*at({1: "Аудитор"})], + [*at({1: "ИНН", 2: "7811111111"})], + ], + rules, + ) + assert cells.inn is not None + assert cells.inn.text == "7700000000" + + +def test_auditor_inn_alone_gives_none(rules: StatementRules) -> None: + cells, _ = _cells([["Бухгалтерский баланс"], ["Аудитор"], ["ИНН", "7811111111"]], rules) + assert cells.inn is None + + +def test_organization_by_legal_form_above_label(rules: StatementRules) -> None: + cells, _ = _cells( + [ + ["Бухгалтерский баланс"], + ['ООО "НПЗ"'], + [*at({1: "Организация", 3: "по ОКПО", 4: "16098811"})], + ], + rules, + ) + assert cells.organization_name is not None + assert cells.organization_name.text == 'ООО "НПЗ"' + + +def test_report_date_from_triple(rules: StatementRules) -> None: + numeric, _ = _cells( + [["Бухгалтерский баланс"], [*at({1: "Дата (число, месяц, год)", 2: "30", 3: "09", 4: "2025"})]], rules + ) + assert numeric.report_date is not None + assert numeric.report_date.text == "30.09.2025" + assert numeric.report_date.ref == "Баланс!B2" + worded, _ = _cells( + [["Бухгалтерский баланс"], [*at({1: "Дата (число, месяц, год)", 2: "30", 3: "сентября", 4: "2025"})]], rules + ) + assert worded.report_date is not None + assert worded.report_date.text == "30 сентября 2025" + for cells in (numeric, worded): + assert str(parse_header(cells, rules).report_date.value) == "2025-09-30" + + +def test_units_only_by_okei(rules: StatementRules) -> None: + cells, _ = _cells([["Бухгалтерский баланс"], [*at({1: "по ОКЕИ", 2: "384"})]], rules) + assert cells.units is None + assert cells.okei is not None + assert cells.okei.text == "384" + head = parse_header(cells, rules) + assert (head.units.value, head.okei.value) == ("тыс. руб.", "384") diff --git a/api/tests/unit/parser/statements/test_lines.py b/api/tests/unit/parser/statements/test_lines.py deleted file mode 100644 index cb72397..0000000 --- a/api/tests/unit/parser/statements/test_lines.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»).""" - -from decimal import Decimal - -import pytest -from gridbuilder import sheet_from_rows, with_formats - -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 - - -def test_explanation_values_on_continuation_without_header_are_not_names(rules: Rules) -> None: - """N-4: на продолжении без шапки «Пояснения» («5.1») узнаются по коротким значениям и не становятся названием.""" - sheet = sheet_from_rows( - [ - [None, "Запасы", "1210", 5], - ["5.1", "Дебиторская задолженность", "1230", 7], - ["5.2", None, "1200", 12], - ] - ) - layout = find_layout(sheet, rules, 1, sheet.n_rows) - - result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) - - names = {line.line_code: line.line_name for line in result.lines} - assert names == {"1210": "Запасы", "1230": "Дебиторская задолженность", "1200": ""} - - -# --- числовой формат ячейки (#42): значение — как его показывает документ --- - -FORMAT_ROWS = [ - ["Наименование показателя", "Код", "На 31 декабря 2024 г."], - ["Основные средства", "1150", Decimal("10741981390")], - ["Запасы", "1210", Decimal("586260.26")], - ["Прочие", "1190", Decimal("0")], - ["Дебиторская", "1230", Decimal("-872206880.07")], - ["Финансовые", "1240", Decimal("-646532")], - ["Резерв", "1250", Decimal("0.35")], - ["Итого по разделу I", "1100", Decimal("898852.21795")], -] - - -def _formatted(rules: Rules, formats: dict[int, str]) -> tuple[dict[str, object], set[str]]: - sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(row, 3): fmt for row, fmt in formats.items()}) - layout = find_layout(sheet, rules, 1, sheet.n_rows) - result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) - return {line.line_code: line.values[0] for line in result.lines}, result.unparsed_formats - - -def test_scaling_comma_and_literals_follow_number_format(rules: Rules) -> None: - values, unparsed = _formatted( - rules, - { - 2: "#,##0,", - 3: "0,", - 4: '[=0]"-"', - 5: '[=-872206880.07]"(872\xa0207)";#,##0,', - 6: '[=-646532]"(647)";#,##0,', - 8: "#,##0", - }, - ) - - assert values["1150"].amount == Decimal("10741981.00") - assert values["1210"].amount == Decimal("586.00") - assert values["1190"].amount is None and values["1190"].is_recognized # прочерк без предупреждения - assert values["1230"].amount == Decimal("-872207.00") # литерал со скобками - assert values["1240"].amount == Decimal("-647.00") - assert values["1100"].amount == Decimal("898852.00") - assert unparsed == set() - - -def test_unparsed_format_keeps_raw_value_and_is_collected(rules: Rules) -> None: - values, unparsed = _formatted(rules, {7: "0%", 8: "0.00%"}) - - assert values["1250"].amount == Decimal("0.35") and values["1100"].amount == Decimal("898852.22") - assert unparsed == {"0%", "0.00%"} - - -def test_unparsed_format_in_row_without_code_is_collected(rules: Rules) -> None: - rows = [*FORMAT_ROWS, ["Без кода", None, Decimal("5")]] - sheet = with_formats(sheet_from_rows(rows), {(len(rows), 3): "0%"}) - layout = find_layout(sheet, rules, 1, sheet.n_rows) - - result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) - - assert result.skipped_with_sums == 1 and result.unparsed_formats == {"0%"} - - -def test_fractional_share_counts_displayed_values(rules: Rules) -> None: - """Рубли с копейками, показанные в тысячах целыми (`#,##0,`), дробными не считаются.""" - sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(2, 3): "#,##0,", (3, 3): "0,", (8, 3): "#,##0"}) - layout = find_layout(sheet, rules, 1, sheet.n_rows) - - result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) - - # дробными остаются только значения без формата: 1190 = 0 (не считается), 1230, 1250 (-872206880.07 и 0.35) - assert result.fractional_values == 2 # noqa: PLR2004 diff --git a/api/tests/unit/parser/statements/test_number_format.py b/api/tests/unit/parser/statements/test_number_format.py index 52c33f9..a0ec920 100644 --- a/api/tests/unit/parser/statements/test_number_format.py +++ b/api/tests/unit/parser/statements/test_number_format.py @@ -4,7 +4,7 @@ from decimal import Decimal import pytest -from src.parser.statements.number_format import FormatResult, display_amount +from src.parser.statements.number_format import FormatResult, display_amount, general_text def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]: @@ -131,19 +131,20 @@ def test_escaped_char_is_decoration() -> None: assert shown("1500", "#,##0\\ \\р") == ("value", Decimal("1500.00")) -def test_huge_value_out_of_range_is_unparsed() -> None: - assert shown("1" + "0" * 40, "#,##0") == ("unparsed", None) +def test_huge_value_is_returned_without_range_check() -> None: + """P4-4: диапазон numeric(18,2) проверяет пакет (VALUE_OUT_OF_RANGE), адаптер отдаёт показываемое число.""" + assert shown("1" + "0" * 40, "#,##0") == ("value", Decimal("1" + "0" * 40 + ".00")) -def test_max_numeric_18_2_range() -> None: - """Excel показывает 15 значащих цифр: 16-значное целое округляется до 1E+16, что уже вне numeric(18,2).""" +def test_long_integers_keep_all_digits() -> None: + """P4-4: длинные целые показываются без потери цифр, диапазон проверяет пакет.""" assert shown("999999999999999", "0") == ("value", Decimal("999999999999999.00")) - assert shown("9999999999999999", "0") == ("unparsed", None) - assert shown("99999999999999999", "0") == ("unparsed", None) + assert shown("9999999999999999", "0") == ("value", Decimal("9999999999999999.00")) + assert shown("99999999999999999", "0") == ("value", Decimal("99999999999999999.00")) -def test_scaled_huge_value_fits() -> None: - assert shown("99999999999999999000", "0,") == ("unparsed", None) +def test_scaled_huge_value_is_not_range_checked() -> None: + assert shown("99999999999999999000", "0,") == ("value", Decimal("99999999999999999.00")) assert shown("999999999999999000", "0,") == ("value", Decimal("999999999999999.00")) @@ -153,7 +154,7 @@ def test_condition_compares_with_excel_15_digit_precision() -> None: result = display_amount(Decimal("-1486533582.3500001"), fmt) - assert result == FormatResult("value", Decimal("-1486534")) + assert result == FormatResult("value", Decimal("-1486534"), "(1\u00a0486\u00a0534)") def test_value_is_rounded_to_15_digits_half_up_before_scaling() -> None: @@ -165,8 +166,31 @@ def test_condition_operand_is_rounded_to_15_digits() -> None: """Литерал «-» — «нет значения»; «x» дал бы unparsed по R-5 независимо от условия, поэтому берём прочерк.""" result = display_amount(Decimal("0.1234567890123456"), '[=0.1234567890123456]"-"') - assert result == FormatResult("value", None) + assert result == FormatResult("value", None, "-") def test_condition_that_does_not_match_the_pattern_is_unparsed() -> None: assert shown("0", '[=1E-5]"x"') == ("unparsed", None) + + +@pytest.mark.parametrize( + ("raw", "fmt", "value", "text"), + [ + ("106311116.46", "#,##0,", "106311", "106 311"), + ("-646532.4", "#,##0;(#,##0)", "-646532", "(646 532)"), + ("-1500", "#,##0", "-1500", "-1 500"), + ("-1500", "#,##0;#,##0", "-1500", "1 500"), + ("1.5", "0.00", "1.50", "1,50"), + ("0", '#,##0;-#,##0;"-"', None, "-"), + ("12345678901234567", "0", "12345678901234567", "12345678901234567"), + ], +) +def test_display_text(raw: str, fmt: str, value: str | None, text: str) -> None: + shown = display_amount(Decimal(raw), fmt) + expected = None if value is None else Decimal(value) + assert (shown.kind, shown.value, shown.text) == ("value", expected, text) + + +def test_general_text() -> None: + texts = [general_text(Decimal(x)) for x in ("7016006635.0", "106311116.460", "-0.5")] + assert texts == ["7016006635", "106311116,46", "-0,5"] diff --git a/api/tests/unit/parser/statements/test_pipeline.py b/api/tests/unit/parser/statements/test_pipeline.py new file mode 100644 index 0000000..1ce7184 --- /dev/null +++ b/api/tests/unit/parser/statements/test_pipeline.py @@ -0,0 +1,85 @@ +"""Конвейер форм книги: кандидаты адаптера → пакет → документы и предупреждения файла контракта 2.0.""" + +import pytest +from gridbuilder import at, sheet_from_rows +from result_contract import assert_valid_result + +from arm_statements import Rules as StatementRules +from arm_statements import default_rules +from src.contract import FileWarning, Metrics, ResultStatus +from src.parser import ParseOutcome +from src.parser.deadline import Deadline +from src.parser.detect import SheetKind +from src.parser.grid import Sheet +from src.parser.statements.pipeline import StatementPipeline + +NO_DEADLINE = Deadline(10_000.0) +BALANCE_LINES = ( + ("Основные средства", 1150, 100), + ("Итого по разделу I", 1100, 100), + ("Запасы", 1210, 50), + ("Итого по разделу II", 1200, 50), + ("БАЛАНС", 1600, 150), + ("Нераспределенная прибыль", 1370, 150), + ("Итого по разделу III", 1300, 150), + ("БАЛАНС", 1700, 150), +) + + +@pytest.fixture(scope="module") +def statement_rules() -> StatementRules: + return default_rules() + + +def _balance(name: str = "Баланс") -> Sheet: + """Шапка из восьми строк, подпись столбцов в 9-й, первая строка с кодом — 10-я (код в столбце C).""" + rows = [ + at({1: "Бухгалтерский баланс"}), + at({1: "на 31 марта 2025 г."}), + at({1: "Организация", 2: "ООО Ромашка"}), + at({1: "ИНН", 2: "7700000000"}), + at({1: "Единица измерения", 2: "тыс. руб."}), + [], + [], + [], + at({1: "Наименование показателя", 3: "Код", 4: "На 31 марта 2025 г."}), + *[at({1: title, 3: code, 4: amount}) for title, code, amount in BALANCE_LINES], + ] + return sheet_from_rows(rows, name=name) + + +def test_document_converts_to_contract(statement_rules: StatementRules) -> None: + outcome = StatementPipeline(statement_rules).parse([(_balance(), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False) + + assert outcome.file_warnings == () + document = outcome.documents[0] + assert document.header.form_name == "Бухгалтерский баланс" + assert (document.header.inn, document.header.inn_text, document.header.inn_confidence) == ( + "7700000000", + "7700000000", + 100, + ) + assert document.lines[0].ref == "Баланс!C10" + assert document.status == "done" + assert_valid_result(ParseOutcome(status=ResultStatus.DONE, documents=outcome.documents, metrics=Metrics(rows=8))) + + +def test_file_warnings_are_contract_models(statement_rules: StatementRules) -> None: + """Лист без формы (нет заголовка и кодов) → документа нет, `SHEET_OTHER` как `FileWarning` с locator.""" + sheet = sheet_from_rows([at({1: "Примечания"}), at({1: "Текст без кодов"})], name="Примечания") + + outcome = StatementPipeline(statement_rules).parse( + [(sheet, SheetKind.STATEMENT_CONTINUATION)], NO_DEADLINE, xlsb=False + ) + + assert outcome.documents == () + assert all(isinstance(w, FileWarning) for w in outcome.file_warnings) + assert [(w.code, w.severity, w.locator) for w in outcome.file_warnings] == [ + ("SHEET_OTHER", "info", "sheet:Примечания") + ] + + +def test_xlsb_flag_adds_document_warning(statement_rules: StatementRules) -> None: + outcome = StatementPipeline(statement_rules).parse([(_balance(), SheetKind.STATEMENT)], NO_DEADLINE, xlsb=True) + + assert "XLSB_NO_FORMATS" in {w.code for w in outcome.documents[0].warnings} diff --git a/api/tests/unit/parser/statements/test_statement.py b/api/tests/unit/parser/statements/test_statement.py deleted file mode 100644 index d7e8e11..0000000 --- a/api/tests/unit/parser/statements/test_statement.py +++ /dev/null @@ -1,456 +0,0 @@ -# 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, with_formats - -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) - - -# --- N-1: слабое продолжение (одна кодовая строка) — коды той же формы, присоединение в предупреждениях --- - - -def test_single_code_sheet_join_is_reported(parser: StatementParser) -> None: - single = sheet_from_rows([["Результат прочих операций", "2520", 0]], "ОФР_2") - - _, warnings, _ = parser.parse_sheets( - [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() - ) - - assert "sheet=ОФР_2 присоединён к документу как продолжение по 1 кодовой строке" in warnings - - -def test_single_code_sheet_of_another_form_does_not_join(parser: StatementParser) -> None: - """Лист «Справка» с кодом баланса после ОФР — не продолжение ОФР, даже при совпавшем числе колонок.""" - single = sheet_from_rows([["Основные средства", "1150", 0]], "Справка") - - documents, warnings, ok = parser.parse_sheets( - [(sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), (single, SheetKind.STATEMENT_CONTINUATION)], deadline() - ) - - assert not ok and len(documents) == 1 and "1150" not in [line.line_code for line in documents[0].lines] - assert any( - text.startswith("sheet=Справка лист с кодами форм (1 < 3) не присоединён: коды 1150 не из формы INCOME") - for text in warnings - ) - - -def test_unparsed_number_format_gives_one_document_warning(parser: StatementParser) -> None: - long_format = "0.0000%" + "x" * 40 - page_1 = with_formats( - sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "0%", (8, 3): "0%", (9, 3): long_format, (11, 3): "0.00%"} - ) - page_2 = with_formats(sheet_from_rows(BALANCE_PAGE_2, "ББ2"), {(3, 3): "0%"}) - sheets = [(page_1, SheetKind.STATEMENT), (page_2, SheetKind.STATEMENT_CONTINUATION)] - - _, warnings, _ = parser.parse_sheets(sheets, deadline()) - - found = [text for text in warnings if "формат чисел не разобран" in text] - shortened = long_format[:29] + "…" - assert found == [f"sheet=ББ1 формат чисел не разобран, значения как в ячейках: «0%», «0.00%», «{shortened}»"] - - -def test_parsed_formats_give_no_format_warning(parser: StatementParser) -> None: - page_1 = with_formats(sheet_from_rows(BALANCE_PAGE_1, "ББ1"), {(7, 3): "#,##0", (8, 3): "0,"}) - - _, warnings, _ = parser.parse_sheets([(page_1, SheetKind.STATEMENT)], deadline()) - - assert not any("формат чисел" in text for text in warnings) - - -def test_xlsb_flag_adds_warning_to_statement_documents_only(parser: StatementParser) -> None: - sheets = [ - (sheet_from_rows(BALANCE_PAGE_1, "ББ"), SheetKind.STATEMENT), - (sheet_from_rows(INCOME, "ОФР"), SheetKind.STATEMENT), - ] - - _, warnings, _ = parser.parse_sheets(sheets, deadline(), formats_unreadable=True) - _, plain, _ = parser.parse_sheets(sheets, deadline()) - - text = "формат чисел xlsb не читается: значения как в файле" - assert [w for w in warnings if "xlsb" in w] == [f"sheet=ББ {text}", f"sheet=ОФР {text}"] - assert not any("xlsb" in w for w in plain) diff --git a/api/tests/unit/parser/test_book_parser.py b/api/tests/unit/parser/test_book_parser.py index c44fb41..de0d974 100644 --- a/api/tests/unit/parser/test_book_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -22,19 +22,21 @@ 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, StatementDocument -from src.contract.result import StatementHeader +from arm_statements import Rules as StatementRules +from arm_statements import default_rules +from src.contract import ErrorCode, FileWarning, OsvDocument, ResultStatus, StatementDocument from src.parser import ParseOutcome, hierarchy -from src.parser.book import BookParser, StatementSheetParser +from src.parser.book import BookParser 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.osv import OsvParser, _unique_notes +from src.parser.osv_warnings import OsvNote +from src.parser.readers.xls import NO_CODEPAGE_WARNING from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules -from src.parser.statements.statement import StatementParser +from src.parser.statements.pipeline import StatementPipeline +from tests.synthetic.xls_writer import write_xls from tests.synthetic.xlsb_writer import write_xlsb OPENING = "Сальдо на начало периода" @@ -129,18 +131,24 @@ def statement() -> list[Cells]: return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]] +@pytest.fixture(scope="module") +def statement_rules() -> StatementRules: + return default_rules() + + def book( rules: Rules, *, - statements: StatementSheetParser | None = None, max_bytes: int = MAX_BYTES, timeout_seconds: float = TIMEOUT_SECONDS, clock: Callable[[], float] = time.monotonic, ) -> BookParser: + statement_rules = default_rules() return BookParser( rules, osv=OsvParser(rules), - statements=statements or StatementParser(rules), + statements=StatementPipeline(statement_rules), + statement_rules=statement_rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock, @@ -153,12 +161,27 @@ def only_document(outcome: ParseOutcome) -> OsvDocument: return document -def warnings_of(outcome: ParseOutcome) -> list[str]: +def file_warnings_of(outcome: ParseOutcome) -> list[FileWarning]: + """Предупреждения файла (`metrics.warnings`).""" assert outcome.metrics is not None assert outcome.metrics.warnings is not None return outcome.metrics.warnings +def warnings_of(outcome: ParseOutcome) -> list[str]: + """Сообщения предупреждений документов ОСВ (контракт 2.0: лежат в самих документах), по порядку документов.""" + return [ + warning.message + for document in outcome.documents + if isinstance(document, OsvDocument) + for warning in document.warnings + ] + + +def file_codes_of(outcome: ParseOutcome) -> list[str]: + return [warning.code for warning in file_warnings_of(outcome)] + + # --- Штатный разбор --- @@ -215,9 +238,10 @@ def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None: assert outcome.metrics.rows == DATA_ROWS assert outcome.metrics.duration_ms is not None assert outcome.metrics.duration_ms >= 0 - assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline"] + assert warnings_of(outcome) == ["иерархия строк — по группировке листа (outline)"] + assert (document.status, outcome.metrics.warnings) == ("done", []) payload = assert_valid_result(outcome) - assert payload["schema_version"] == "1.2" + assert payload["schema_version"] == "2.0" def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None: @@ -233,7 +257,7 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None: assert checks.parent_sum_violations == [ACCOUNT_ROW] assert checks.total_row_matches_root is True warnings = warnings_of(outcome) - assert warnings[0] == "sheet=ОСВ 60 hierarchy=outline" + assert warnings[0] == "иерархия строк — по группировке листа (outline)" assert any(f"строки {SUPPLIER_ROW}" in warning for warning in warnings), warnings assert any(f"строки {ACCOUNT_ROW}" in warning for warning in warnings), warnings assert_valid_result(outcome) @@ -285,7 +309,7 @@ def test_empty_osv_is_done_with_total_only(rules: Rules) -> None: assert document.checks.total_row_matches_root is True assert outcome.metrics is not None assert outcome.metrics.rows == 0 - assert warnings_of(outcome) == ["sheet=ОСВ 66 hierarchy=trivial", "нет строк данных"] + assert warnings_of(outcome) == ["иерархия строк — плоский список (trivial)", "нет строк данных"] assert_valid_result(outcome) @@ -381,7 +405,7 @@ def test_statement_sheet_goes_to_real_pipeline_next_to_osv(rules: Rules) -> None 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 "иерархия строк — по группировке листа (outline)" in warnings_of(outcome) assert_valid_result(outcome) @@ -396,10 +420,16 @@ def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None: assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"] assert outcome.metrics is not None assert outcome.metrics.rows == 2 * DATA_ROWS - warnings = warnings_of(outcome) - assert "sheet=ОСВ 60 hierarchy=outline" in warnings - assert "sheet=ОСВ 62 hierarchy=outline" in warnings - assert any(warning.startswith("sheet=Справка skipped") for warning in warnings), warnings + for document in outcome.documents: + assert isinstance(document, OsvDocument) + assert [w.code for w in document.warnings] == ["OSV_HIERARCHY_BY_OUTLINE"] + (other_warning,) = file_warnings_of(outcome) + assert (other_warning.code, other_warning.severity, other_warning.locator) == ( + "SHEET_OTHER", + "info", + "sheet:Справка", + ) + assert other_warning.message == "лист без признаков ОСВ и формы" assert_valid_result(outcome) @@ -506,6 +536,61 @@ def test_deadline_exceeded_is_timeout(rules: Rules) -> None: assert "превышен лимит" in outcome.error.message +def test_sheet_kind_classification_is_interrupted_by_deadline(rules: Rules) -> None: + """I-1: классификация листов (`plan_sheets`) проверяет дедлайн на каждом листе, книга из «прочего» — TIMEOUT.""" + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + clock = itertools.count(step=CLOCK_STEP) + content = xlsx({"Лист 1": notes, "Лист 2": notes, "Лист 3": notes}) + + outcome = book(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(content, "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "вид листа" in outcome.error.message + + +def test_package_exception_on_candidate_is_recognition_failed_with_collected_warnings( + rules: Rules, monkeypatch: pytest.MonkeyPatch +) -> None: + """I-2(а), P4-11: исключение пакета на кандидате — ошибка книги, предупреждения файла до него сохраняются.""" + + def broken(candidate: object, statement_rules: object) -> object: + raise ValueError("сбой пакета\nвторая строка") + + monkeypatch.setattr("src.parser.statements.pipeline.normalize_candidate", broken) + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + + outcome = book(rules).parse(xlsx({"Примечания": notes, "ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.status is ResultStatus.ERROR + assert outcome.error is not None + assert outcome.error.code is ErrorCode.RECOGNITION_FAILED + assert outcome.error.message == "ValueError: сбой пакета вторая строка" + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [("SHEET_OTHER", "sheet:Примечания")] + + +def test_deadline_on_forms_path_is_timeout_in_adapter(rules: Rules) -> None: + """I-2(б): дедлайн истекает на сборке кандидатов адаптера (после классификации листа).""" + ticks = itertools.chain([0.0] * 3, itertools.repeat(1e9)) # старт, создание дедлайна, вид листа + + outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "форма" in outcome.error.message + + +def test_deadline_on_forms_path_is_timeout_in_pipeline(rules: Rules) -> None: + """I-2(б): дедлайн истекает перед нормализацией кандидата в `StatementPipeline`.""" + ticks = itertools.chain([0.0] * 4, itertools.repeat(1e9)) # + проверка адаптера + + outcome = book(rules, timeout_seconds=100, clock=lambda: next(ticks)).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX") + + assert outcome.error is not None + assert outcome.error.code is ErrorCode.TIMEOUT + assert "форма «sheet:ББ»" in outcome.error.message + + def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None: def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: raise ValueError("сбой стратегии\nвторая строка") @@ -632,67 +717,88 @@ def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> 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]] +# --- Формы (оркестрация книги, общий код arm_statements) --- -class _FakeStatements: - """Возвращает по одному документу DISCLOSURE на лист формы и запоминает, что получил.""" +def test_statement_sheets_go_to_pipeline_in_sheet_order(rules: Rules) -> None: + content = xlsx({"ББ": BALANCE_ROWS, "Лист_1": mini_osv(), "ОФР": INCOME_ROWS}) - def __init__(self) -> None: - self.received: list[tuple[str, SheetKind]] = [] - self.formats_unreadable = False + outcome = book(rules).parse(content, "XLSX") - def parse_sheets( - self, sheets: Sequence[tuple[Sheet, SheetKind]], deadline: Deadline, *, formats_unreadable: bool = False - ) -> tuple[list[StatementDocument], list[str], bool]: - self.formats_unreadable = formats_unreadable - 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 [document.locator for document in outcome.documents] == ["sheet:Лист_1", "sheet:ББ", "sheet:ОФР"] + assert_valid_result(outcome) + + +def test_osv_document_has_status_and_osv_warnings(rules: Rules) -> None: + """Spec «Предупреждение ОСВ»: статус документа и `OSV_*` в самом документе, а не в `metrics.warnings`.""" + spoiled = (60, None, 20, 10, 71, None) + + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX") + + document = only_document(outcome) + assert document.status == "needs_check" + assert all(w.severity == "info" and w.code.startswith("OSV_") for w in document.warnings) + assert "OSV_BALANCE_IDENTITY" in {w.code for w in document.warnings} + assert outcome.metrics is not None and outcome.metrics.warnings == [] + assert not any(w.message.startswith("sheet=") for w in file_warnings_of(outcome)) + assert outcome.status is ResultStatus.NEEDS_CHECK + + +def test_osv_notes_are_unique_by_code_and_message(rules: Rules) -> None: + """T6.1: повтор считается по паре (код, текст): одинаковый текст под разными кодами остаётся.""" + notes = [OsvNote("A", "текст"), OsvNote("B", "текст"), OsvNote("A", "текст")] + assert _unique_notes(notes) == [OsvNote("A", "текст"), OsvNote("B", "текст")] + + +def test_other_sheet_is_file_warning(rules: Rules) -> None: + """Spec «Лист «прочее»»: документ формы не создаётся, предупреждение файла с `locator`.""" + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + + outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "Примечания": notes}), "XLSX") + + assert len(outcome.documents) == 1 + (warning,) = file_warnings_of(outcome) + assert (warning.code, warning.severity, warning.locator) == ("SHEET_OTHER", "info", "sheet:Примечания") + assert warning.message == "лист без признаков ОСВ и формы" + + +def test_file_type_mismatch_is_info_file_warning(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLS") + + (warning,) = file_warnings_of(outcome) + assert (warning.code, warning.severity, warning.locator) == ("FILE_TYPE_MISMATCH", "info", None) + assert warning.message == "file_type=XLS, но содержимое OOXML: прочитано как XLSX" 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 _xls_cells(rows: Sequence[Cells]) -> dict[tuple[int, int], object]: + return {position: cell.value for position, cell in sheet_from_rows(rows, "x").cells.items()} -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") +def test_xls_without_codepage_is_file_warning(rules: Rules) -> None: + """R-2: заметка читателя — предупреждение файла `XLS_NO_CODEPAGE` (info, без locator), кода OSV_ для неё нет.""" + content = write_xls({"ОСВ 60": _xls_cells(mini_osv()), "ББ": _xls_cells(BALANCE_ROWS)}, codepage=None) - assert outcome.status is ResultStatus.NEEDS_CHECK # лист-продолжение потерян: в нём меньше трёх кодов - assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60"] - assert "sheet=с.2 колонка кодов не найдена" in warnings_of(outcome) + outcome = book(rules).parse(content, "XLS") + + assert [(w.code, w.severity, w.locator) for w in file_warnings_of(outcome)] == [("XLS_NO_CODEPAGE", "info", None)] + assert file_warnings_of(outcome)[0].message == NO_CODEPAGE_WARNING + osv = [d for d in outcome.documents if isinstance(d, OsvDocument)] + assert osv and all("OSV_XLS_NO_CODEPAGE" not in {w.code for w in d.warnings} for d in osv) + assert_valid_result(outcome) + + +def test_xls_without_codepage_forms_only_book(rules: Rules) -> None: + content = write_xls({"ББ": _xls_cells(BALANCE_ROWS)}, codepage=None) + + outcome = book(rules).parse(content, "XLS") + + assert "XLS_NO_CODEPAGE" in file_codes_of(outcome) def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None: @@ -704,18 +810,111 @@ def test_book_without_osv_and_forms_is_invalid_document(rules: Rules) -> None: 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, *, formats_unreadable: bool = False - ) -> tuple[list[StatementDocument], list[str], bool]: - documents, warnings, _ = super().parse_sheets(sheets, deadline, formats_unreadable=formats_unreadable) - return documents, warnings, False +def test_documents_order_osv_then_forms(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "Лист_1": mini_osv()}), "XLSX") - outcome = book(rules, statements=_NotOk()).parse(xlsx({"ББ": BALANCE}), "XLSX") + assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"] + +def test_consistent_forms_book_is_done(rules: Rules) -> None: + """ББ и ОФР с сошедшимися соотношениями: оба документа `done`, предупреждений нет, книга `done`.""" + outcome = book(rules).parse(xlsx({"ББ": GOOD_BALANCE_ROWS, "ОФР": GOOD_INCOME_ROWS}), "XLSX") + + assert [(d.document_type, d.status) for d in outcome.documents] == [("BALANCE", "done"), ("INCOME", "done")] + assert outcome.status is ResultStatus.DONE + assert_valid_result(outcome) + + +def test_statement_error_warning_makes_book_needs_check(rules: Rules) -> None: + """Книга `done` ⇔ все документы `done` и нет предупреждений файла `error` (спека «Статус книги»).""" + broken = [row[:] for row in GOOD_BALANCE_ROWS] + broken[-1][2] = 999 # 1600 ≠ 1100 + 1200 → жёсткое соотношение + + outcome = book(rules).parse(xlsx({"ББ": broken}), "XLSX") + + (document,) = outcome.documents + assert document.status == "needs_check" assert outcome.status is ResultStatus.NEEDS_CHECK - assert len(outcome.documents) == 1 + + +def test_disclosures_only_book_is_done(rules: Rules) -> None: + """Spec «Книга только из пояснений»: `DISCLOSURE` всегда `done`, статус книги от него не зависит.""" + content = xlsx( + { + "0710003": [["Форма по ОКУД", "0710003"], ["Расшифровка"]], + "Пояснения": [["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["Текст"]], + } + ) + + outcome = book(rules).parse(content, "XLSX") + + assert [(d.document_type, d.status) for d in outcome.documents] == [("DISCLOSURE", "done")] * 2 + assert outcome.status is ResultStatus.DONE + + +def test_disclosure_title_with_balance_codes_is_balance_with_type_conflict(rules: Rules) -> None: + rows: list[Cells] = [["Пояснения к бухгалтерскому балансу"], *BALANCE_ROWS[1:]] + + outcome = book(rules).parse(xlsx({"Пояснения": rows}), "XLSX") + + (document,) = outcome.documents + assert isinstance(document, StatementDocument) + assert document.document_type == "BALANCE" + conflicts = [w for w in document.warnings if w.code == "TYPE_CONFLICT"] + assert [w.severity for w in conflicts] == ["error"] + assert document.status == "needs_check" + + +def _xlsb_book(rows_by_sheet: dict[str, Sequence[Cells]]) -> bytes: + return write_xlsb({name: _xls_cells(rows) for name, rows in rows_by_sheet.items()}) + + +def test_xlsb_form_scale_suspect(rules: Rules) -> None: + """Книга xlsb, единица тыс. руб. и ≥ 40 % дробных значений: масштаб подозрителен (error) → `needs_check`.""" + fractional = [row[:] for row in BALANCE_ROWS] + for row in fractional[4:]: + row[2] = Decimal(row[2]) + Decimal("0.5") # 100.5, 50.5, ... + fractional.insert(3, ["Единица измерения", "тыс. руб."]) + + outcome = book(rules).parse(_xlsb_book({"ББ": fractional}), "XLSB") + + (document,) = outcome.documents + codes = {w.code: w.severity for w in document.warnings} + assert codes.get("XLSB_SCALE_SUSPECT") == "error", codes + assert document.status == "needs_check" + assert ("XLSB_NO_FORMATS", "info") in {(w.code, w.severity) for w in file_warnings_of(outcome)} | { + (w.code, w.severity) for w in document.warnings + } + + +def test_xlsb_form_without_fractions_has_only_no_formats(rules: Rules) -> None: + whole = [row[:] for row in GOOD_BALANCE_ROWS] + whole.insert(3, ["Единица измерения", "тыс. руб."]) + whole.insert(3, ["ИНН", "7700000000"]) + + outcome = book(rules).parse(_xlsb_book({"ББ": whole}), "XLSB") + + (document,) = outcome.documents + assert [w.code for w in document.warnings] == ["XLSB_NO_FORMATS"] + assert document.status == "done" + assert outcome.status is ResultStatus.DONE + + +def test_xlsb_book_marks_osv_with_outline_note_only(rules: Rules) -> None: + """P4-13: заметка «outline недоступен» — в каждом документе ОСВ книги xlsb; формы её не получают.""" + outcome = book(rules).parse(_xlsb_book({"ББ": BALANCE_ROWS, "ОСВ 60": mini_osv()}), "XLSB") + + osv, form = outcome.documents + assert isinstance(osv, OsvDocument) and isinstance(form, StatementDocument) + assert "OSV_XLSB_NO_OUTLINE" in {w.code for w in osv.warnings} + assert "OSV_XLSB_NO_OUTLINE" not in {w.code for w in form.warnings} + + +def test_xlsx_book_has_no_xlsb_notes(rules: Rules) -> None: + outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "ОСВ 60": mini_osv()}), "XLSX") + + assert not any("XLSB" in w.code for document in outcome.documents for w in document.warnings) + assert outcome.metrics is not None and outcome.metrics.warnings == [] # --- N-2/N-3: лист с одной кодовой строкой — продолжение только сразу после листа формы --- @@ -731,15 +930,42 @@ BALANCE_ROWS: list[Cells] = [ ["Итого по разделу II", "1200", 50], ["БАЛАНС", "1600", 150], ] +GOOD_BALANCE_ROWS: list[Cells] = [ + *BALANCE_ROWS, + ["Нераспределенная прибыль", "1370", 150], + ["Итого по разделу III", "1300", 150], + ["БАЛАНС", "1700", 150], +] +GOOD_INCOME_ROWS: list[Cells] = [ + ["Отчет о финансовых результатах"], + ["за 1 квартал 2025 г."], + ["Организация", "ООО Ромашка"], + ["Наименование показателя", "Код", "За 1 квартал 2025 г."], + ["Выручка", "2110", 1000], + ["Себестоимость продаж", "2120", -600], + ["Валовая прибыль (убыток)", "2100", 400], + ["Прибыль (убыток) от продаж", "2200", 400], + ["Прибыль (убыток) до налогообложения", "2300", 400], + ["Чистая прибыль (убыток)", "2400", 400], +] +INCOME_ROWS: list[Cells] = [ + ["Отчет о финансовых результатах"], + ["за 1 квартал 2025 г."], + ["Организация", "ООО Ромашка"], + ["Наименование показателя", "Код", "За 1 квартал 2025 г."], + ["Выручка", "2110", 1000], + ["Себестоимость продаж", "2120", -600], + ["Валовая прибыль (убыток)", "2100", 400], +] def test_single_code_sheet_after_osv_sheet_is_not_a_continuation(rules: Rules) -> None: - """Баланс, лист ОСВ, затем лист с одной строкой 1700: между формой и листом стоит ОСВ — лист пропущен.""" + """Баланс, лист ОСВ, затем лист с одной строкой 1700: между формой и листом стоит ОСВ — лист «прочее».""" data_ = xlsx({"ББ": BALANCE_ROWS, "Лист_1": mini_osv(), "Хвост": [["БАЛАНС", "1700", 150]]}) outcome = book(rules).parse(data_, "XLSX") - assert "sheet=Хвост skipped: нет признаков ОСВ или формы" in warnings_of(outcome) + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [("SHEET_OTHER", "sheet:Хвост")] statements = [document for document in outcome.documents if not isinstance(document, OsvDocument)] assert len(statements) == 1 and "1700" not in [line.line_code for line in statements[0].lines] @@ -759,30 +985,39 @@ def test_single_code_sheet_right_after_form_joins(rules: Rules) -> None: assert document.locator == "sheet:ББ,ББ_2" and [line.line_code for line in document.lines][-1] == "1700" -def test_statement_sheets_are_reported_when_osv_sheet_fails(rules: Rules) -> None: - """T7.1: лист ОСВ сломан — ответ с ошибкой всё равно называет листы форм, которые остались неразобранными.""" +def test_file_warnings_before_osv_error_are_kept(rules: Rules) -> None: + """Ошибка листа ОСВ: ответ несёт предупреждения файла, собранные до неё (P4-14); «форма не разобрана» не пишется.""" broken: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]] + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] - outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS, "ОСВ 60": broken}), "XLSX") + outcome = book(rules).parse(xlsx({"Примечания": notes, "ББ": BALANCE_ROWS, "ОСВ 60": broken}), "XLSX") assert outcome.status is ResultStatus.ERROR - assert "sheet=ББ форма не разобрана: книга завершилась ошибкой листа ОСВ «ОСВ 60»" in warnings_of(outcome) + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [("SHEET_OTHER", "sheet:Примечания")] + assert not any("форма не разобрана" in w.message for w in file_warnings_of(outcome)) -def test_xlsb_book_marks_formats_unreadable_for_statements_only(rules: Rules) -> None: - """#42, R-7: xlsb определяется по содержимому книги (без подмены предупреждений); ОСВ предупреждения не получает.""" - cells = { - name: {position: cell.value for position, cell in sheet_from_rows(rows, name).cells.items()} - for name, rows in (("ББ", BALANCE_ROWS), ("ОСВ 60", mini_osv())) - } - outcome = book(rules).parse(write_xlsb(cells), "XLSB") +def test_file_warnings_follow_sheet_order(rules: Rules) -> None: + """Предупреждения идут по порядку листов: «Справка» (до ББ) и «Хвост» (после ОФР) не уезжают относительно форм.""" + notes: list[Cells] = [["Примечания"], ["Текст без кодов и сумм"]] + tail: list[Cells] = [["Хвост"], ["Текст без кодов и сумм"]] + unjoined = [["БАЛАНС", "1700", 150]] # одна кодовая строка после листа «прочее» — не продолжение + content = xlsx( + { + "Справка": notes, + "ББ": BALANCE_ROWS, + "Лист_1": mini_osv(), + "Одиночка": unjoined, + "ОФР": INCOME_ROWS, + "Хвост": tail, + } + ) - xlsb_warnings = [w for w in warnings_of(outcome) if "формат чисел xlsb" in w] - assert xlsb_warnings == ["sheet=ББ формат чисел xlsb не читается: значения как в файле"] - assert [type(document).__name__ for document in outcome.documents] == ["OsvDocument", "StatementDocument"] + outcome = book(rules).parse(content, "XLS") # xlsx под видом XLS → FILE_TYPE_MISMATCH первым - -def test_xlsx_book_has_no_xlsb_format_warning(rules: Rules) -> None: - outcome = book(rules).parse(xlsx({"ББ": BALANCE_ROWS}), "XLSX") - - assert not any("формат чисел" in w for w in warnings_of(outcome)) + assert [(w.code, w.locator) for w in file_warnings_of(outcome)] == [ + ("FILE_TYPE_MISMATCH", None), + ("SHEET_OTHER", "sheet:Справка"), + ("SHEET_OTHER", "sheet:Одиночка"), + ("SHEET_OTHER", "sheet:Хвост"), + ] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index b088714..b0f335e 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -79,7 +79,8 @@ def test_tolerance_is_one_kopeck() -> None: def test_consistent_tree_has_no_violations() -> None: - checks, warnings = run_checks(tree()) + checks, notes = run_checks(tree()) + warnings = [note.message for note in notes] assert checks.total_row_matches_root is True assert checks.balance_identity_violations == [] @@ -250,7 +251,8 @@ def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]: def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None: rows = multi_root_tree() - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert total_matches_root(rows) is True assert checks.total_row_matches_root is True @@ -288,7 +290,8 @@ def test_expanded_balance_total_is_not_matched_by_net_when_total_has_both_sides( def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None: rows = multi_root_tree(("140", None, "40", "20", "161", None)) - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings) @@ -326,7 +329,8 @@ def test_missing_total_fails_root_check_with_warning() -> None: """R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение.""" rows = tree()[:-1] - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert total_matches_root(rows) is False assert checks.total_row_matches_root is False @@ -337,7 +341,8 @@ def test_run_checks_warnings_name_row_numbers() -> None: rows = tree() rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7) - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert checks.balance_identity_violations == [9] assert checks.parent_sum_violations == [7] @@ -350,7 +355,8 @@ def test_run_checks_reports_total_mismatch_with_row_numbers() -> None: rows = tree() rows[-1] = total(10, ("100", None, "50", "30", "121", None)) - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings @@ -361,7 +367,8 @@ def test_run_checks_warning_lists_are_capped() -> None: broken: Sums = ("1", None, None, None, "2", None) rows = [row(number, broken) for number in range(1, 101)] - checks, warnings = run_checks(rows) + checks, notes = run_checks(rows) + warnings = [note.message for note in notes] assert checks.balance_identity_violations == list(range(1, 101)) (identity,) = [warning for warning in warnings if "сальдо" in warning] @@ -431,7 +438,8 @@ def test_total_row_is_still_checked_on_such_accounts() -> None: rows[-1] = total(9, (None, None, "31", "30", None, None)) assert total_matches_root(rows) is False - checks, warnings = run_checks(rows, NO_SALDO) + checks, notes = run_checks(rows, NO_SALDO) + warnings = [note.message for note in notes] assert checks.total_row_matches_root is False assert any("Итого" in warning for warning in warnings) @@ -449,7 +457,8 @@ def test_exemption_is_matched_by_account_root_not_by_prefix_of_digits() -> None: def test_run_checks_without_violations_on_account_91_gives_clean_status() -> None: - checks, warnings = run_checks(tree_91(), NO_SALDO) + checks, notes = run_checks(tree_91(), NO_SALDO) + warnings = [note.message for note in notes] assert checks.balance_identity_violations == [] assert checks.parent_sum_violations == [] diff --git a/api/tests/unit/parser/test_cli.py b/api/tests/unit/parser/test_cli.py index 0b37b5d..4ae6f0e 100644 --- a/api/tests/unit/parser/test_cli.py +++ b/api/tests/unit/parser/test_cli.py @@ -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.2" + assert payload["schema_version"] == "2.0" 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.2",\n') + assert out.startswith('{\n "schema_version": "2.0",\n') keys = list(json.loads(out)) assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden diff --git a/api/tests/unit/parser/test_grid.py b/api/tests/unit/parser/test_grid.py index baccddc..c5797b3 100644 --- a/api/tests/unit/parser/test_grid.py +++ b/api/tests/unit/parser/test_grid.py @@ -5,7 +5,7 @@ from decimal import Decimal import pytest -from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter +from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, cell_ref, column_letter def _sheet( @@ -91,3 +91,11 @@ def test_column_letter(index: int, letter: str) -> None: def test_column_letter_rejects_non_positive_index() -> None: with pytest.raises(ValueError, match="1-based"): column_letter(0) + + +def test_cell_ref_quotes_sheet_name_by_excel_rule() -> None: + assert cell_ref("Баланс", 27, 3) == "Баланс!C27" + assert cell_ref("Лист_1", 2, 2) == "Лист_1!B2" + assert cell_ref("Бухгалтерский баланс. Лист 1", 14, 3) == "'Бухгалтерский баланс. Лист 1'!C14" + assert cell_ref("2024", 1, 1) == "'2024'!A1" + assert cell_ref("Отчёт д'Артаньяна", 1, 28) == "'Отчёт д''Артаньяна'!AB1" diff --git a/api/tests/unit/parser/test_inn_rows_osv.py b/api/tests/unit/parser/test_inn_rows_osv.py index 395ab28..3c44a80 100644 --- a/api/tests/unit/parser/test_inn_rows_osv.py +++ b/api/tests/unit/parser/test_inn_rows_osv.py @@ -39,7 +39,9 @@ def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) - ] ) - document, warnings, ok = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + result = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + document, ok = result.document, result.ok + warnings = [note.message for note in result.notes] assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")] assert not ok diff --git a/api/tests/unit/parser/test_inn_subkonto.py b/api/tests/unit/parser/test_inn_subkonto.py index d529612..1c2ef8b 100644 --- a/api/tests/unit/parser/test_inn_subkonto.py +++ b/api/tests/unit/parser/test_inn_subkonto.py @@ -49,7 +49,8 @@ def r(level: int, text: object, sums: Sums) -> list[object]: def _parse(sheet: Sheet, rules: Rules) -> tuple[OsvDocument, list[str], bool]: - return OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + result = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + return result.document, [note.message for note in result.notes], result.ok def _values(document: OsvDocument) -> list[str]: diff --git a/api/tests/unit/parser/test_no_coordinates.py b/api/tests/unit/parser/test_no_coordinates.py index d4b1c25..403b76a 100644 --- a/api/tests/unit/parser/test_no_coordinates.py +++ b/api/tests/unit/parser/test_no_coordinates.py @@ -202,7 +202,7 @@ 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 "statements/adapter.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_osv_warnings.py b/api/tests/unit/parser/test_osv_warnings.py new file mode 100644 index 0000000..49c7500 --- /dev/null +++ b/api/tests/unit/parser/test_osv_warnings.py @@ -0,0 +1,94 @@ +"""Предупреждения ОСВ с кодами `OSV_*` (P4-12): коды по источнику, тексты — в `warnings` документа (контракт 2.0).""" + +import pytest +from gridbuilder import L, M, sheet_from_rows +from test_checks import row, total, tree + +from src.parser.checks import run_checks +from src.parser.deadline import Deadline +from src.parser.grid import Sheet +from src.parser.osv import OsvParser +from src.parser.osv_warnings import OsvNote, hierarchy_note +from src.parser.rules import Rules, load_rules + +SUMS = (None, None, 100, 100, None, None) +BROKEN = (None, None, 100, 100, None, 5) + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _sheet(*rows: list[object]) -> Sheet: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60"], + ["Период: 9 месяцев 2025 г."], + ["Детализация по субсчетам, субконто: Контрагенты"], + ["Выводимые данные: сумма"], + [], + ["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ] + return sheet_from_rows([*head, *rows]) + + +def test_check_notes_have_codes() -> None: + rows = tree() + rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7) + rows[-1] = total(10, ("100", None, "50", "30", "121", None)) + + _, notes = run_checks(rows) + + assert [note.code for note in notes] == ["OSV_BALANCE_IDENTITY", "OSV_PARENT_SUM", "OSV_TOTAL"] + + +def test_hierarchy_note_code_by_strategy() -> None: + assert hierarchy_note("sums") == OsvNote("OSV_HIERARCHY_BY_SUMS", "иерархия строк — по суммам (sums)") + assert hierarchy_note("account_pattern").code == "OSV_HIERARCHY_BY_ACCOUNT_PATTERN" + + +def test_hierarchy_note_text_names_the_strategy() -> None: + assert hierarchy_note("outline").message == "иерархия строк — по группировке листа (outline)" + assert hierarchy_note("indent").message == "иерархия строк — по отступам (indent)" + assert hierarchy_note("account_pattern").message == "иерархия строк — по кодам счетов (account_pattern)" + assert hierarchy_note("sums").message == "иерархия строк — по суммам (sums)" + assert hierarchy_note("trivial").message == "иерархия строк — плоский список (trivial)" + + +def test_parse_sheet_notes_start_with_hierarchy(rules: Rules) -> None: + sheet = _sheet([L(0), "60.01", *BROKEN], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *BROKEN]) + + result = OsvParser(rules).parse_sheet(sheet, Deadline(60)) + + assert result.notes[0].code.startswith("OSV_HIERARCHY_BY_") + assert all(note.code.startswith("OSV_") for note in result.notes) + assert not result.ok + + +def test_document_warnings_are_the_notes(rules: Rules) -> None: + sheet = _sheet([L(0), "60.01", *BROKEN], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *BROKEN]) + + result = OsvParser(rules).parse_sheet(sheet, Deadline(60)) + + assert [(w.code, w.severity, w.message) for w in result.document.warnings] == [ + (note.code, "info", note.message) for note in result.notes + ] + assert [note.message for note in result.notes] == [ + "иерархия строк — по группировке листа (outline)", + "валюта в шапке не найдена", + "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 9, 11", + "§1.4: суммы родителя ≠ сумме детей (±0.01): строки 9", + ] + assert result.document.status == "needs_check" + + +def test_reader_notes_are_appended_last(rules: Rules) -> None: + sheet = _sheet([L(0), "60.01", *SUMS], [L(1), "ООО Альфа", *SUMS], [L(0), "Итого", *SUMS]) + note = OsvNote("OSV_XLSB_NO_OUTLINE", "xlsb: outline level недоступен") + + result = OsvParser(rules).parse_sheet(sheet, Deadline(60), [note]) + + assert result.notes[-1] == note + assert result.document.warnings[-1].code == "OSV_XLSB_NO_OUTLINE" diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py index 06e8019..d925583 100644 --- a/api/tests/unit/parser/test_read_limits.py +++ b/api/tests/unit/parser/test_read_limits.py @@ -13,6 +13,7 @@ from typing import Any import openpyxl import pytest +from arm_statements import default_rules from src.contract import ErrorCode, ResultStatus from src.parser.book import BookParser from src.parser.errors import UnreadableError, UnsupportedFormatError @@ -28,7 +29,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 +from src.parser.statements.pipeline import StatementPipeline FAST_SECONDS = 1.0 SHEET_PART = "xl/worksheets/sheet1.xml" @@ -125,8 +126,14 @@ def test_huge_merged_range_is_rejected_before_openpyxl() -> None: def _book(rules: Rules, max_bytes: int) -> BookParser: + statement_rules = default_rules() return BookParser( - rules, osv=OsvParser(rules), statements=StatementParser(rules), max_bytes=max_bytes, timeout_seconds=120 + rules, + osv=OsvParser(rules), + statements=StatementPipeline(statement_rules), + statement_rules=statement_rules, + max_bytes=max_bytes, + timeout_seconds=120, ) diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py index c1e6615..96f68d1 100644 --- a/api/tests/unit/parser/test_readers.py +++ b/api/tests/unit/parser/test_readers.py @@ -15,7 +15,7 @@ from openpyxl.styles import Alignment, Font from src.contract import ErrorCode from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError from src.parser.grid import EMPTY_CELL, Cell, MergedRange -from src.parser.readers import read_book +from src.parser.readers import read_book, read_book_detected from src.parser.readers import xlsb as xlsb_reader from src.parser.readers._common import make_cell from src.parser.readers.xls import NO_CODEPAGE_WARNING @@ -91,11 +91,12 @@ def test_xlsx_reader_builds_sheet_model() -> None: assert sheets[1].row_cells(1) == [(1, Cell("x", "text"))] -def test_file_type_mismatch_reads_by_signature_with_warning() -> None: - sheets, warnings = read_book(_synthetic_xlsx(), "XLS") +def test_file_type_mismatch_reads_by_signature_and_reports_mismatch() -> None: + read = read_book_detected(_synthetic_xlsx(), "XLS") - assert [sheet.name for sheet in sheets] == ["ОСВ", "Прочее"] - assert warnings == ["file_type=XLS, но содержимое OOXML: прочитано как XLSX"] + assert [sheet.name for sheet in read.sheets] == ["ОСВ", "Прочее"] + assert read.mismatch == "file_type=XLS, но содержимое OOXML: прочитано как XLSX" + assert read.warnings == [] # несовпадение — не заметка читателя: книга делает из него предупреждение файла def test_garbage_bytes_are_unsupported_format() -> None: @@ -217,20 +218,23 @@ def test_integral_float_is_read_without_fraction(value: float, expected_text: st _CYRILLIC_CELLS = {(1, 1): "Оборотно-сальдовая ведомость", (2, 2): Decimal("12.5")} -def test_xls_without_codepage_is_read_as_cp1251_with_warning() -> None: +def test_xls_without_codepage_is_read_as_cp1251_with_flag() -> None: data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=None) - sheets, warnings = read_book(data, "XLS") + read = read_book_detected(data, "XLS") - assert [sheet.name for sheet in sheets] == ["ОСВ 60"] - assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" - assert warnings == [NO_CODEPAGE_WARNING] + assert [sheet.name for sheet in read.sheets] == ["ОСВ 60"] + assert read.sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert read.no_codepage is True + assert read.warnings == [] + assert NO_CODEPAGE_WARNING == "xls без кодовой страницы — строки прочитаны как cp1251" def test_xls_with_codepage_is_read_as_before_without_warning() -> None: data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=1251) - sheets, warnings = read_book(data, "XLS") + read = read_book_detected(data, "XLS") - assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" - assert warnings == [] + assert read.sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert read.no_codepage is False + assert read.warnings == [] diff --git a/api/tests/unit/parser/test_statement_rules.py b/api/tests/unit/parser/test_statement_rules.py deleted file mode 100644 index 3bef6b5..0000000 --- a/api/tests/unit/parser/test_statement_rules.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Словари форм (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 e7cc79e..cbad8b9 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.1. +"""Контракт обмена с сервисами распознавания: результат и конверт v2.0, запрос v1.0. Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53); @@ -156,12 +156,11 @@ def test_example_passes_model_and_round_trips(name: str) -> None: def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None: result = RecognitionResult.model_validate(_example("result-statement-minimal.json")) values = result.documents[0].lines[1].values # type: ignore[union-attr] - assert values[0].amount == Decimal("5678.00") - assert values[1].amount == Decimal("-12.30") - assert isinstance(values[1].amount, Decimal) + assert values[0].amount == Decimal("428311") + assert isinstance(values[0].amount, Decimal) wire = result.to_wire() - assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30" - assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло + assert wire["documents"][0]["lines"][1]["values"][0]["amount"] == "428311" # без «.00» — как пришло + assert wire["documents"][0]["lines"][3]["values"][0]["amount"] == "428411" def test_osv_amount_keeps_kopecks() -> None: @@ -249,8 +248,9 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("message/status-unknown", "result-message-done.json", _set("status", "queued"), False), ("message/no-service", "result-message-processing.json", _drop("service"), False), ("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False), - ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False), - ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True), + ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), True), + ("message/schema_version-2.7", "result-message-processing.json", _set("schema_version", "2.7"), True), + ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), False), ("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False), ("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False), ("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True), @@ -331,8 +331,9 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("result/error-null", "result-error.json", _set("error", None), True), ("result/error-empty-object", "result-error.json", _set("error", {}), True), ("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False), - ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False), - ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True), + ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), True), + ("result/schema_version-2.3", "result-osv-minimal.json", _set("schema_version", "2.3"), True), + ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), False), ("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False), ("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False), ("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False), @@ -545,6 +546,168 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ False, ), ("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True), + # --- v2.0: строки, предупреждения, проверки, версия общего кода --- + ("result/v2-excel-base", "result-statement-excel.json", lambda p: None, True), + ( + "result/v2-unreadable-with-code", + "result-statement-minimal.json", + _set("documents.0.lines.2.line_code", "1240"), + False, + ), + ("result/v2-ok-without-code", "result-statement-minimal.json", _set("documents.0.lines.0.line_code", None), False), + ( + "result/v2-line-code-3-digits", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_code", "120"), + False, + ), + ( + "result/v2-detail-without-parent_ref", + "result-statement-excel.json", + _drop("documents.1.lines.1.parent_ref"), + False, + ), + ("result/v2-line-without-ref", "result-statement-minimal.json", _drop("documents.0.lines.0.ref"), False), + ("result/v2-line-empty-ref", "result-statement-minimal.json", _set("documents.0.lines.0.ref", ""), False), + ("result/v2-row_index-negative", "result-statement-minimal.json", _set("documents.0.lines.0.row_index", -1), False), + ( + "result/v2-line-status-unknown", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_status", "bad"), + False, + ), + ( + "result/v2-value-without-text", + "result-statement-minimal.json", + _drop("documents.0.lines.0.values.0.text"), + False, + ), + ( + "result/v2-value-text-null", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.text", None), + True, + ), + ( + "result/v2-value-without-needs_review", + "result-statement-minimal.json", + _drop("documents.0.lines.0.values.0.needs_review"), + False, + ), + ("result/v2-no-statements_version", "result-statement-minimal.json", _drop("service.statements_version"), False), + ( + "result/v2-statements_version-empty", + "result-statement-minimal.json", + _set("service.statements_version", ""), + False, + ), + ("result/v2-osv-no-statements_version", "result-osv-minimal.json", lambda p: None, True), + ("result/v2-osv-no-status", "result-osv-minimal.json", _drop("documents.0.status"), False), + ("result/v2-osv-no-warnings", "result-osv-minimal.json", _drop("documents.0.warnings"), False), + ("result/v2-osv-status-error", "result-osv-minimal.json", _set("documents.0.status", "error"), False), + ("result/v2-form_variant-missing", "result-statement-minimal.json", _drop("documents.0.form_variant"), False), + ("result/v2-form_variant-bad", "result-statement-minimal.json", _set("documents.0.form_variant", "short"), False), + ("result/v2-type-OTHER", "result-statement-minimal.json", _set("documents.0.document_type", "OTHER"), False), + ("result/v2-no-checks-with-lines", "result-statement-minimal.json", _drop("documents.0.checks"), False), + ( + "result/v2-checks-without-lines", + "result-statement-excel.json", + lambda p: p["documents"][2].update(checks={"relations_failed": [], "soft_relations_failed": []}), + False, + ), + ( + "result/v2-checks-incomplete", + "result-statement-minimal.json", + _drop("documents.0.checks.soft_relations_failed"), + False, + ), + ( + "result/v2-relation-codes-empty", + "result-statement-excel.json", + _set("documents.0.checks.soft_relations_failed.0.codes", []), + False, + ), + ( + "result/v2-relation-code-bad", + "result-statement-excel.json", + _set("documents.0.checks.soft_relations_failed.0.codes", ["12"]), + False, + ), + ( + "result/v2-relation-expected-null", + "result-statement-excel.json", + _set("documents.0.checks.soft_relations_failed.0.expected", None), + True, + ), + ( + "result/v2-period_kind-bad", + "result-statement-minimal.json", + _set("documents.0.columns.0.period_kind", "past"), + False, + ), + ( + "result/v2-period_kind-relative", + "result-statement-minimal.json", + _set("documents.0.columns.0.period_kind", "relative:prev_year_end"), + True, + ), + ( + "result/v2-column_index-negative", + "result-statement-minimal.json", + _set("documents.0.columns.0.column_index", -1), + False, + ), + ("result/v2-inn-bad", "result-statement-minimal.json", _set("documents.0.header.inn", "123"), False), + ("result/v2-inn-12", "result-statement-minimal.json", _set("documents.0.header.inn", "770000000000"), True), + ( + "result/v2-needs_review_fields-unknown", + "result-statement-minimal.json", + _set("documents.0.header.needs_review_fields", ["fax"]), + False, + ), + ( + "result/v2-needs_review_fields-duplicate", + "result-statement-minimal.json", + _set("documents.0.header.needs_review_fields", ["inn", "inn"]), + False, + ), + ( + "result/v2-warning-code-lowercase", + "result-statement-minimal.json", + _set("documents.0.warnings.0.code", "abc"), + False, + ), + ( + "result/v2-warning-severity-bad", + "result-statement-minimal.json", + _set("documents.0.warnings.0.severity", "warning"), + False, + ), + ( + "result/v2-warning-field-unknown", + "result-statement-minimal.json", + _set("documents.0.warnings.0.field", "fax"), + False, + ), + ( + "result/v2-warning-line_refs-null", + "result-statement-minimal.json", + _set("documents.0.warnings.1.line_refs", None), + False, + ), + ("result/v2-metrics-warnings-strings", "result-statement-minimal.json", _set("metrics.warnings", ["x"]), False), + ( + "result/v2-metrics-warnings-file", + "result-statement-minimal.json", + _set("metrics.warnings", [{"code": "SHEET_OTHER", "severity": "info", "message": "m", "locator": "sheet:A"}]), + True, + ), + ( + "result/v2-metrics-warnings-locator-null", + "result-statement-minimal.json", + _set("metrics.warnings", [{"code": "SHEET_OTHER", "severity": "info", "message": "m", "locator": None}]), + False, + ), ] @@ -599,7 +762,7 @@ def test_message_status_enum_values() -> None: def test_contract_version_constant() -> None: - assert CONTRACT_VERSION == "1.2" + assert CONTRACT_VERSION == "2.0" 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 deleted file mode 100644 index 2480420..0000000 --- a/api/tests/unit/test_contract_v12.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Контракт 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_contract_v20.py b/api/tests/unit/test_contract_v20.py new file mode 100644 index 0000000..8392562 --- /dev/null +++ b/api/tests/unit/test_contract_v20.py @@ -0,0 +1,185 @@ +"""Контракт v2.0 (RSHB/arm-ka-docs#116): модели результата и конверта v2, запрос остаётся v1.""" + +import datetime +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from src.contract import CONTRACT_VERSION, RecognitionRequest, RecognitionResult +from src.contract.common import REQUEST_SCHEMA_VERSION_PATTERN, SCHEMA_VERSION_PATTERN +from src.contract.result import ( + FailedRelation, + OsvHeader, + StatementChecks, + StatementDocument, + StatementLine, +) + +EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples" +RESULT_EXAMPLES = ( + "result-osv-minimal", + "result-osv-needs-check", + "result-statement-excel", + "result-statement-minimal", + "result-error", +) + + +def _load(name: str) -> dict[str, Any]: + data = json.loads((EXAMPLES_DIR / f"{name}.json").read_text(encoding="utf-8")) + assert isinstance(data, dict) + return data + + +def test_docs_examples_validate_with_models() -> None: + for name in RESULT_EXAMPLES: + payload = _load(name) + assert RecognitionResult.model_validate(payload).to_wire() == payload + + +def test_request_stays_v1_and_result_is_v2() -> None: + assert CONTRACT_VERSION == "2.0" + assert SCHEMA_VERSION_PATTERN == r"^2\.\d+$" + assert REQUEST_SCHEMA_VERSION_PATTERN == r"^1\.\d+$" + request = RecognitionRequest.model_validate(_load("request-tabular")) + assert request.schema_version == "1.0" + with pytest.raises(ValidationError): + RecognitionRequest.model_validate({**_load("request-tabular"), "schema_version": "2.0"}) + + +def test_statement_without_statements_version_is_rejected() -> None: + payload = _load("result-statement-excel") + del payload["service"]["statements_version"] + with pytest.raises(ValidationError, match="statements_version"): + RecognitionResult.model_validate(payload) + + +def test_osv_only_result_does_not_need_statements_version() -> None: + payload = _load("result-osv-minimal") + assert "statements_version" not in payload["service"] + RecognitionResult.model_validate(payload) + + +def test_unreadable_line_with_code_is_rejected() -> None: + """Спека «Строка без кода с кодом в поле»: `line_code` не `null` ⇔ `line_status = ok`.""" + line = _load("result-statement-minimal")["documents"][0]["lines"][2] + assert line["line_status"] == "unreadable" and line["line_code"] is None + line["line_code"] = "1240" + with pytest.raises(ValidationError, match="line_code"): + StatementLine.model_validate(line) + ok_line = {**line, "line_status": "ok", "line_code": None} + with pytest.raises(ValidationError, match="line_code"): + StatementLine.model_validate(ok_line) + + +def test_detail_line_requires_parent_ref() -> None: + line = _load("result-statement-excel")["documents"][1]["lines"][1] + assert line["line_status"] == "detail" + del line["parent_ref"] + with pytest.raises(ValidationError, match="parent_ref"): + StatementLine.model_validate(line) + + +def test_checks_required_for_balance_with_lines_and_forbidden_without() -> None: + document = _load("result-statement-minimal")["documents"][0] + without_checks = {k: v for k, v in document.items() if k != "checks"} + with pytest.raises(ValidationError, match="checks"): + StatementDocument.model_validate(without_checks) + empty = {**document, "lines": [], "checks": {"relations_failed": [], "soft_relations_failed": []}} + with pytest.raises(ValidationError, match="checks"): + StatementDocument.model_validate(empty) + disclosure = {**document, "document_type": "DISCLOSURE", "form_variant": None, "lines": []} + disclosure.pop("checks") + StatementDocument.model_validate(disclosure) + + +def test_form_variant_is_required_and_other_type_is_gone() -> None: + document = _load("result-statement-minimal")["documents"][0] + with pytest.raises(ValidationError): + StatementDocument.model_validate({k: v for k, v in document.items() if k != "form_variant"}) + with pytest.raises(ValidationError): + StatementDocument.model_validate({**document, "document_type": "OTHER"}) + + +def test_osv_document_requires_status_and_warnings() -> None: + payload = _load("result-osv-minimal") + for key in ("status", "warnings"): + broken = json.loads(json.dumps(payload)) + del broken["documents"][0][key] + with pytest.raises(ValidationError): + RecognitionResult.model_validate(broken) + + +def test_failed_relation_has_codes_and_nullable_amounts() -> None: + relation = FailedRelation.model_validate( + { + "column_index": 3, + "relation": "2200 = 2100 - 2210 - 2220", + "codes": ["2200", "2100", "2210", "2220"], + "expected": None, + "actual": "400.00", + } + ) + assert relation.expected is None and relation.actual == Decimal("400.00") + with pytest.raises(ValidationError): + FailedRelation.model_validate( + {"column_index": 3, "relation": "x", "codes": [], "expected": None, "actual": None} + ) + with pytest.raises(ValidationError): + FailedRelation.model_validate( + {"column_index": 3, "relation": "x", "codes": ["12"], "expected": None, "actual": None} + ) + + +def test_statement_checks_have_no_duplicate_line_codes() -> None: + checks = StatementChecks.model_validate({"relations_failed": [], "soft_relations_failed": []}) + assert "duplicate_line_codes" not in StatementChecks.model_fields + assert checks.to_wire() == {"relations_failed": [], "soft_relations_failed": []} + + +def test_warning_code_is_free_text_by_pattern_and_severity_is_closed() -> None: + payload = _load("result-statement-minimal") + payload["documents"][0]["warnings"][0]["code"] = "NEW_CODE_2_1" + RecognitionResult.model_validate(payload) + payload["documents"][0]["warnings"][0]["code"] = "lowercase" + with pytest.raises(ValidationError): + RecognitionResult.model_validate(payload) + payload["documents"][0]["warnings"][0].update(code="OK_CODE", severity="warning") + with pytest.raises(ValidationError): + RecognitionResult.model_validate(payload) + + +def test_metrics_warnings_are_structures() -> None: + payload = _load("result-osv-minimal") + payload["metrics"] = {"warnings": ["строка"]} + with pytest.raises(ValidationError): + RecognitionResult.model_validate(payload) + payload["metrics"]["warnings"] = [ + {"code": "SHEET_OTHER", "severity": "info", "message": "лист", "locator": "sheet:Прочее"} + ] + assert RecognitionResult.model_validate(payload).to_wire()["metrics"]["warnings"][0]["locator"] == "sheet:Прочее" + + +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_rules_coverage_helpers.py b/api/tests/unit/test_rules_coverage_helpers.py index 0431f5a..2888175 100644 --- a/api/tests/unit/test_rules_coverage_helpers.py +++ b/api/tests/unit/test_rules_coverage_helpers.py @@ -22,8 +22,6 @@ 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 b75ed8a..35b8f8d 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -18,22 +18,25 @@ from jsonschema import Draft202012Validator, FormatChecker from loguru import logger from referencing import Registry, Resource +from arm_statements import RulesError from src.contract import ( NATS_MSG_ID_HEADER, RESULT_SUBJECT, ErrorCode, FileType, + FileWarning, MessageError, Metrics, OsvDocument, RecognitionResult, RecognitionResultMessage, ResultStatus, + StatementDocument, ) from src.core.config import NATSSettings from src.core.nats import NatsConnection, NatsPublishError from src.core.worker import RecognitionWorker, result_key -from src.parser import NullParser, ParseOutcome, get_parser +from src.parser import NullParser, ParseOutcome, get_parser, require_statements_version API_DIR = Path(__file__).resolve().parents[2] CONTRACTS_DIR = API_DIR / "contracts" @@ -45,7 +48,7 @@ FILE_ID = REQUEST["file_id"] BUCKET = REQUEST["s3"]["bucket"] SOURCE_KEY = REQUEST["s3"]["key"] RESULT_KEY = f"results/{FILE_ID}/{JOB_ID}/result.json" -SERVICE = {"name": "tabular-parser", "version": "9.9.9"} +SERVICE = {"name": "tabular-parser", "version": "9.9.9", "statements_version": "abc1234"} OTHER_JOB_ID = "11111111-2222-4333-8444-555555555555" PARSER_ROWS = 3 # metrics.rows, которые возвращает подменённый парсер @@ -192,6 +195,7 @@ class Env: parser=parser if parser is not None else NullParser(), service_name=SERVICE["name"], service_version=SERVICE["version"], + statements_version=SERVICE["statements_version"], concurrency=concurrency, done_cache_size=done_cache_size, clock=FakeClock(), @@ -253,7 +257,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.2", + "schema_version": "2.0", "job_id": JOB_ID, "file_id": FILE_ID, "status": "processing", @@ -390,7 +394,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.2" + assert result["schema_version"] == "2.0" assert result["job_id"] == JOB_ID assert result["file_id"] == FILE_ID assert result["error"]["code"] == "SCHEMA_MISMATCH" @@ -525,7 +529,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.2" + assert envelope["result_schema_version"] == "2.0" assert "error" not in envelope assert envelope["metrics"]["rows"] == PARSER_ROWS assert envelope["metrics"]["duration_ms"] >= 0 @@ -557,7 +561,9 @@ async def test_error_outcome_metrics_are_kept_and_duration_added() -> None: return ParseOutcome( status=ResultStatus.ERROR, error=MessageError(code=ErrorCode.INVALID_FORMAT, message="не ОСВ"), - metrics=Metrics(warnings=["лист пуст"]), + metrics=Metrics( + warnings=[FileWarning(code="SHEET_OTHER", severity="info", message="лист пуст", locator="sheet:Л")] + ), ) environment = await _make_env(ErrorWithMetricsParser()) @@ -570,7 +576,9 @@ async def test_error_outcome_metrics_are_kept_and_duration_added() -> None: envelope = environment.bodies()[1] _assert_message_valid(envelope) assert envelope["error"] == {"code": "INVALID_FORMAT", "message": "не ОСВ"} - assert envelope["metrics"]["warnings"] == ["лист пуст"] + assert envelope["metrics"]["warnings"] == [ + {"code": "SHEET_OTHER", "severity": "info", "message": "лист пуст", "locator": "sheet:Л"} + ] assert envelope["metrics"]["duration_ms"] >= 0 @@ -682,6 +690,7 @@ async def test_result_publish_is_retried_with_same_msg_id_through_nats_connectio parser=NullParser(), service_name=SERVICE["name"], service_version=SERVICE["version"], + statements_version=SERVICE["statements_version"], ) worker.start() try: @@ -869,10 +878,73 @@ 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.2" + assert envelope["result_schema_version"] == "2.0" 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)] == [] assert RecognitionResult.model_validate(stored).to_wire() == stored assert [document["document_type"] for document in stored["documents"]] == ["OSV"] assert stored["documents"][0]["header"]["account_code_raw"] == "66" + + +# --- версия общего кода в результате (контракт 2.0) ----------------------------------------------------------------- +async def test_result_carries_statements_version(env: Env) -> None: + """Spec «Версия общего кода в результате»: `service.statements_version` — в ack, конверте и файле результата; + запрос 1.0 принимается (запрос остаётся v1), запрос 2.0 — `SCHEMA_MISMATCH` без ack.""" + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + assert [body["schema_version"] for body in env.bodies()] == ["2.0", "2.0"] + assert all(body["service"]["statements_version"] == "abc1234" for body in env.bodies()) + + +async def test_worker_with_form_result_has_statements_version_in_result_file() -> None: + document = StatementDocument.model_validate( + { + "document_type": "DISCLOSURE", + "form_variant": None, + "locator": "sheet:Пояснения", + "status": "done", + "warnings": [], + "header": {"form_name": "Пояснения"}, + "columns": [], + "lines": [], + } + ) + parser = SimpleNamespace(parse=lambda data, file_type: ParseOutcome(ResultStatus.DONE, documents=(document,))) + environment = Env(parser) + environment.worker.start() + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + stored = environment.storage.json_objects[(BUCKET, RESULT_KEY)] + assert stored["service"]["statements_version"] == "abc1234" + assert [error.message for error in VALIDATORS["result"].iter_errors(stored)] == [] + + +def test_require_statements_version_fails_without_file(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="VERSION"): + require_statements_version(tmp_path / "VERSION") + + +def test_require_statements_version_reads_the_marker(tmp_path: Path) -> None: + marker = tmp_path / "VERSION" + marker.write_text("abc1234\n", encoding="utf-8") + + assert require_statements_version(marker) == "abc1234" + assert require_statements_version() # копия пакета в репозитории размечена sync-statements + + +def test_get_parser_wraps_rules_error_into_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None: + """I-2(в): ошибка словарей пакета при старте — `RuntimeError`, сервис не стартует.""" + + def broken() -> object: + raise RulesError("битый словарь форм") + + monkeypatch.setattr("src.parser.default_rules", broken) + + with pytest.raises(RuntimeError, match="битый словарь форм"): + get_parser() diff --git a/openspec/changes/unified-normalization-tp/design.md b/openspec/changes/unified-normalization-tp/design.md new file mode 100644 index 0000000..c7a5ec9 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/design.md @@ -0,0 +1,122 @@ +# Design: unified-normalization-tp + +## Контекст + +Change переносит в tabular-parser требования общей нормализации ББ/ОФР: шаг 1 (кандидат в форму из книги Excel) +пишет адаптер сервиса, шаги 2–8 выполняет копия пакета `arm_statements`, результат и конверт — контракт v2.0. +Дизайн и решения Р-1…Р-24 — change `unified-statement-normalization` в arm-ka-docs (issue RSHB/arm-ka-docs#116); +реализация — план 4 (`plans/2026-10-18-plan-4-tabular-parser.md` в docs), issue RSHB/arm-tabular-parser#54, ветка +`feat/54-statements-v2`. + +Номера P4-n — решения автора плана, R-n — рулинги контроллера при исполнении (нумерация сквозная, R-3 не присваивался). +Дата всех решений ниже — 18.10.2026. + +## Decisions made without discussion + +### Решения автора плана (P4-n) + +- **P4-1.** Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и + воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или + наоборот) валят `result_contract` и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до + удаления старого кода; между задачами 7 и 8 проверка идёт с `ARM_KA_FIXTURES=/nonexistent`. Цена: одна крупная задача и + окно в одну задачу без проверки на реальных файлах. +- **P4-2.** Адаптер пишется новыми модулями рядом со старыми (`cells`, `forms`, `header_cells`, `adapter`, `contract`, + `pipeline`); старые `statements/*` не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики + поиска блоков и шапки. +- **P4-3.** `Cell.text` числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель + групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; + `General`/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — `dd.mm.yyyy`; + логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; `value` и статус + от этого не зависят (у Excel значение берётся из `value`). Совпадает с примером docs + `result-statement-excel.json` (`"586 260,26"`). +- **P4-4.** Показываемое число вне `numeric(18,2)` больше не делает формат «неразобранным»: адаптер отдаёт `Decimal`, а + `VALUE_OUT_OF_RANGE` и `amount = null` выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 + целых цифр (на реальных файлах их нет). +- **P4-5.** Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, + прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна + пакету только в левом. +- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета: + то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке + подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся. +- **P4-7.** Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари» + раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний + `normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер + продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер. +- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только + по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое — + столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов, + сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее + столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`). + Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом + колонок, присоединится и будет сопоставлен пакетом по датам. +- **P4-9.** `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без + `type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов). +- **P4-10.** `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с + перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`). +- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так проверяется дедлайн между + кандидатами (`header.form_name` пакет ставит сам из `evidence.title`, пара «кандидат → документ» не нужна). + Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС). + Цена: одна сломанная форма роняет книгу с ОСВ. +- **P4-12.** Коды ОСВ — по источнику предупреждения, не по тексту: `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`, + `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, + `OSV_XLSB_NO_OUTLINE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и + `OSV_ROWS` разные случаи различаются только текстом. +- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» — + `XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в + каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ). + Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ. +- **P4-14.** Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а + текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными. +- **P4-15.** Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений + (`glue.ok`, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире. +- **P4-16.** Нет `api/arm_statements/VERSION` — сервис не стартует (`RuntimeError`), как при ошибке словаря; в golden + `service.statements_version` — volatile-поле. Цена: копия без `VERSION` не запустится даже локально. +- **P4-17.** Новые синтетические случаи (форма xlsb, книга из пояснений, конфликты продолжений) — в памяти в юнит-тестах + (`sheet_from_rows`, `write_xlsb`), новых файлов в `fixtures/statements-xlsx/synthetic` нет; PR fixtures — только golden. +- **P4-18.** Манифест получает необязательное поле `error_warnings` (точное множество кодов `error` документа); + проверка уникальности кодов строк снята (повторы законны, Р-4). Цена: при правке пакета, меняющей набор + предупреждений, манифест правится вместе с golden. +- **P4-19.** Тест покрытия словарей tabular-parser больше не охватывает словари форм — они в пакете. Цена: «мёртвый» + синоним заголовка или метки шапки на реальных файлах не ловится (O-8). +- **P4-20.** В tabular-parser — свой change `unified-normalization-tp` с дельтами `statement-parsing`, + `tabular-recognition-service`, `osv-parsing` (по образцу P0-5); требование «Копия контракта идентична источнику» правит + план 0, здесь не трогается. +- **P4-21.** Golden — отдельные issue, ветка и PR в RSHB/arm-ka-fixtures (один PR на репозиторий); сливается сразу за PR + tabular-parser. Цена: короткое окно, когда `make check` `main` одного из них красный на golden. +- **P4-22.** Две формы на одном листе дают два документа с одинаковым `locator` (спека уникальности `locator` не требует; + `ref` строк различаются). + +### Рулинги контроллера при исполнении (R-n) + +- **R-1.** docs-worktree отсоединён на origin/main (docs #117 слит), а не на feat/116 (П-1/П-5 плана). Цена ошибки: нет. +- **R-2.** Предупреждение tp #47 «xls без кодовой страницы…» — `XLS_NO_CODEPAGE` пакета (info, файл, без locator); нового + `OSV_*`-кода нет; текст сообщения reader'а tp #47 сохранён; передаётся флагом `BookRead.no_codepage`. Цена: правка + `book.py` и golden. +- **R-4.** Задача 1 (markdown-дельты) без отдельного task-ревью — проверена `openspec validate --strict` и финальным + ревью ветки. Цена: неточность формулировки дельты. +- **R-5.** Проверка диапазона numeric(18,2) в `display_amount` не возвращается (P4-4); диапазон и `VALUE_OUT_OF_RANGE` + выдаёт пакет; старый конвейер удалён задачей 9. Цена: нет после удаления. +- **R-6.** Для |raw| ≥ 1e15 текст/значение числа без округления до 15 значащих цифр (иначе число из 17 цифр теряет + цифры); пробел групп — обычный U+0020 как в примере docs `result-statement-excel.json`. Цена: расхождение с NBSP + русского Excel, на value/статус не влияет. +- **R-7.** Ячейки из одних цифр не считаются подзаголовком даты `report_date` (иначе год из тройки «число|месяц|год» + принимается за подзаголовок), как в прежнем `header.py`. Цена: подзаголовок из голого числа «2025» не распознаётся. +- **R-8.** Ячейка в строке-шапке таблицы (в строке есть подпись «Код» по словарю пакета `code_caption`) не заголовок + формы: подпись колонки «Пояснения» (`'^пояснени'` в `form_titles` пакета) делала лист-продолжение НПЗ ещё одной формой + DISCLOSURE. Дефект адаптера (спека называет «Пояснения» подписью колонки), пакет не правится. Решение владельца: + оставить, посмотреть позже. Цена: компактная шапка, где заголовок формы и «Код» в одной строке, потеряет заголовок. +- **R-9.** Предупреждения адаптера при кандидате без документа — предупреждения файла со своим `locator`; порядок + `metrics.warnings` — сначала reader, затем по номеру первого листа; `XLS_NO_CODEPAGE` и `OSV_XLSB_NO_OUTLINE` — флаги + `BookRead` (`no_codepage`, `no_outline`). Цена: правка `book.py` и golden. +- **R-10.** НПЗ, ОФР, строки «Реализация иного имущества, прав требования, прочих работ и услуг» (F34, F44): значения 0 + с форматом `[=0]"-"` — только прочерки, суммы нет; по спеке «строка без кода и без содержимого — не строка данных», + пакет её не выдаёт; значение прочерка не превращаем в 0 (сломало бы различие null/0 и соотношения §7). + `test_npz_documents` не требует detail/parent_ref 2340. Вопрос владельцу O-8.2: сценарий спеки «Реализация иного + имущества… с суммами» не соответствует файлу. Цена: строки из прочерков не видны аналитику. +- **R-11.** Задача 9 (удаление по списку) без отдельного task-ревью — покрыта финальным ревью ветки. +- **R-12.** Остановка Р-31 по Межениновской (посторонние формулы аналитика в столбцах U–W) снята владельцем 18.10.2026: + правило в пакете (docs #123, Р-44; arm-statements #6, 3390f92) — столбец без подписи правее последней подписанной + колонки с числами не колонка сумм, предупреждение `NUMBERS_OUTSIDE_TABLE` (info). Копия пакета обновлена коммитом + f5d071f. Цена: нет. +- **R-13.** Ложная склейка/дедлайн: `plan_sheets` получает `deadline` (финальное ревью I-1, регрессия таймаута против + main). Цена: нет. diff --git a/openspec/changes/unified-normalization-tp/proposal.md b/openspec/changes/unified-normalization-tp/proposal.md new file mode 100644 index 0000000..d915274 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/proposal.md @@ -0,0 +1,40 @@ +## Why + +Формы ББ/ОФР из Excel разбираются собственным конвейером tabular-parser (раскладка, отбор строк, значения, проверки §7, +статус), отличным от конвейера scan-gateway; ядро не доверяет ни одному и пересчитывает результат по косвенным признакам +(строки без кода молча теряются, повтор кода теряет сумму, предупреждения — плоские строки). Change `unified-statement-normalization` +в arm-ka-docs (issue RSHB/arm-ka-docs#116, дизайн с решениями Р-1…Р-24) приводит оба сервиса к одной цепочке шагов 1–9: +шаг 1 (кандидат в форму) пишет адаптер источника, шаги 2–8 выполняет общий пакет `arm_statements`, результат и конверт — +контракт v2.0. Этот change переносит требования в tabular-parser; реализация — план 4 +(`plans/2026-10-18-plan-4-tabular-parser.md` в docs), issue RSHB/arm-tabular-parser#54. + +## What Changes + +- **statement-parsing:** + - **MODIFIED** «Листы форм и их продолжения находятся по содержимому» — классификация ОСВ → форма → продолжение → + прочее, слабое продолжение только сразу после листа формы, лист «прочее» даёт `SHEET_OTHER`; + - **MODIFIED** «Шапка документа» — поля шапки блока как `Cell` с `confidence = 100` в границах блока, разбор текста — общим кодом; + - **MODIFIED** «Склейка листов-продолжений и статус документа» — склейка до раскладки, `segment_breaks`, + `CONTINUATION_TYPE_CONFLICT`, `SHEET_NOT_JOINED`; статус документа считает функция пакета; + - **MODIFIED** «Словари форм, координаты, golden» — словари форм живут в пакете, координат в коде нет, golden и манифест; + - **REMOVED** «Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения», + «Соотношения §7 — данные, проверка по колонкам» — шаги 2–8 выполняет копия `arm_statements`; + - **ADDED** «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные колонки в Excel», + «Манифест ожиданий на реальных формах», «Статус книги». +- **tabular-recognition-service:** **MODIFIED** «Результат по контракту» — файл результата и конверт v2.0, структурные + `metrics.warnings`, `service.statements_version`, ack `processing` — `schema_version = "2.0"`, запрос остаётся 1.0. +- **osv-parsing:** **MODIFIED** «Контрольные суммы определяют статус» (`status` листа и `warnings[]` с кодами `OSV_*` и + `severity = info` в документе, строк `sheet=<имя> …` в `metrics.warnings` нет) и «Форматы, лимиты, время» + (несоответствие `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` `info`). Остальной разбор ОСВ не меняется. +- **BREAKING** для потребителей: файл результата и конверт — MAJOR 2.0 (`line_code` nullable, структурные + предупреждения, нет `checks.duplicate_line_codes`); слияние PR — только после выкладки ядра, принимающего 2.0. + +## Impact + +- Код: `api/src/parser/statements/{cells,forms,header_cells,adapter,pipeline}.py` (новые), `grid.py`, `detect.py`, + `book.py`, `readers/`, `osv.py`, `checks.py`, `osv_warnings.py`; `api/src/contract/**` (модели v2.0), `api/contracts/**` + (копия схем v2.0), `api/src/core/worker.py`, `api/src/app.py`; удаляются `statements/{checks,columns,detect,header,lines,merge,statement}.py` + и `api/rules/statement_{headers,periods,checks}.yaml`. +- Копия пакета `api/arm_statements/` (раскладывает `sync-statements`) — не правится. +- Тесты: манифест `api/tests/statements_manifest.yaml`; golden `fixtures/golden/expected_{osv,statement}_parse/**` (отдельный PR fixtures, arm-tabular-parser#54). +- Спеки: `statement-parsing`, `tabular-recognition-service`, `osv-parsing`. Контракт — `docs/contracts/` v2.0. diff --git a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md new file mode 100644 index 0000000..faba9a1 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md @@ -0,0 +1,200 @@ +## MODIFIED Requirements + +### Requirement: Контрольные суммы определяют статус +Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit − +turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же +`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у +родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто, +а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами +родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ, +R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в +`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root, +balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы +§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое +нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть +структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, +`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, +`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять +структуру под сходимость. + +Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут +только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` +(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если +код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом +(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель» +сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у +родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно. + +Остаточные риски (известные слепые зоны, не ловятся проверками §1.4): +- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом + (перестановка сторон при совпавшем нетто) не проверяется. +- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна + сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало. +- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт → + субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится. +- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто + строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже). +- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его + непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто, + а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются). +- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0 + (закреплено тестом как осознанное поведение R-21). +- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет + `group_type` строк ветки; суммы и проверки это не затрагивает. + +#### Scenario: Все реальные файлы сходятся +- **WHEN** разбирается каждый файл `fixtures/osv/**` +- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты + +#### Scenario: Испорченная сумма +- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1 +- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` — + `row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк + +#### Scenario: Сальдо родителя свёрнуто нетто +- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя +- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто + или расхождение оборотов — нарушение + +#### Scenario: Счёт 91 — аналитика только с оборотами +- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на + субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`) +- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а + расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением + +#### Scenario: «Итого» не найдена +- **WHEN** в таблице нет строки с текстом из словаря «Итого» +- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена» + +#### Scenario: Пустая ОСВ +- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2 + квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет +- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL), + `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение + `OSV_NO_DATA_ROWS` «нет строк данных» + +#### Scenario: Предупреждение ОСВ +- **WHEN** на листе ОСВ есть строка без родителя +- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6; + в `metrics.warnings` конверта строки `sheet=…` нет + +### Requirement: Форматы, лимиты, время +Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен — +каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`. +Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова +библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма +объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×); +суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl +потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге, +без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без +префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении); +для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше +`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше +`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части +`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING); +превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка +таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`; +непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный +риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в +пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл +`fixtures/osv/**` SHALL разбираться быстрее 5 с. + +#### Scenario: XLSB +- **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7) +- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения документа `OSV_XLSB_NO_OUTLINE` + («xlsb: outline level недоступен», `info`); `hierarchy=indent` + +#### Scenario: Расширение не совпадает с содержимым +- **WHEN** запрос говорит `XLSX`, а байты — BIFF8 +- **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому + +#### Scenario: xls без кодовой страницы +- **WHEN** разбирается xls без записи CODEPAGE (синтетика, `codepage=None`) +- **THEN** строки прочитаны как cp1251, в `metrics.warnings` — предупреждение файла `XLS_NO_CODEPAGE` (`info`, без + `locator`; код пакета, `OSV_`-кода для него нет); статус по содержимому + +#### Scenario: Превышен лимит +- **WHEN** передан файл в 51 МиБ +- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается + +#### Scenario: Бомба объединённых ячеек +- **WHEN** передан xlsx в несколько килобайт с `` (5,2 млн ячеек) +- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается + +#### Scenario: Обходы скана объединённых ячеек +- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в + `workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `ref` стоит атрибут + с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`) +- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с + малым диапазоном читается, и объединение видно в листе + +#### Scenario: Объединение на весь столбец в xls +- **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`) +- **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает) + +#### Scenario: Распакованный zip больше лимита +- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES` +- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается + +### Requirement: Листы ОСВ находятся по содержимому +Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости +всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и +дефисов). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`); прочие листы +SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в +`metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` +(«в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. +Имя листа MUST NOT использоваться для поиска. + +#### Scenario: Один лист — одна ОСВ (все реальные фикстуры) +- **WHEN** разбирается любой файл из `fixtures/osv/**` +- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done` + +#### Scenario: Два листа ОСВ и посторонний лист +- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62» +- **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"` + +#### Scenario: Нет ОСВ +- **WHEN** разбирается книга с одним листом произвольной таблицы +- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов + +### Requirement: Иерархия строк — каскад стратегий +Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки +или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, +где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: +она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна +строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `warnings[]` документа листа предупреждением +`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`severity = info`); в `metrics.warnings` строк `sheet=<имя> hierarchy=…` нет. `parent_row_number` SHALL указывать на ближайшую +предыдущую строку с меньшим уровнем. + +#### Scenario: Outline level (реальные файлы) +- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных) +- **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» + `parent_row_number` — строка `66.03` + +#### Scenario: Без outline level +- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) +- **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по + кодам счетов и жирности (`s03`) + +#### Scenario: Без outline и отступов +- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) +- **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт + +#### Scenario: Пустая ОСВ — тривиальное дерево +- **WHEN** в ОСВ нет строк данных кроме «Итого» +- **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя + +### Requirement: Золотые снимки и property-тесты +Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` +(= файл результата без `job_id`, `file_id`, `service.version`, `service.statements_version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, +обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять +инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без +каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`). + +#### Scenario: Регрессия +- **WHEN** правка парсера меняет `level` одной строки в любом реальном файле +- **THEN** golden-тест падает с diff по этому файлу + +#### Scenario: Новый файл без снимка +- **WHEN** в `fixtures/osv/` добавлен файл без снимка +- **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update» diff --git a/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md new file mode 100644 index 0000000..43436c7 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md @@ -0,0 +1,228 @@ +## MODIFIED Requirements + +### Requirement: Листы форм и их продолжения находятся по содержимому +Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER` и выполняться в порядке: +признак ОСВ → форма → продолжение → прочее. Признак ОСВ SHALL проверяться первым. Лист SHALL считаться `STATEMENT`, если +в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок формы или код +ОКУД из словаря заголовков пакета `arm_statements`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`, +если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее +ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм; слабое продолжение (столбец +кодов найден только ослабленным поиском пакета, не меньше `min_codes_continuation` кодов) SHALL учитываться только сразу +после листа формы. Лист без признаков ОСВ, формы и продолжения, без `type_hint` и кодов SHALL считаться `OTHER`: документа +нет, предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`). Тип формы (`type_hint`) SHALL определяться +правилом пакета: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx → `DISCLOSURE`), иначе заголовок +по словарю («Пояснения», «Расшифровка» → `DISCLOSURE`), иначе `null`. Титул «Бухгалтерская отчетность» 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`, со статусом `done`; статус книги от него не зависит + +#### Scenario: Лист «прочее» +- **WHEN** в книге лист «Примечания» без заголовка формы, ОКУД и кодов +- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"` + +### Requirement: Шапка документа +Адаптер SHALL собирать шапку каждого блока формы в границах этого блока: поля `organization_name`, `inn`, `report_date`, +`units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое `Cell` или `null`, найденные поля с `confidence = +100`. Метки поля SHALL искаться по словарю заголовков пакета в области от начала блока до строки заголовка таблицы +(у блока без кодов — во всём блоке); значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка +следующей строки. ИНН SHALL браться у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться. +`organization_name` SHALL браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком +ОПФ. Разбор текста `Cell` (ИНН — только цифры длиной 10 или 12, дата отчёта по словарю периодов, единицы по словарю +единиц или коду ОКЕИ) SHALL выполнять общий код пакета; единицы MUST NOT нормализоваться адаптером. Шапка другого блока +или другого листа MUST NOT использоваться. У документа без шапки все поля 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»; `*_confidence = 100` + +#### 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 обрабатываться по порядку; лист-продолжение SHALL дописываться в сетку открытой формы адаптером до +раскладки, граница листа — в `segment_breaks`. Открытая форма — последний кандидат с `type_hint` `BALANCE` или `INCOME`. +Лист с маркером «Форма … с.N» присоединяется по типу маркера; расхождение типа маркера с `type_hint` формы SHALL давать +отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без маркера и шапки SHALL +присоединяться, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок сумм совпадает с +формой; иначе — отдельный кандидат и предупреждение `SHEET_NOT_JOINED` (`error`). `locator` SHALL перечислять имена листов +через запятую. Продолжение без открытой формы SHALL давать отдельный кандидат без предупреждения адаптера (пакет выдаёт +`NO_ORGANIZATION_NO_INN`), документ — `needs_check`. Статус документа SHALL вычислять функция пакета (одна для сервисов и +ядра); `checks` и условие `done` — по спеке `statement-normalization`. `metrics.rows` книги SHALL быть суммой строк данных +ОСВ (без `TOTAL`) и строк `ok` документов форм (строки `detail` и без кода — отдельно). Если книга завершилась ошибкой +листа ОСВ, ответ — ошибка этого листа; список неразобранных листов форм не добавляется. + +#### Scenario: Баланс на пяти листах +- **WHEN** разбирается книга НПЗ +- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`, + одной сеткой и `segment_breaks` по границам листов, строки от 1105 до 1700 включительно, `INCOME` на трёх листах (лист + отчёта с единственной строкой 2400 присоединяется как продолжение), строка 2400 присутствует; `1600 = 1700` и + `2100`/`2200`/`2300` — в `soft_relations_failed` как округление (OQ-138); статус книги `done` + +#### Scenario: Лист-продолжение с одной кодовой строкой +- **WHEN** **сразу** после листа формы идёт лист без шапки с единственной строкой 2400 (код в диапазоне формы) +- **THEN** лист присоединяется к открытой форме (кодов меньше трёх — число колонок не сверяется), строка присутствует, + граница листа — в `segment_breaks` + +#### Scenario: Лист другой ширины с тремя кодами +- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы +- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error` + +#### Scenario: Тип маркера против формы +- **WHEN** после листа ОФР идёт лист с маркером «Форма 0710001 с.2» +- **THEN** лист — отдельный кандидат `BALANCE`, предупреждение `CONTINUATION_TYPE_CONFLICT` уровня `error` + +#### 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` выполнено, `1600 = 1700` + нарушено как «непустая цель при пустых членах» → `needs_check` + +#### Scenario: Смесь ОСВ и форм в одной книге +- **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса) +- **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим + +### Requirement: Словари форм, координаты, golden +Словари форм (заголовки и ОКУД, периоды, соотношения §7, единицы, перечни кодов) SHALL жить в пакете `arm_statements` и в +tabular-parser MUST NOT дублироваться: файлы `api/rules/statement_*.yaml` удалены. Код `src/parser/statements/**` и +`src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`); `ref` ячейки собирается из +индексов функцией `grid.cell_ref`. Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы +`statements-xlsx/**` и обновляться только `make golden-update`; `service.statements_version` — волатильное поле снимка. +Property-тест по манифесту `tests/statements_manifest.yaml` SHALL проверять тип, ИНН, ожидаемый статус, сходимость +жёстких соотношений для полных форм и, где задано, множество кодов предупреждений `error` документа. + +#### Scenario: Новый синоним без релиза кода +- **WHEN** в словарь заголовков пакета `arm-statements` добавлен синоним заголовка «Баланс предприятия» и копия пакета + обновлена скриптом `sync-statements` +- **THEN** лист классифицирован как `BALANCE` без изменения кода tabular-parser; собственного словаря форм в сервисе нет + +#### Scenario: Мёртвая запись словаря +- **WHEN** запись словаря форм не срабатывает ни на одном файле +- **THEN** это проверяет тест покрытия словарей пакета `arm-statements` (внутри пакета, на его данных); тест покрытия + tabular-parser словари форм не охватывает + +#### Scenario: Регрессия по снимкам +- **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка +- **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures + +## REMOVED Requirements + +### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту +**Reason**: Шаг 2 (раскладка: столбец кодов, строка заголовка, колонки-периоды) выполняет копия `arm_statements`. +**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 2 — столбец кодов», «Шаг 2 — строка заголовка, +нумерация и роли строк», «Шаг 2 — подпись и дата колонки». + +### Requirement: Строки с кодом и значения +**Reason**: Шаги 3–5 (строки данных, значение ячейки, строка формы, единицы) выполняет копия `arm_statements`. +**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 3 — строки данных и их статусы», «Шаг 4 — +значение ячейки», «Шаг 4 — единицы и дробные значения», «Шаг 5 — строка формы». + +### Requirement: Соотношения §7 — данные, проверка по колонкам +**Reason**: Шаг 7 (самопроверки §7, «посчитанное» соотношение, округление) выполняет копия `arm_statements`, словарь +соотношений — в пакете. +**Migration**: Требование — спека `statement-normalization` (docs): «Шаг 7 — самопроверки §7». + +## ADDED Requirements + +### Requirement: Адаптер Excel шага 1 +tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по +сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма; +продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL +выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без +кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах +(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`; +заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!` (имя листа в одинарных +кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать +листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, +диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в +`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без +признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), +документа нет. + +#### Scenario: Книга ББ + ОФР +- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» +- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов + (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР` + +#### Scenario: Ссылки ячеек +- **WHEN** код строки стоит в ячейке C27 листа «Баланс» +- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27` + +### Requirement: Ячейка Excel +Адаптер SHALL превращать ячейку Excel в `Cell`: `text` — как показывает числовой формат ячейки (tp#42), `value` — число +или дата, если источник их знает, `confidence = 100`, `ref = <лист>!` по правилу Excel (имя листа в одинарных +кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры; апостроф внутри имени удваивается). +Сетка кандидата — плотная: строки блока целиком, пустая позиция — `null`; объединённая ячейка — только в позиции левой +верхней. Значения ББ/ОФР из Excel SHALL приниматься пакетом по `value`; ОСВ числовой формат не применяет. + +#### Scenario: Excel-ячейка с числовым форматом +- **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,` +- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес ячейки) + +#### Scenario: Имя листа с пробелом +- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1» +- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"` + +### Requirement: Строки без кода и неизвестные колонки в Excel +tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее +не отдавались, OQ-36 / OQ-141), числовые колонки с нераспознанной подписью — колонкой без даты и `error` (ранее +пропускались), повторы кодов — всеми строками без `duplicate_line_codes`. + +#### Scenario: НПЗ «Реализация иного имущества» +- **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340 +- **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком + +### Requirement: Манифест ожиданий на реальных формах +Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу +«посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без +1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, +`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только +актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая +цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT +меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста +пересчитываются прогоном и правятся только с объяснением в описании PR. + +#### Scenario: Страница 2 баланса +- **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600) +- **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью + +### Requirement: Статус книги +Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений +файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в +порядке листов, затем формы. + +#### Scenario: Книга только из пояснений +- **WHEN** книга содержит только листы пояснений (0710003, «Пояснения») +- **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done` diff --git a/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..1a5bef6 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md @@ -0,0 +1,74 @@ +## MODIFIED Requirements + +### Requirement: Результат по контракту +После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата +(по `recognition-result.schema.json` v2.0, `schema_version = "2.0"`) в `results/{file_id}/{job_id}/result.json` и +опубликовать конверт результата (по `recognition-result-message.schema.json` v2.0, `result_schema_version = "2.0"`) с +заголовком `Nats-Msg-Id = result::`. Ack `processing` SHALL нести `schema_version = "2.0"`; запрос +`recognition-request` остаётся v1.0. `service.statements_version` файла результата SHALL быть коммитом копии пакета +`arm_statements` (файл `VERSION` копии; сервис без него не стартует) и записываться при наличии `statementDocument`. +`metrics.warnings` конверта SHALL содержать предупреждения уровня файла структурами `{code, severity, message, locator?}`, +не строками. Статус конверта — по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня +`error`. При неудаче публикации сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку. + +#### Scenario: Запрос с заглушкой парсера (Срез 0) +- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер), + запрос подтверждён и объект существует в `uploads/` +- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id` + из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать + +#### Scenario: Запрос с реальным парсером +- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB) +- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая + книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать; + корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс») + +#### Scenario: Результат и ack версии 2.0 +- **WHEN** обработан запрос `schema_version = "1.0"` с книгой, содержащей баланс +- **THEN** ack `processing` и конверт результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`, файл + результата проходит схему v2.0, `service.statements_version` равен коммиту копии `arm_statements` + +#### Scenario: Предупреждение файла структурой +- **WHEN** в книге лист «Примечания» без признаков ОСВ и формы +- **THEN** `metrics.warnings` конверта содержит `{code: "SHEET_OTHER", severity: "info", message, locator: "sheet:Примечания"}` + +#### Scenario: Повтор публикации +- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно +- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется + +### Requirement: Парсер — заменяемый интерфейс +Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о +NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL быть +оркестратором книги `BookParser`: он читает книгу один раз, классифицирует листы по содержимому и отдаёт листы ОСВ +конвейеру спеки `osv-parsing`, листы форм и их продолжения — конвейеру спеки `statement-parsing` (адаптер Excel и копия +пакета `arm_statements`); прочие листы дают предупреждение файла `SHEET_OTHER`. Файл результата SHALL писаться с +`schema_version = "2.0"`; документы книги — ОСВ в порядке листов, затем формы в порядке листов. Заглушка `NullParser` +(`error NOT_IMPLEMENTED`) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию. +Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари ОСВ читаются и проверяются там же, +словари форм — `default_rules()` пакета). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, `osv_groups`, +`periods`, `data_types`, `osv_checks`) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии `arm_statements`. + +#### 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 = "2.0"`, файл результата проходит схему v2.0 и + содержит один `osvDocument` со `status = done` и `account_code_raw = "66"` + +#### Scenario: Реальные формы через воркер +- **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX` +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "2.0"`, файл результата проходит схему v2.0 и + содержит два `statementDocument` (`BALANCE`, `INCOME`) со строками `line_status`, `ref`, `checks` и пустыми + `checks.relations_failed`; расхождения итогов в пределах округления (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/ + `2300`) — в `soft_relations_failed` (OQ-138); `service.statements_version` равен коммиту копии пакета + +#### Scenario: Книга без ОСВ и форм +- **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы +- **THEN** публикуется конверт `error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов + +#### Scenario: Сервис без версии общего кода не стартует +- **WHEN** в копии `arm_statements` нет файла `VERSION` +- **THEN** `require_statements_version()` бросает `RuntimeError` с текстом «копия arm_statements без VERSION», сервис не + стартует diff --git a/openspec/changes/unified-normalization-tp/tasks.md b/openspec/changes/unified-normalization-tp/tasks.md new file mode 100644 index 0000000..cd5e2c3 --- /dev/null +++ b/openspec/changes/unified-normalization-tp/tasks.md @@ -0,0 +1,27 @@ +## 1. Change и подготовка + +- [x] 1.1 OpenSpec change `unified-normalization-tp`: proposal, tasks, дельты `statement-parsing`, `tabular-recognition-service`, `osv-parsing` (задача 1) +- [x] 1.2 Подготовка хоста: сверка копии `arm_statements` с планом 2 и её свежесть (`make statements-check`) (задача 2) + +## 2. Адаптер Excel шага 1 + +- [x] 2.1 Ячейка Excel → `Cell`: `ref = <лист>!`, текст по числовому формату, плотная сетка (задача 3) +- [x] 2.2 Блоки форм на листе, вид листа, шапка блока на словарях пакета (задача 4) +- [x] 2.3 Адаптер книги → `FormCandidate`: классификация листов, склейка продолжений, `segment_breaks`, предупреждения (задача 5) + +## 3. ОСВ и переход на контракт v2.0 + +- [x] 3.1 Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) (задача 6) +- [x] 3.2 Переключение на общий код и контракт v2.0: модели, копия схем, сборка книги, путь ОСВ, воркер, `statements_version` (задача 7) + +## 4. Регрессия и очистка + +- [x] 4.1 Golden и манифест реальных форм (задача 8) +- [x] 4.2 Удаление собственного кода шагов 2–8 и словарей форм (задача 9) +- [ ] 4.3 Полная проверка `make check-full`, PR tabular-parser и PR fixtures (задача 10) + +## 5. При архивации + +- [ ] 5.1 После `openspec archive` вручную поправить Purpose главных спек: `statement-parsing` — «контракт v1.2, + `rules/statement_*.yaml`» → контракт v2.0, словари форм в пакете `arm_statements`; `osv-parsing` — «`osvDocument` + контракта v1.1» → v2.0