Andrey Epifancev c780dfd858 fix(statements): округление итогов не портит статус (OQ-138), слабые продолжения только сразу после формы и той же формы, подстроки по порядку строк
- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 %
  от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не
  меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done.
- N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа;
  присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser.
- M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230).
- N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name.
- T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке.
make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden).

Refs #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:14:21 +04:00

230 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# statement-parsing Specification
## Purpose
Разбор бухгалтерского баланса (0710001) и отчёта о финансовых результатах (0710002) из книг Excel в
`statementDocument` контракта v1.2 по §12 `05-domain-rules.md`: тип формы, шапка, колонки-периоды, строки с кодами и
значения, соотношения §7, склейка листов-продолжений внутри книги. Структура ищется по содержимому: ни буквы колонок,
ни номера строк, ни имена листов не являются контрактом; синонимы и соотношения — данные в `rules/statement_*.yaml`.
## Requirements
### Requirement: Листы форм и их продолжения находятся по содержимому
Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER`. Лист SHALL считаться
`STATEMENT`, если в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок
формы или код ОКУД из словаря `statement_headers.yaml`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`,
если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее
ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм. Признак ОСВ SHALL
проверяться первым. Тип формы SHALL определяться по заголовку или ОКУД: баланс / 0710001 → `BALANCE`, отчёт о
финансовых результатах (о прибылях и убытках) / 0710002 → `INCOME`, пояснения, расшифровки и прочие формы 0710xxx →
`DISCLOSURE`. Титул «Бухгалтерская отчетность» MUST NOT задавать тип: тип берётся из ближайшего следующего заголовка.
Два заголовка форм на одном листе SHALL давать два документа, второй — со строки своего заголовка.
#### Scenario: Заголовок формы после титульного блока
- **WHEN** разбирается `statements-xlsx/npz/2025-Q3/Бухгалтерская отчетность … .xlsx`, лист «Бухгалтерский баланс. Лист 1»,
где заголовок «Бухгалтерский баланс» стоит ниже титула «Бухгалтерская отчетность за январь - сентябрь 2025 г.»
- **THEN** лист классифицирован как `STATEMENT`, документ `BALANCE`, `header.okud = "0710001"`
#### Scenario: Лист-продолжение без заголовка
- **WHEN** разбирается лист «Бухгалтерский баланс. Лист 2» той же книги (первая строка — «в том числе:», далее 11901, 1100…)
- **THEN** лист классифицирован как `STATEMENT_CONTINUATION` и не даёт отдельного документа
#### Scenario: Маркер «с.2» в 1С-выгрузке
- **WHEN** разбирается `statements-xlsx/unagrande-co/2024-FY/Б.Б. Лист 2.xls` (первая строка «Форма 0710001 с.2»)
- **THEN** лист классифицирован как `STATEMENT_CONTINUATION`, тип по ОКУД маркера — `BALANCE`
#### Scenario: Две формы на одном листе
- **WHEN** разбирается синтетический лист `st01_both_forms_one_sheet` (баланс, затем ОФР)
- **THEN** результат содержит два документа — `BALANCE` и `INCOME` с одинаковым `locator` листа, строки ОФР не попали в баланс
#### Scenario: Расшифровка
- **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах»
- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`; статус книги от него не зависит
### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту
Строкой заголовка таблицы SHALL считаться первая после заголовка формы строка с ячейкой «Код»/«Коды» (синонимы в
словаре) и хотя бы одной ячейкой-периодом правее; заголовок, разнесённый на две строки или с переносами внутри ячейки,
SHALL склеиваться; строка нумерации колонок («1 2 3 4 5») SHALL пропускаться. Колонкой кодов SHALL считаться колонка,
в которой не меньше 60 % непустых значений блока — четырёх- или пятизначные коды строк форм (1100–1799 для ББ, 2100–2999
для ОФР и их пятизначные расширения, 5xxx для расшифровок); коды читаются как текст из ячеек любого типа. Колонкой
наименований SHALL считаться ближайшая левее кодов колонка с преобладанием текста; колонка «Пояснения» в наименования
MUST NOT попадать. Колонками-периодами SHALL быть все ячейки заголовка правее кодов с распознанным периодом, по порядку
слева направо, `column_index` с нуля, `period_date` — дата или конец периода из `statement_periods.yaml`. Ячейки заголовка
без периода MUST NOT становиться колонками; их значения не читаются, в предупреждения пишется одна строка. Если колонки
кодов нет, документ SHALL отдаваться без строк со статусом `needs_check` и предупреждением «колонка кодов не найдена».
#### Scenario: Три раскладки, одни и те же колонки
- **WHEN** разбираются `unagrande-co/2024-FY/Б.Б Лист 1.xls` (колонки «Пояснения | Наименование показателя | Код | На 31
декабря 2024 г. | … 2023 | … 2022»), `npz/2025-Q3/…xlsx` («Пояснения | Наименование показателя | Код | На 30 сентября
2025 г. | …») и `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` («Наименование показателя | Коды | За 9 месяцев 2025 года
| На 31 декабря 2024 года | …», под шапкой строка «1 2 3 4 5»)
- **THEN** у каждого документа три колонки с `period_date` `2024-12-31/2023-12-31/2022-12-31`, `2025-09-30/2024-12-31/2023-12-31`
и `2025-09-30/2024-12-31/2023-12-31` соответственно, строка нумерации не стала строкой данных, «Пояснения» не попали в
`line_name`
#### Scenario: Заголовок в две строки
- **WHEN** разбирается синтетический лист `st02_csv_like` («На 31 декабря» в одной строке, «2024 г.» в следующей, коды
без подписи «Код»)
- **THEN** колонки-периоды найдены по склеенному тексту (`2024-12-31`, `2023-12-31`), колонка кодов найдена без подписи
#### Scenario: Пустые шаблоны форм
- **WHEN** разбираются `templates/*.xlsx` (образцы 66н и ФСБУ 4/2023: коды есть, сумм и шапки организации нет; год в
заголовке колонки разнесён по ячейкам «20» и « г.»)
- **THEN** документы `BALANCE`/`INCOME` со строками-кодами без значений, статус `needs_check`, ошибки разбора нет;
колонки-периоды могут отсутствовать
#### Scenario: Колонка без периода
- **WHEN** в синтетическом листе правее колонок дат есть колонка «Изменение, %»
- **THEN** она не входит в `columns`, предупреждение «колонка без периода пропущена: Изменение, %»
#### Scenario: Нет колонки кодов
- **WHEN** разбирается синтетический лист `st03_no_codes` (наименования и суммы без кодов)
- **THEN** документ `BALANCE` с пустыми `lines`, статус `needs_check`, предупреждение «колонка кодов не найдена»
### Requirement: Шапка документа
Поля шапки SHALL искаться по меткам из `statement_headers.yaml` в области от начала блока формы до строки заголовка
таблицы; значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка следующей строки. `inn` SHALL
быть 10 или 12 цифр у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться. `organization_name` SHALL
браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком ОПФ. `units` SHALL
отдаваться текстом как в файле без нормализации. `report_date_text`/`report_date` SHALL браться из подзаголовка формы
(«на <дата>», «за <период>»), иначе из тройки «число, месяц, год»; для ОФР `report_date` — конец периода. Найденные поля
SHALL иметь `*_confidence = 100`. У документа без шапки все поля SHALL быть `null`, кроме `form_name` из маркера.
#### Scenario: Полная шапка 1С
- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls`
- **THEN** `organization_name = 'Акционерное общество "Унагранде Компани"'`, `inn = "3249004697"` (не ИНН аудитора
7716021332), `okpo = "47888105"`, `okud = "0710001"`, `units = "в тыс. рублей"`, `report_date = 2024-12-31`,
`report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440»
#### Scenario: Организация над меткой, ОФР за период
- **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ
- **THEN** `organization_name = 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'`, `inn = null` (на листе ОФР метки «ИНН» нет, а
шапка другого листа не используется), `okpo = "16098811"`, `report_date = 2025-09-30`, `units = "тыс. руб."`; у
документа `BALANCE` той же книги `inn = "2315995868"` из титульного блока над заголовком формы
#### Scenario: Дата из тройки чисел
- **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа)
- **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"`
### Requirement: Строки с кодом и значения
Каждая строка блока с кодом в колонке кодов SHALL давать `line` с `line_code` (текст без пробелов), `line_name` (trim,
переносы заменены пробелом), `line_code_confidence = line_name_confidence = 100` и по одному `value` на каждую
колонку-период. Строки без кода MUST NOT отдаваться (ASSUMPTION(OQ-36)); пятизначные подстроки SHALL отдаваться как
обычные строки. Число SHALL приводиться к `Decimal` по §1.2 (скобки → минус, пробелы убираются); пусто, «–», «-» →
`amount = null`, `is_recognized = true`; нечисловой текст → `amount = null`, `string_value` = текст, `is_recognized =
false`; `confidence = 100`. Повтор кода внутри документа SHALL оставлять первую строку, записывать код в
`checks.duplicate_line_codes` и переводить документ в `needs_check`. Единицы MUST NOT нормализоваться: при `units` с
«тыс.» и дробной частью не меньше чем у 10 % значений в предупреждения SHALL писаться «значений с дробной частью при
единице тыс. руб.: N».
#### Scenario: Подстроки и значения с копейками
- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls`
- **THEN** есть строки `1110` («Нематериальные активы», `5971264.14`) и `11101` («Нематериальные активы в организации»,
`5852464.14`); строка без кода с нулями после 11103 не отдана; предупреждение о дробной части при «тыс. рублей» есть
#### Scenario: Пустые значения и прочерки
- **WHEN** в `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` у строки 1170 пусто в первой колонке и 10 в остальных
- **THEN** `values[0].amount = null`, `values[1].amount = "10"`, все `is_recognized = true`
#### Scenario: Отрицательные в скобках
- **WHEN** в синтетическом ОФР 2120 записана как «(6 162 067)»
- **THEN** `amount = "-6162067"`
### Requirement: Соотношения §7 — данные, проверка по колонкам
Соотношения SHALL храниться в `statement_checks.yaml` в виде `{form, relation, strict, abs_terms}` и вычисляться чистой
функцией над «код → сумма» по каждой колонке-периоду отдельно и только по четырёхзначным кодам; отсутствующий код —
0; соотношение SHALL считаться выполненным, если сходится правая часть как записана либо с членами `abs_terms` по
модулю, с допуском ±1 в единицах формы. Единицы шапки считаются верными (OQ-138, решение владельца 06.10.2026):
расхождение жёсткого соотношения больше ±1, но не больше `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю
числа соотношения SHALL считаться округлением итогов — попадать в `checks.soft_relations_failed` с предупреждением
«сошлось в пределах округления» и не менять статус. Нарушение жёсткого соотношения сверх этого SHALL попадать в `checks.relations_failed`
(`column_index`, `relation`, `expected`, `actual`) и переводить документ в `needs_check`; нарушение мягкого — в
`checks.soft_relations_failed` и предупреждение. Проверка «сумма пятизначных подстрок = родитель» SHALL генерироваться по
коду как мягкая, когда подстроки есть; пятизначная строка SHALL относиться к ближайшей четырёхзначной строке выше с теми же
первыми тремя цифрами (12310 под 1230), а без такой строки — к строке с её первыми четырьмя цифрами. Перечень нарушений
SHALL дублироваться строками в `metrics.warnings`.
#### Scenario: Полные формы сходятся
- **WHEN** разбираются обе формы Межениновской (`1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`)
- **THEN** у документов `BALANCE` и `INCOME` `checks.relations_failed = []` по всем колонкам, статус `done`
#### Scenario: Знаки 1С
- **WHEN** в ОФР Межениновской расходы записаны со знаком минус (2120 = −417659 при 2110 = 276851 и 2100 = −140808)
- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms`, в `relations_failed` его нет; то же доказывает
unit-тест `test_relation_holds_with_abs_terms`
#### Scenario: Округление итогов
- **WHEN** в ОФР Унагранде Компани (страница 1) итог 2100 = 2 871 145 000 при 2110 − 2120 = 2 871 144 583.67, а в ОФР
Унагранде Групп 3 кв. 2025 итог 2200 = −1 365 000 при 2220 = −393 623 601.56 (расхождение 398.44)
- **THEN** нарушения в `soft_relations_failed` с предупреждением «сошлось в пределах округления», `relations_failed = []`,
статус `done`; расхождение 11 при итоге 1 000 000 (больше 0,001 %) — в `relations_failed`, `needs_check`
#### Scenario: Испорченный итог
- **WHEN** в синтетическом балансе 1600 на 5 больше суммы 1100 + 1200
- **THEN** `relations_failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected, actual}`, статус `needs_check`
#### Scenario: Мягкое соотношение
- **WHEN** в ОФР подстроки 2411 + 2412 не равны 2410
- **THEN** нарушение в `soft_relations_failed`, статус документа не меняется
### Requirement: Склейка листов-продолжений и статус документа
Листы книги SHALL обрабатываться по порядку; лист `STATEMENT_CONTINUATION` SHALL присоединяться к последнему открытому
документу формы при совпадении числа колонок-сумм, строки добавляются в конец, `locator` SHALL перечислять имена листов
через запятую. Продолжение без открытого документа SHALL давать документ с пустой шапкой, типом по ОКУД маркера (иначе
`OTHER`), статусом `needs_check` и предупреждением «страница-продолжение без шапки: загрузите форму одним файлом».
Документ SHALL считаться `ok`, если все жёсткие соотношения сошлись по всем колонкам, дубликатов кодов нет, колонка
кодов найдена, есть хотя бы одна колонка-период, в шапке есть организация или ИНН и документ не является одиночным
продолжением. Книга SHALL получать `done`, если все документы ОСВ и форм `ok`, иначе `needs_check`; книга без документов
ОСВ и форм SHALL давать `error INVALID_DOCUMENT`. `metrics.rows` книги SHALL быть суммой строк данных ОСВ (без `TOTAL`)
и строк с кодом документов форм. Если книга завершилась ошибкой листа ОСВ, ответ с ошибкой SHALL называть в
`metrics.warnings` листы форм, оставшиеся неразобранными.
#### Scenario: Баланс на пяти листах
- **WHEN** разбирается книга НПЗ
- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`
и строками от 1105 до 1700 включительно, `INCOME` на трёх листах (лист отчёта с единственной строкой 2400 присоединяется
как продолжение: после листа формы хватает одного кода, `min_codes_continuation`), строка 2400 присутствует;
`checks.relations_failed` пусты у обоих; `1600 = 1700` (1.94 / 9.02 / 2.48) и `2100`/`2200`/`2300` — в
`soft_relations_failed` как округление (OQ-138); статус книги `done`
#### Scenario: Лист-продолжение с одной кодовой строкой
- **WHEN** **сразу** после листа формы идёт лист с единственной кодовой строкой (`2400`, меньше `min_codes`), коды из
диапазона формы открытого документа и то же число колонок сумм
- **THEN** лист присоединяется к последнему открытому документу, строка присутствует, предупреждение «присоединён к
документу как продолжение по N кодовой строке»; если присоединить нельзя (число колонок, тип формы маркера или коды
другой формы — лист «Справка» с кодом баланса после ОФР), книга `needs_check` и предупреждение «лист с кодами форм
(N < min_codes) не присоединён: …»; лист с одной кодовой строкой после листа ОСВ или прочего листа, а также первая
страница формы с одной кодовой строкой формой не считаются (пропуск с предупреждением)
#### Scenario: Одинокая страница 2
- **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет)
- **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check`, предупреждение о продолжении
#### Scenario: Страница 1 без продолжения
- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив)
- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1700`/`1300` отсутствуют
(считаются нулём: соотношение `1600 = 1700` нарушено → `needs_check` с перечнем)
#### Scenario: Смесь ОСВ и форм в одной книге
- **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса)
- **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим
### Requirement: Словари форм, координаты, golden
Словари `statement_headers.yaml`, `statement_periods.yaml`, `statement_checks.yaml` SHALL валидироваться при старте;
каждая запись SHALL срабатывать хотя бы на одном файле `../fixtures/statements-xlsx/**` или синтетике. Код
`src/parser/statements/**` и `src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`).
Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы `statements-xlsx/**` и обновляться
только `make golden-update`; property-тест по манифесту `tests/fixtures/statements_manifest.yaml` SHALL проверять тип,
ИНН, ожидаемый статус и сходимость жёстких соотношений для полных форм.
#### Scenario: Новый синоним без релиза кода
- **WHEN** в `statement_headers.yaml` добавлен синоним заголовка «Баланс предприятия» и файл с таким заголовком
- **THEN** лист классифицирован как `BALANCE` без изменения кода
#### Scenario: Мёртвая запись словаря
- **WHEN** запись словаря не срабатывает ни на одном файле
- **THEN** тест покрытия падает с именем записи
#### Scenario: Регрессия по снимкам
- **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка
- **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures