refactor: удалён собственный конвейер форм и словари форм — шаги 2–8 в arm_statements (#54)

Удалены модули statements/{checks,columns,detect,header,lines,merge,statement,legacy_models}.py, словари
rules/statement_{headers,periods,checks}.yaml и их тесты. Словари форм и шаги 2–8 — в пакете arm_statements.
Rules без полей statement_*; покрытие словарей ОСВ (DICTIONARIES) без словарей форм. Документация сервиса
переписана под адаптер форм, контракт 2.0 и коды OSV_*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 21:53:01 +04:00
parent 0f12c0eb3d
commit be5ad8ce86
28 changed files with 66 additions and 3694 deletions

View File

@ -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 — два каталога

View File

@ -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`, плюс фикстура или синтетика, на которой оно срабатывает.
### Ограничения

View File

@ -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/`), сервис их не читает.
## Как писать паттерны

View File

@ -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}

View File

@ -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

View File

@ -1,13 +0,0 @@
# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §12 п. 4.
# Именованные группы обязательны: d (день), m (месяц числом или названием), y (год), n (число месяцев).
months: '(?P<m01>январ)|(?P<m02>феврал)|(?P<m03>март)|(?P<m04>апрел)|(?P<m05>ма[йя])|(?P<m06>июн)|(?P<m07>июл)|(?P<m08>август)|(?P<m09>сентябр)|(?P<m10>октябр)|(?P<m11>ноябр)|(?P<m12>декабр)'
# «На 31 декабря 2024 г.», «на 30 сентября 2025 г.», «31 декабря 2023 года» (без «на» — подзаголовок формы)
on_date: '(?P<d>\d{1,2})\s+(?P<m>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P<y>(19|20)\d{2})'
# «31.12.2024», «30.09.2025 г.»
numeric_date: '(?P<d>\d{1,2})\.(?P<m>\d{1,2})\.(?P<y>(19|20)\d{2})'
# «За 9 месяцев 2025 г.», «за 6 мес. 2025»
for_months: '\b(?P<n>\d{1,2})\s*мес\w*\.?\s*(?P<y>(19|20)\d{2})'
# «За Январь - Декабрь 2024 г.», «за январь сентябрь 2025 г.» (дефис уже заменён пробелом)
for_range: '(?P<m1>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(по\s+)?(?P<m>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P<y>(19|20)\d{2})'
# «За 2024 г.», «2024 год», ячейка «2024»
for_year: '(^|\bза\s+)(?P<y>(19|20)\d{2})(\s*(г\b|год)|\s*$)'

View File

@ -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

View File

@ -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<target>\d{4})\s*=\s*(?P<rhs>\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),
)

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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))

View File

@ -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]]:

View File

@ -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),

View File

@ -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"
]

View File

@ -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

View File

@ -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

View File

@ -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}"
)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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)