diff --git a/CLAUDE.md b/CLAUDE.md index 12326a2..0f62ea1 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/**`; для пакета ДКБ @@ -102,19 +102,34 @@ 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,legacy_models}.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 — два каталога diff --git a/README.md b/README.md index bec430b..d9c7f54 100644 --- a/README.md +++ b/README.md @@ -271,29 +271,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`, плюс фикстура или синтетика, на которой оно срабатывает. ### Ограничения 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/contract/result.py b/api/src/contract/result.py index 13da1c5..236193e 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -244,7 +244,7 @@ class FailedRelation(ContractModel): """ 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 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/checks.py b/api/src/parser/statements/checks.py deleted file mode 100644 index cc35ddd..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.parser.rules import FormType, RelationRule, Rules -from src.parser.statements.legacy_models import FailedRelation, StatementChecks, StatementLine - -__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/header.py b/api/src/parser/statements/header.py deleted file mode 100644 index fec53d3..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.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 -from src.parser.statements.legacy_models import StatementHeader - -__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/legacy_models.py b/api/src/parser/statements/legacy_models.py deleted file mode 100644 index 5dc9d0a..0000000 --- a/api/src/parser/statements/legacy_models.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Модели документа формы контракта 1.x — только для старого конвейера шагов 2–8 (`statement.py`, `lines.py`, ...). - -Книга их не использует (формы идут через `arm_statements` и модели контракта 2.0); файл удаляется вместе со старым -конвейером в задаче 9 плана 4. -""" - -from typing import Literal - -from pydantic import Field - -from src.contract.common import Confidence, ContractDate, ContractModel, IntLike, Money, OptionalNotNull - -__all__ = [ - "FailedRelation", - "StatementChecks", - "StatementColumn", - "StatementDocument", - "StatementHeader", - "StatementLine", - "StatementValue", -] - - -class StatementHeader(ContractModel): - form_name: OptionalNotNull[str] = None - organization_name: str | None = None - organization_name_confidence: Confidence = None - inn: str | None = None - inn_confidence: Confidence = None - report_date_text: str | None = None - report_date: ContractDate | None = None - units: str | None = None # «тыс. руб.» - okud: str | None = None - okpo: str | None = None - okopf: str | None = None - okfs: str | None = None - address: str | None = None - - -class StatementColumn(ContractModel): - column_index: IntLike - label: str # «На 30 сентября 2025 г.» - period_date: ContractDate | None = None - - -class StatementValue(ContractModel): - column_index: IntLike - amount: Money = None - string_value: str | None = None - confidence: Confidence = None - is_recognized: OptionalNotNull[bool] = None - - -class StatementLine(ContractModel): - line_code: str - line_code_confidence: Confidence = None - line_name: str - line_name_confidence: Confidence = None - values: list[StatementValue] - - -class FailedRelation(ContractModel): - """Нарушенное соотношение §7 по одной колонке. - - `expected` — левая часть как в файле, `actual` — правая по строкам. - """ - - column_index: IntLike - relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml - expected: Money - actual: Money - - -class StatementChecks(ContractModel): - """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" - - relations_failed: list[FailedRelation] = Field(default_factory=list) - soft_relations_failed: list[FailedRelation] = Field(default_factory=list) - duplicate_line_codes: list[str] = Field(default_factory=list) - - -class StatementDocument(ContractModel): - document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] - locator: str # «pages:1-2» - header: StatementHeader - columns: list[StatementColumn] - lines: list[StatementLine] - checks: OptionalNotNull[StatementChecks] = None diff --git a/api/src/parser/statements/lines.py b/api/src/parser/statements/lines.py deleted file mode 100644 index 878f49c..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.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.legacy_models import StatementLine, StatementValue -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 bff39d0..0000000 --- a/api/src/parser/statements/merge.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Черновик документа формы и присоединение листов-продолжений (design D-8).""" - -from dataclasses import dataclass, field - -from src.parser.rules import FormType -from src.parser.statements.columns import PeriodColumn, TableLayout -from src.parser.statements.legacy_models import StatementHeader, StatementLine - -__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/statement.py b/api/src/parser/statements/statement.py deleted file mode 100644 index 3f239c0..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.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.legacy_models import StatementColumn, StatementDocument, StatementHeader -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_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py index 4305344..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/**` (диапазон месяцев, «Итого развернутое», шапка без меток) и синтетика. @@ -80,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/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/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/unit/parser/statements/test_checks.py b/api/tests/unit/parser/statements/test_checks.py deleted file mode 100644 index 969f8cd..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.parser.rules import Rules, load_rules -from src.parser.statements.checks import run_statement_checks -from src.parser.statements.legacy_models import StatementLine, StatementValue - - -@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_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_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_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_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_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_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)