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:
parent
0f12c0eb3d
commit
be5ad8ce86
37
CLAUDE.md
37
CLAUDE.md
@ -15,8 +15,8 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам (
|
||||
|
||||
## Правила репозитория
|
||||
- `api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh`
|
||||
(`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`). Словари `api/rules/statement_*.yaml`
|
||||
сервиса остаются до плана 4.
|
||||
(`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`); код возврата 20 при расхождении. Словари
|
||||
форм — в пакете; `api/rules/statement_*.yaml` удалены планом 4 (#54).
|
||||
|
||||
## Тесты
|
||||
- property-тесты инвариантов (§1.4) на всех файлах `../fixtures/osv/**` и `../fixtures/statements-xlsx/**`; для пакета ДКБ
|
||||
@ -102,19 +102,34 @@ property, покрытие словарей); без каталога `../fixtur
|
||||
|
||||
Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.
|
||||
|
||||
## Добавлено в Срезе 2b (парсер ББ/ОФР; `openspec/changes/slice-2b-statement-parser/`)
|
||||
## Формы через arm_statements (план 4, #54)
|
||||
|
||||
**Модули:** `api/src/parser/book.py` (`BookParser`: лимиты, чтение, дедлайн, вид листа, статус и карта ошибок; документы —
|
||||
ОСВ в порядке листов, затем формы) и `api/src/parser/statements/` (`detect` → `columns` → `header` → `lines` → `checks` →
|
||||
`merge`/`statement`; подробности — `README.md`, раздел «Парсер форм (ББ/ОФР)»). `get_parser(settings)` теперь создаёт
|
||||
`BookParser` (листы ОСВ по-прежнему разбирает `osv.py`).
|
||||
Конвейер форм ББ/ОФР (шаги 2–8, словари форм, перевод в контракт) — в пакете `api/arm_statements/` (копия
|
||||
`arm-statements`, версия — `arm_statements/VERSION`). Собственного конвейера форм и словарей форм в сервисе нет: удалены
|
||||
`api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement,legacy_models}.py` и
|
||||
`api/rules/statement_*.yaml`.
|
||||
|
||||
**Модули адаптера** (`api/src/parser/statements/`): `forms.py` (блоки форм и вид листа по словарям пакета), `cells.py`
|
||||
(ячейка Excel -> `arm_statements.Cell`), `header_cells.py` (шапка по меткам словаря), `adapter.py` (`plan_sheets`,
|
||||
`build_candidates`: листы -> кандидаты для пакета, продолжение присоединяется), `pipeline.py` (`StatementPipeline`:
|
||||
кандидаты -> `normalize_candidate` -> `to_contract`), `number_format.py` (числовой формат ячейки, #42).
|
||||
`book.py` (`BookParser`) склеивает ОСВ и формы; `get_parser(settings)` создаёт его с `default_rules()` пакета.
|
||||
|
||||
**Контракт 2.0:** `CONTRACT_VERSION = "2.0"`; у документов `status` и `warnings`; `checks` обязателен у BALANCE/INCOME со
|
||||
строками; `line_code` может быть пуст (`line_status`). `service.statements_version` — версия копии пакета (без `VERSION`
|
||||
сервис не стартует). Копия `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).
|
||||
|
||||
**Коды `OSV_*`:** предупреждения листа ОСВ — уровень `info`, модуль `osv_warnings.py` (контракт 2.0); предупреждение файла
|
||||
`XLS_NO_CODEPAGE` — из пакета.
|
||||
|
||||
**Правила расширения форм:**
|
||||
- Новая форма или написание = строка в `api/rules/statement_*.yaml` (+ синтетика `st0x` в
|
||||
`tests/synthetic/generate_statements.py` или реальный файл в `../fixtures/statements-xlsx/`). Не `if` под файл,
|
||||
организацию или лист. Соотношения §7 — данные `statement_checks.yaml`.
|
||||
- Словари форм меняются только в `arm-statements`, затем `sync-statements` (`--in-place` в PR фичи). Новое написание или
|
||||
соотношение — там же, плюс фикстура или синтетика, на которой оно срабатывает (`tests/synthetic/generate_statements.py`,
|
||||
`../fixtures/statements-xlsx/`). Не `if` под файл, организацию или лист.
|
||||
- **Копию `api/arm_statements/` не правим напрямую** (код возврата 20 у `statements-check` при расхождении). Правка в
|
||||
копии теряется при следующем `sync-statements`.
|
||||
- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`): тот же AST-линтер `test_no_coordinates.py`; слов формы в
|
||||
коде нет — только в YAML. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется.
|
||||
коде сервиса нет. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется.
|
||||
- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`) под формы не правятся.
|
||||
|
||||
**Тесты на реальных данных:** манифест ожиданий — `api/tests/statements_manifest.yaml` (18 файлов). Golden — два каталога
|
||||
|
||||
41
README.md
41
README.md
@ -271,29 +271,34 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|
||||
|
||||
## Парсер форм (ББ/ОФР)
|
||||
|
||||
Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через `statements/`. Документы
|
||||
результата: сначала ОСВ в порядке листов, затем формы в порядке листов. Структура ищется по содержимому, как в ОСВ;
|
||||
синонимы и соотношения §7 — данные `api/rules/statement_*.yaml`, в коде нет ни слова из формы и ни одной координаты.
|
||||
Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через адаптер `statements/`
|
||||
к пакету `arm_statements`. Документы результата: сначала ОСВ в порядке листов, затем формы в порядке листов.
|
||||
|
||||
**Словари форм и шаги 2–8 живут в пакете `arm_statements`** (копия `api/arm_statements/`, версия — `arm_statements/VERSION`,
|
||||
`service.statements_version`). Собственного конвейера форм и словарей форм в сервисе нет: синонимы заголовков, периодов,
|
||||
соотношения §7 — данные пакета (`rules/statement_*.yaml`), код сервиса их не содержит.
|
||||
|
||||
**Копию `api/arm_statements/` не правим напрямую.** Обновление — `docs/scripts/sync-statements.sh` (`--in-place` в PR
|
||||
фичи); свежесть проверяет `make statements-check` (код возврата 20 при расхождении, входит в `make check-full`).
|
||||
|
||||
| Модуль (`api/src/parser/statements/`) | Что делает |
|
||||
|---|---|
|
||||
| `detect.py` | блоки форм на листе и вид листа (форма / продолжение / прочее); заголовок формы или код ОКУД открывает блок |
|
||||
| `columns.py` | заголовок таблицы, колонка кодов (не меньше 60 % кодов в блоке), колонка наименований, колонки-периоды |
|
||||
| `header.py` | шапка: организация, ИНН (ИНН аудитора не берётся), ОКПО, ОКЕИ, единицы «как в файле», дата периода |
|
||||
| `lines.py` | строки с кодом и значения в `Decimal`; пятизначные коды — по первым четырём цифрам; дубликаты кодов |
|
||||
| `checks.py` | соотношения §7 по колонкам, допуск ±1 в единицах формы, отсутствующий код = 0, `abs_terms` для знака расходов |
|
||||
| `merge.py` | черновик документа и присоединение листов-продолжений к последнему открытому документу |
|
||||
| `statement.py` | конвейер и итоговый `statementDocument`, критерий `ok` |
|
||||
| `forms.py` | блоки форм на листе и вид листа по словарям пакета: заголовок формы или код ОКУД открывает блок; маркер «с.2», подпись «Код» с периодами или колонка кодов — продолжение |
|
||||
| `cells.py` | ячейка Excel -> `arm_statements.Cell`: текст как его показывает Excel, значение, ссылка на ячейку; без нормализации |
|
||||
| `header_cells.py` | шапка формы по меткам словаря пакета: ячейки-значения для `parse_header` (ИНН аудитора не берётся, организация — после метки «Организация», дата — из подзаголовка или тройки «число | месяц | год») |
|
||||
| `adapter.py` | `plan_sheets` раскладывает листы по видам (ОСВ / форма / продолжение / прочее); `build_candidates` собирает кандидатов для пакета, лист-продолжение присоединяется к открытой форме |
|
||||
| `pipeline.py` | `StatementPipeline`: кандидаты адаптера -> шаги 2–8 пакета (`normalize_candidate`) -> документы контракта через `to_contract`; предупреждения файла — `warning_to_contract` |
|
||||
| `number_format.py` | отображаемое значение числа по числовому формату ячейки (`#,##0,` и т. п.; #42) |
|
||||
|
||||
Форма и продолжение: лист с заголовком формы или ОКУД — новая форма; лист без заголовка, но с маркером «Форма … с.2»,
|
||||
подписью «Код» с периодами или колонкой кодов — продолжение, присоединяется к последнему открытому документу.
|
||||
Книга только из листов пояснений даёт `done` с документами `DISCLOSURE` без строк.
|
||||
Модули ОСВ (`osv.py`, `osv_warnings.py`) формы не затрагивают. Предупреждения листа ОСВ — коды `OSV_*` (уровень `info`,
|
||||
контракт 2.0): `osv_warnings.py`; `XLS_NO_CODEPAGE` — предупреждение файла пакета.
|
||||
|
||||
Критерий `ok` документа: жёсткие соотношения сошлись, дубликатов кодов нет, колонка кодов найдена, есть колонка-период,
|
||||
в шапке есть организация или ИНН, документ не одинокое продолжение. Статус книги — `done`, если `ok` у всех документов,
|
||||
иначе `needs_check` (список нарушений — `documents[].checks`; `soft_relations_failed` на статус не влияют).
|
||||
Новый синоним или соотношение — строка в YAML (`api/rules/README.md`), плюс файл-фикстура или синтетика, на которой она
|
||||
срабатывает (`test_rules_coverage.py`).
|
||||
Результат формы — контракт **2.0** (`CONTRACT_VERSION = "2.0"`, `src/contract/`): у документа `status` и `warnings`,
|
||||
`checks` обязателен у BALANCE/INCOME со строками, `line_code` может быть пуст (`line_status`: `ok | unreadable | missing |
|
||||
detail`). Статус книги и критерий `ok` документа вычисляет пакет (шаги 2–8); сервис статус не пересчитывает.
|
||||
|
||||
Словарь форм не правится в сервисе. Новое написание формы или соотношение — правка в `arm-statements` с последующим
|
||||
`sync-statements`, плюс фикстура или синтетика, на которой оно срабатывает.
|
||||
|
||||
### Ограничения
|
||||
|
||||
|
||||
@ -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/`), сервис их не читает.
|
||||
|
||||
## Как писать паттерны
|
||||
|
||||
|
||||
@ -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}
|
||||
@ -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
|
||||
@ -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*$)'
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
)
|
||||
10
api/tests/fixtures/test_rules_coverage.py
vendored
10
api/tests/fixtures/test_rules_coverage.py
vendored
@ -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))
|
||||
|
||||
|
||||
@ -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]]:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
]
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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}"
|
||||
)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user