# 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 проверяться первым. Лист SHALL считаться `STATEMENT`, если в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок формы или код ОКУД из словаря заголовков пакета `arm_statements`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`, если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм; слабое продолжение (столбец кодов найден только ослабленным поиском пакета, не меньше `min_codes_continuation` кодов) SHALL учитываться только сразу после листа формы. Лист без признаков ОСВ, формы и продолжения, без `type_hint` и кодов SHALL считаться `OTHER`: документа нет, предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`). Тип формы (`type_hint`) SHALL определяться правилом пакета: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx → `DISCLOSURE`), иначе заголовок по словарю («Пояснения», «Расшифровка» → `DISCLOSURE`), иначе `null`. Титул «Бухгалтерская отчетность» MUST NOT задавать тип: тип берётся из ближайшего следующего заголовка. Два заголовка форм на одном листе SHALL давать два блока и два документа, второй — со строки своего заголовка. #### Scenario: Заголовок формы после титульного блока - **WHEN** разбирается `statements-xlsx/npz/2025-Q3/Бухгалтерская отчетность … .xlsx`, лист «Бухгалтерский баланс. Лист 1», где заголовок «Бухгалтерский баланс» стоит ниже титула «Бухгалтерская отчетность за январь - сентябрь 2025 г.» - **THEN** лист классифицирован как `STATEMENT`, документ `BALANCE`, `header.okud = "0710001"` #### Scenario: Лист-продолжение без заголовка - **WHEN** разбирается лист «Бухгалтерский баланс. Лист 2» той же книги (первая строка — «в том числе:», далее 11901, 1100…) - **THEN** лист классифицирован как `STATEMENT_CONTINUATION` и не даёт отдельного документа #### Scenario: Маркер «с.2» в 1С-выгрузке - **WHEN** разбирается `statements-xlsx/unagrande-co/2024-FY/Б.Б. Лист 2.xls` (первая строка «Форма 0710001 с.2») - **THEN** лист классифицирован как `STATEMENT_CONTINUATION`, тип по ОКУД маркера — `BALANCE` #### Scenario: Две формы на одном листе - **WHEN** разбирается синтетический лист `st01_both_forms_one_sheet` (баланс, затем ОФР) - **THEN** результат содержит два документа — `BALANCE` и `INCOME` с одинаковым `locator` листа, строки ОФР не попали в баланс #### Scenario: Расшифровка - **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах» - **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`, со статусом `done`; статус книги от него не зависит #### Scenario: Лист «прочее» - **WHEN** в книге лист «Примечания» без заголовка формы, ОКУД и кодов - **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"` ### Requirement: Шапка документа Адаптер SHALL собирать шапку каждого блока формы в границах этого блока: поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое `Cell` или `null`, найденные поля с `confidence = 100`. Метки поля SHALL искаться по словарю заголовков пакета в области от начала блока до строки заголовка таблицы (у блока без кодов — во всём блоке); значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка следующей строки. ИНН SHALL браться у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться. `organization_name` SHALL браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком ОПФ. Разбор текста `Cell` (ИНН — только цифры длиной 10 или 12, дата отчёта по словарю периодов, единицы по словарю единиц или коду ОКЕИ) SHALL выполнять общий код пакета; единицы MUST NOT нормализоваться адаптером. Шапка другого блока или другого листа MUST NOT использоваться. У документа без шапки все поля SHALL быть `null`, кроме `form_name`, который ставит пакет по заголовку. #### Scenario: Полная шапка 1С - **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` - **THEN** `organization_name = 'Акционерное общество "Унагранде Компани"'`, `inn = "3249004697"` (не ИНН аудитора 7716021332), `okpo = "47888105"`, `okud = "0710001"`, `units = "в тыс. рублей"`, `report_date = 2024-12-31`, `report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440»; `*_confidence = 100` #### Scenario: Организация над меткой, ОФР за период - **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ - **THEN** `organization_name = 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'`, `inn = null` (на листе ОФР метки «ИНН» нет, а шапка другого листа не используется), `okpo = "16098811"`, `report_date = 2025-09-30`, `units = "тыс. руб."`; у документа `BALANCE` той же книги `inn = "2315995868"` из титульного блока над заголовком формы #### Scenario: Дата из тройки чисел - **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа) - **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"` ### Requirement: Склейка листов-продолжений и статус документа Листы книги SHALL обрабатываться по порядку; лист-продолжение SHALL дописываться в сетку открытой формы адаптером до раскладки, граница листа — в `segment_breaks`. Открытая форма — последний кандидат с `type_hint` `BALANCE` или `INCOME`. Лист с маркером «Форма … с.N» присоединяется по типу маркера; расхождение типа маркера с `type_hint` формы SHALL давать отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без маркера и шапки SHALL присоединяться, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат и предупреждение `SHEET_NOT_JOINED` (`error`). `locator` SHALL перечислять имена листов через запятую. Продолжение без открытой формы SHALL давать отдельный кандидат без предупреждения адаптера (пакет выдаёт `NO_ORGANIZATION_NO_INN`), документ — `needs_check`. Статус документа SHALL вычислять функция пакета (одна для сервисов и ядра); `checks` и условие `done` — по спеке `statement-normalization`. `metrics.rows` книги SHALL быть суммой строк данных ОСВ (без `TOTAL`) и строк `ok` документов форм (строки `detail` и без кода — отдельно). Если книга завершилась ошибкой листа ОСВ, ответ — ошибка этого листа; список неразобранных листов форм не добавляется. #### Scenario: Баланс на пяти листах - **WHEN** разбирается книга НПЗ - **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`, одной сеткой и `segment_breaks` по границам листов, строки от 1105 до 1700 включительно, `INCOME` на трёх листах (лист отчёта с единственной строкой 2400 присоединяется как продолжение), строка 2400 присутствует; `1600 = 1700` и `2100`/`2200`/`2300` — в `soft_relations_failed` как округление (OQ-138); статус книги `done` #### Scenario: Лист-продолжение с одной кодовой строкой - **WHEN** **сразу** после листа формы идёт лист без шапки с единственной строкой 2400 (код в диапазоне формы) - **THEN** лист присоединяется к открытой форме (кодов меньше трёх — число колонок не сверяется), строка присутствует, граница листа — в `segment_breaks` #### Scenario: Лист другой ширины с тремя кодами - **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы - **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error` #### Scenario: Тип маркера против формы - **WHEN** после листа ОФР идёт лист с маркером «Форма 0710001 с.2» - **THEN** лист — отдельный кандидат `BALANCE`, предупреждение `CONTINUATION_TYPE_CONFLICT` уровня `error` #### Scenario: Одинокая страница 2 - **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет) - **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check` #### Scenario: Страница 1 без продолжения - **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив) - **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1600 = 1700` нарушено как «непустая цель при пустых членах» → `needs_check` #### Scenario: Смесь ОСВ и форм в одной книге - **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса) - **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим ### Requirement: Словари форм, координаты, golden Словари форм (заголовки и ОКУД, периоды, соотношения §7, единицы, перечни кодов) SHALL жить в пакете `arm_statements` и в tabular-parser MUST NOT дублироваться: файлы `api/rules/statement_*.yaml` удалены. Код `src/parser/statements/**` и `src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`); `ref` ячейки собирается из индексов функцией `grid.cell_ref`. Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы `statements-xlsx/**` и обновляться только `make golden-update`; `service.statements_version` — волатильное поле снимка. Property-тест по манифесту `tests/statements_manifest.yaml` SHALL проверять тип, ИНН, ожидаемый статус, сходимость жёстких соотношений для полных форм и, где задано, множество кодов предупреждений `error` документа. #### Scenario: Новый синоним без релиза кода - **WHEN** в словарь заголовков пакета `arm-statements` добавлен синоним заголовка «Баланс предприятия» и копия пакета обновлена скриптом `sync-statements` - **THEN** лист классифицирован как `BALANCE` без изменения кода tabular-parser; собственного словаря форм в сервисе нет #### Scenario: Мёртвая запись словаря - **WHEN** запись словаря форм не срабатывает ни на одном файле - **THEN** это проверяет тест покрытия словарей пакета `arm-statements` (внутри пакета, на его данных); тест покрытия tabular-parser словари форм не охватывает #### Scenario: Регрессия по снимкам - **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка - **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures ### Requirement: Адаптер Excel шага 1 tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма; продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах (организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`; заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!` (имя листа в одинарных кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в `segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), документа нет. #### Scenario: Книга ББ + ОФР - **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» - **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР` #### Scenario: Ссылки ячеек - **WHEN** код строки стоит в ячейке C27 листа «Баланс» - **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27` ### Requirement: Ячейка Excel Адаптер SHALL превращать ячейку Excel в `Cell`: `text` — как показывает числовой формат ячейки (tp#42), `value` — число или дата, если источник их знает, `confidence = 100`, `ref = <лист>!` по правилу Excel (имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры; апостроф внутри имени удваивается). Сетка кандидата — плотная: строки блока целиком, пустая позиция — `null`; объединённая ячейка — только в позиции левой верхней. Значения ББ/ОФР из Excel SHALL приниматься пакетом по `value`; ОСВ числовой формат не применяет. #### Scenario: Excel-ячейка с числовым форматом - **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,` - **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес ячейки) #### Scenario: Имя листа с пробелом - **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1» - **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"` ### Requirement: Строки без кода и неизвестные колонки в Excel tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее не отдавались, OQ-36 / OQ-141), числовые колонки с нераспознанной подписью — колонкой без даты и `error` (ранее пропускались), повторы кодов — всеми строками без `duplicate_line_codes`. #### Scenario: НПЗ «Реализация иного имущества» - **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340 - **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком ### Requirement: Манифест ожиданий на реальных формах Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу «посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без 1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста пересчитываются прогоном и правятся только с объяснением в описании PR. #### Scenario: Страница 2 баланса - **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600) - **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью ### Requirement: Статус книги Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в порядке листов, затем формы. #### Scenario: Книга только из пояснений - **WHEN** книга содержит только листы пояснений (0710003, «Пояснения») - **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done`