Andrey Epifancev d6d795787b openspec: сценарий двухстрочного заголовка — на синтетике st02, шаблоны — отдельный сценарий
Шаблоны ФСБУ разносят год по ячейкам «20» и « г.», по тексту дату не собрать; требование к ним — разбор без ошибки
и needs_check.

Refs #14

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 13:54:00 +04:00

204 lines
22 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`.
## ADDED 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 в единицах формы. Нарушение жёсткого соотношения SHALL попадать в `checks.relations_failed`
(`column_index`, `relation`, `expected`, `actual`) и переводить документ в `needs_check`; нарушение мягкого — в
`checks.soft_relations_failed` и предупреждение. Проверка «сумма пятизначных подстрок = родитель» SHALL генерироваться по
коду как мягкая, когда подстроки есть. Перечень нарушений SHALL дублироваться строками в `metrics.warnings`.
#### Scenario: Полные формы сходятся
- **WHEN** разбираются книга НПЗ и обе формы Межениновской
- **THEN** у документов `BALANCE` и `INCOME` `checks.relations_failed = []` по всем колонкам
#### Scenario: Знаки 1С
- **WHEN** в ОФР Унагранде Компани (страница 1) расходы записаны со знаком минус (2120 = −6162067308.99)
- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms`, в `relations_failed` его нет
#### 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`.
#### Scenario: Баланс на пяти листах
- **WHEN** разбирается книга НПЗ
- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`
и строками от 1105 до 1700 включительно, `INCOME` с тремя листами; статус книги `done`
#### 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