Andrey Epifancev 47e6372cbe chore(openspec): архив unified-statement-normalization (#116)
Спеки применены: 5 новых capability (arm-core, arm-web, scan-gateway,
statement-normalization, tabular-recognition-service) с Purpose, изменения
quality-checks и recognition-contract. Ссылки в 05-domain-rules, DBML v5.19,
entities, dev/checks.md и dev/qa-statements-v2.md ведут в архив.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:25:43 +04:00

917 lines
98 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-normalization Specification
## Purpose
Общий алгоритм нормализации форм ББ/ОФР (шаги 2–8) в пакете `arm_statements`, одинаковый для tabular-parser, scan-gateway и пересчёта в arm-core: колонки кодов и периодов, статусы строк, разбор сумм, код и редакция формы, проверки §7, признак «требует внимания», предупреждения и статус документа; копирование пакета в хосты скриптом sync-statements.
## Requirements
### Requirement: Пакет общего кода `arm_statements` и его копии
Общий код нормализации ББ/ОФР SHALL жить в репозитории `arm-statements` пакетом `arm_statements/` с `pyproject`,
своими тестами и конфигом линтеров. Пакет SHALL содержать: типы шага 1 (`FormCandidate`, `Cell`; сериализуемые в
json dataclass-ы), шаги 2–8, общие разборы текста (двойники, даты, суммы, коды), словари — проверок §7
`statement_checks.yaml` (полный и упрощённый наборы), периодов `statement_periods.yaml`, заголовков форм и ОКУД,
подписей колонок («Код», «Наименование», исключения), букв-двойников, единиц измерения `statement_units.yaml` (написания
и коды ОКЕИ), упорядоченные перечни кодов форм по редакциям (`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без
наименований — байтовая копия `docs/contracts/statement-code-orders.yaml` как `rules/statement_code_orders.yaml`,
диапазоны кодов — и загрузчик словарей. Словаря
«код → наименование» в пакете MUST NOT быть (Р-3). Пакет SHALL владеть своими типами входа и выхода; конвертацию
выхода в модель контракта делает хост. Golden-тесты пакета — внутри пакета на синтетических и обезличенных данных;
тесты на реальных фикстурах остаются в сервисах и соседние репозитории не читают.
В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров и
импортироваться по имени `arm_statements`; раскладку, проверку свежести и маркер версии задаёт требование
«`sync-statements` — копии пакета в потребителях».
#### Scenario: Наименований в пакете нет
- **WHEN** ревьюер ищет в пакете соответствие «код → наименование»
- **THEN** его нет: в перечне кодов только коды, наименования строк — словарь ядра
### Requirement: `sync-statements` — копии пакета в потребителях
Копия пакета в хосте (`arm-core`, `tabular-parser`, `scan-gateway`) SHALL лежать в `api/arm_statements/` (рядом с
`api/src/`, импорт `import arm_statements`) вместе с тестами пакета (`api/arm_statements/tests/`); конфиг линтеров пакета в копию не кладётся (применяется конфиг
хоста); `ruff` и `pytest` хоста SHALL охватывать копию (тесты пакета — в `testpaths`), `mypy` хоста — запускаться и по
ней (`mypy src arm_statements`, у `arm-core` — `mypy src scripts arm_statements`). Маркер версии — файл
`api/arm_statements/VERSION` с полным хэшем коммита репозитория `arm-statements`, из которого разложена копия; его пишет
только скрипт, сервис читает при старте и пишет в результат как `service.statements_version`. Прямые правки копии MUST
NOT делаться.
Копию SHALL раскладывать и проверять скрипт `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`; цель `make -C
docs sync-statements`). Источник — локальный checkout `arm-statements` рядом с workspace или `--source <dir>`, ссылка —
`origin/main` или `--ref`; хост — `--host <имя>` или `--host-dir <dir>`. Без `--check` скрипт раскладывает копию коммита
`--ref` в новый worktree хоста на ветке фичи, пишет `VERSION` и готовит issue и PR (как `sync-web.sh`). С `--in-place` скрипт SHALL раскладывать
копию коммита `--ref` в рабочее дерево указанного хоста (существующий worktree) без issue, коммита и PR — для
подключения хоста и для PR фичи, которой нужна новая версия пакета; при прямой правке копии или недостающей зависимости —
отказ с кодом `20`, ничего не пишется. Копия
сравнивается с деревом `arm_statements/` источника без файла `VERSION` (его в источнике нет). `--check` SHALL
возвращать: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, это коммит `--ref`, и копия перечня кодов
в пакете равна `docs/contracts/statement-code-orders.yaml`; `10` — копия равна дереву коммита из `VERSION`, но в
`--ref` есть более новый (копия отстала), или копия перечня кодов отличается от файла docs (перечень в docs правился
раньше `arm-statements` или пересинхронизации хоста — предупреждение называет файл); `20` — копия отличается от дерева
коммита из `VERSION` (прямая правка), `VERSION` нет в истории источника, зависимости пакета (список `dependencies`
его `pyproject`) отсутствуют в зависимостях хоста или (только у `arm-core`) код словаря имён
`api/rules/statement_lines.yaml` отсутствует в перечне своей редакции **копии пакета**; `2` — нет checkout источника
или хоста, неизвестный аргумент. Файлы и коды расхождения SHALL печататься. Порядок правки перечня кодов: PR в docs →
PR в `arm-statements` (байтовая копия yaml) → `sync-statements` в хостах «сначала ядро»; до конца этой цепочки хосты
получают `10`, а не `20`.
Код пакета SHALL проходить самый строгий конфиг `ruff` и `mypy` среди хостов (пакет проверяется им в своём
репозитории): конфиг хоста применяется к копии, а правка копии под хост запрещена. Тесты пакета SHALL не зависеть от
фикстур и `conftest` хоста: в `api/arm_statements/tests/` свой `conftest.py`, а `conftest` хоста MUST NOT содержать
`autouse`-фикстур без ограничения каталогом `tests/` хоста. Зависимости пакета — только те, что есть в зеркале банка
(сейчас PyYAML); хост держит их в своих зависимостях с версией зеркала.
Цель `statements-check` хоста (`sync-statements.sh --check --host-dir .`, docs — `../docs` или `ARM_KA_DOCS`) SHALL
входить в `make check-full` (коды `0` и `10` — зелёная, `10` с предупреждением «копия отстала», вписывается в описание PR;
`20` и `2` — красная) и MUST NOT входить в `make check` и пайплайн платформы.
#### Scenario: Прямая правка копии
- **WHEN** в `tabular-parser` изменён файл копии пакета, а источник в `arm-statements` — нет
- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check-full` красный; `make check`
сервиса этого не проверяет
#### Scenario: Копия отстала
- **WHEN** копия в `scan-gateway` равна коммиту `abc1234`, а в `arm-statements` `origin/main` уже `def5678`
- **THEN** `--check` завершается с кодом 10, `make check-full` зелёный с предупреждением «копия отстала»
#### Scenario: Нет checkout источника
- **WHEN** рядом нет `arm-statements` и `--source` не задан
- **THEN** `--check` завершается с кодом 2 и подсказкой, `make check-full` красный
#### Scenario: Перечень кодов в docs новее копии
- **WHEN** в docs в `statement-code-orders.yaml` добавлен код, а `arm-statements` и копии в хостах ещё не обновлены
- **THEN** `--check` в каждом хосте завершается с кодом 10 и называет файл перечня; `make check-full` зелёный с
предупреждением, посторонние PR в сервисы не краснеют
#### Scenario: Код словаря ядра вне перечня
- **WHEN** в `arm-core/api/rules/statement_lines.yaml` редакции `fsbu4` есть код 1120, а в перечне `fsbu4` его нет
- **THEN** `--check` в `arm-core` завершается с кодом 20 и называет код и редакцию
#### Scenario: Копия в PR фичи
- **WHEN** PR scan-gateway нужна версия пакета новее, чем в `main` хоста
- **THEN** `sync-statements.sh --in-place --host-dir .` в worktree фичи раскладывает копию и пишет `VERSION`, копия
коммитится отдельным коммитом этого PR; `--check` после раскладки — `0`
#### Scenario: Версия общего кода в результате
- **WHEN** сервис собирает результат копией пакета, у которой в `VERSION` коммит `abc1234` репозитория `arm-statements`
- **THEN** в результате `service.statements_version = "abc1234"`
### Requirement: Шаг 1 — кандидат в форму
Каждый адаптер шага 1 SHALL выдавать на файл список `FormCandidate`, и только с ним работают шаги 2–8. Поля
кандидата: `source` (`ocr` | `excel`); `locator` (`file:<имя json>;table:<i>` у OCR, `sheet:<A>[,<B>…]` у Excel);
`type_hint` (`BALANCE` | `INCOME` | `DISCLOSURE` | `null`); `evidence` (ОКУД и заголовок формы как `Cell`); `header`
(поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое
`Cell` или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция —
`null`; `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто); `warnings`.
`Cell` SHALL быть одной для шапки и таблицы: `text` — как показывает источник; `value` — число или дата, если
источник их знает (у OCR всегда `null`; у Excel — значение по числовому формату ячейки, tp#42); `confidence` 0–100
(у Excel всегда 100); `ref` — позиция в источнике (OCR — `row:<row_index ИС>`, Excel — `<лист>!<A1>` по правилу Excel:
имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри
имени удваивается).
`type_hint` SHALL вычисляться одним правилом у обоих адаптеров: ОКУД по словарю (0710001 → `BALANCE`, 0710002 →
`INCOME`, прочие 0710xxx → `DISCLOSURE`) → иначе заголовок по словарю заголовков (у OCR заголовок — `label` таблицы
ИС; «Пояснения», «Расшифровка» → `DISCLOSURE`) → иначе `null`.
#### 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"`
#### Scenario: OCR-ячейка
- **WHEN** в таблице ИС ячейка строки `row_index = 8` содержит `"[190 :"` с уверенностью 25
- **THEN** у `Cell` `text = "[190 :"`, `value = null`, `confidence = 25`, `ref = "row:8"`
#### Scenario: Тип по ОКУД против заголовка
- **WHEN** у блока формы ОКУД 0710002, а заголовок «Бухгалтерский баланс»
- **THEN** `type_hint = INCOME`; окончательный тип выбирается по кодам на шаге 6
### Requirement: Шаг 1 — шапка кандидата
У каждого кандидата SHALL быть своя шапка. Текст каждого поля разбирается общим кодом по `Cell.text`: ИНН — только
цифры, без замены двойников, длина 10 или 12, иначе `null`; дата отчёта — словарём периодов (тот же, что для подписей
колонок, шаг 2); единицы — словарём единиц `statement_units.yaml` по тексту, а если по тексту не найдены — по коду ОКЕИ
тем же словарём (384 → «тыс. руб.», 383 → «руб.», 385 → «млн руб.»); ОКЕИ — только цифры. Сырой текст и уверенность
каждого поля SHALL доезжать до контракта (`<поле>_text`, `<поле>_confidence`).
#### Scenario: ИНН с мусором клеток
- **WHEN** поле ИНН шапки OCR — `"3|1 21 2|"` с цифрами ИНН между вертикальными чертами
- **THEN** ИНН собирается только из цифр, `|` единицей не считается; `inn_text` хранит исходный текст
#### Scenario: Единицы только кодом ОКЕИ
- **WHEN** в шапке Excel-формы нет текста единиц, а в блоке «Коды» стоит ОКЕИ 384
- **THEN** `units = "тыс. руб."`, `okei = "384"`, предупреждения `UNITS_NOT_FOUND` нет
### Requirement: Очистка OCR-текста — первая стадия типовых разборов
Очистка OCR SHALL выполняться не отдельным шагом, а первой стадией разборов даты, подписи колонки, кода строки,
разлепления склеенной строки и суммы — для любой ячейки без `value` (у Excel — только текстовые ячейки). Буквы-
двойники SHALL заменяться в датах, подписях, кодах и суммах (Р-11): `О/о/O/o → 0`, `З/з → 3`, `б → 6`, `S/s → 5`,
`I/l/|/і → 1`, `Ч → 4` — только вплотную к цифре, в несколько проходов; слова не портятся. В суммах крайние `|`
(след границы таблицы) SHALL убираться, а не заменяться. ИНН двойники MUST NOT трогать. Сумма с заменой двойника
получает `is_recognized = false`; код с заменой считается неуверенным. Текст длиннее 200 символов разбором даты не
обрабатывается (`null`).
#### Scenario: Двойник в сумме
- **WHEN** ячейка суммы OCR — `"1 2О5"` с уверенностью 100
- **THEN** `amount = 1205`, `is_recognized = false`, `text = "1 2О5"`
#### Scenario: Двойник в коде
- **WHEN** ячейка кода — `"I1О0"` с уверенностью 100
- **THEN** код разбирается как `1100` и считается неуверенным (подтверждается структурой, шаг 8)
#### Scenario: Граница таблицы в сумме
- **WHEN** ячейка суммы — `"| : 122477"`
- **THEN** крайняя `|` удаляется, а не становится единицей; сумма 122477, `is_recognized = false`
### Requirement: Шаг 1 — многостраничная форма
Требование относится к адаптеру Excel: у OCR каждая таблица ИС — отдельный кандидат, склейки таблиц нет (повторная
шапка внутри таблицы ИС — сегмент шага 2). У Excel одна форма SHALL быть одной сеткой на выходе шага 1. Строки продолжения дописываются в конец сетки по маркеру
«Форма … с.2» или по позиции листа; граница листа записывается в `segment_breaks`. Тип из маркера продолжения SHALL
сверяться с `type_hint` формы: расхождение — отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT`
(`error`). Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и
более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат и предупреждение
`SHEET_NOT_JOINED` (`error`).
#### Scenario: Лист-продолжение с одной строкой
- **WHEN** после листа ОФР идёт лист без шапки с единственной строкой 2400
- **THEN** лист присоединяется к ОФР (код в диапазоне формы, кодов меньше трёх — число колонок не сверяется), граница
листа — в `segment_breaks`
#### Scenario: Лист другой ширины с тремя кодами
- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы
- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error`
### Requirement: Диапазоны кодов и разбор кода
Словарь диапазонов кодов SHALL содержать отрезки: ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала
3100–3699, отчёт о движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании
6100–6499. Кодовые диапазоны для поиска столбца — объединение отрезков. Разбор кода: убрать пробелы и переносы →
заменить двойники (`|` → 1 только если результат попадает в кодовый диапазон) → убрать краевую пунктуацию → 4–5 цифр
(допускается хвост `.0`), первые четыре — в кодовом диапазоне. Многострочная ячейка — код, только если каждая её часть
код. «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца.
#### Scenario: Год и сумма — не код
- **WHEN** в ячейке «2024» или четырёхзначная сумма «1050» или «7450»
- **THEN** кодом это не считается (вне отрезков); «3450» — код (отрезок 3100–3699 отчёта об изменениях капитала)
#### Scenario: Краевая пунктуация и черта
- **WHEN** ячейка кода — `"1220 ,"` или `"| 140"`
- **THEN** коды `1220` и `1140`
### Requirement: Шаг 2 — столбец кодов
Столбец кодов SHALL находиться одним правилом: (а) столбец с подписью из словаря подписей кода («код», «коды», «код
строки», «код показателя» — после нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью
доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3; при нескольких кандидатах — с наибольшим числом кодов; берётся
последняя такая подпись над первой строкой с кодом; (б) иначе — столбец с наибольшим числом ячеек-кодов при кодах ≥ 3
и доле ≥ 0,6; иначе предупреждение `CODE_COLUMN_NOT_FOUND`.
#### Scenario: Титульный блок 1С с подписью «Коды»
- **WHEN** под подписью «Коды» титульного блока выгрузки 1С стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ
(один код из девяти, доля 0,11), а коды строк формы — в другом столбце
- **THEN** столбец под «Коды» не выбирается; столбец кодов — по правилу (б)
#### Scenario: Документ из двух строк
- **WHEN** форма содержит две строки с кодами под подписью «Код»
- **THEN** столбец кодов найден (два из двух, доля 1,0)
### Requirement: Шаг 2 — сегменты, повторная шапка и границы листов
Раскладка SHALL состоять из сегментов. Новый сегмент открывает строка-шапка (в ней есть ячейка из словаря подписей
кода, или не менее двух её непустых ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница
листа из `segment_breaks`. У сегмента своя раскладка: по его шапке; без шапки — столбец кодов по правилу шага 2, а если
сегмент не первый и кодов в нём меньше трёх — ослабленный поиск: столбец с наибольшим числом кодов из диапазона
столбца первого сегмента, минимум один. Колонки документа (`columns[]`) SHALL задаваться первым сегментом; колонки
любого следующего сегмента новых `columns[]` не создают, а сопоставляются с колонками первого сегмента:
- сегмент со своей шапкой (повторная шапка OCR, лист-продолжение Excel со своей строкой заголовка) — по `period_date`,
если у каждой колонки сегмента дата есть и совпадает с датой ровно одной колонки первого сегмента; иначе по порядку;
- сегмент без шапки — колонки значений — столбцы правее кода, где в строках с кодами есть числа, сопоставление по
порядку.
Значения сегмента несут `column_index` сопоставленной колонки первого сегмента. При сопоставлении по порядку число
колонок сегмента не совпало с первым — предупреждение `SEGMENT_COLUMNS_MISMATCH` (`error`): значения по позиции в
пределах меньшего числа колонок,
лишние столбцы сегмента в `columns[]` не попадают, их номера в сетке перечислены в `message`. Столбец кодов не найден ни
так, ни так — предупреждение `SEGMENT_WITHOUT_CODES` (`error`) сегмента; строки сегмента не теряются (роль `data`,
статусы по шагу 3).
#### Scenario: Лист-продолжение НПЗ со своей шапкой
- **WHEN** у ББ НПЗ («Бухгалтерский баланс. Лист 4») своя строка заголовка, колонки периодов — в столбцах 8–10, у первого
листа — в столбцах 16, 21, 24, даты подписей те же (30.09.2025, 31.12.2024, 31.12.2023)
- **THEN** новых колонок нет: значения листа 4 получают `column_index` 16, 21, 24 по совпадению `period_date`;
предупреждения нет, §7 ББ считается по одной колонке на дату, документ остаётся `done`
#### Scenario: Повторная шапка с другими подписями
- **WHEN** повторная шапка второй страницы скана содержит две колонки без разобранной даты, у первого сегмента — две
колонки с датами
- **THEN** сопоставление по порядку, значения несут номера колонок первого сегмента, предупреждения нет
#### Scenario: Сегмент шире первого
- **WHEN** у сегмента со своей шапкой четыре колонки периодов, у первого — три
- **THEN** `SEGMENT_COLUMNS_MISMATCH` уровня `error`, значения первых трёх — по позиции, номер четвёртого столбца — в
`message`
#### Scenario: Лист «_2» НПЗ со столбцом кодов в другой колонке
- **WHEN** второй лист формы без шапки содержит единственную строку 2400, и код стоит не в той колонке, что на первом
листе
- **THEN** у сегмента свой столбец кодов (ослабленный поиск), значения строки 2400 не сдвинуты
#### Scenario: Повторная шапка на второй странице скана
- **WHEN** в таблице ИС посередине стоит повторная строка «Наименование показателя | Код | На 30.09.2025 | …»
- **THEN** с неё начинается новый сегмент со своей раскладкой; строка-шапка в данные не попадает
### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк
Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе
последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом);
двухстрочный заголовок склеивается. Строка нумерации SHALL получать роль `numbering` и в данные не попадать; строка —
нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников — целое число, и числа слева
направо возрастают на 1, начинаются с 1 или 2 и допускают не больше одного пропуска. Роль
`title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у
одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа,
`label = ""`, `period_date = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`).
Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки без предупреждения.
#### Scenario: Нумерация в скане (AURORA-1517)
- **WHEN** под шапкой таблицы ИС строка «1 | 2 | 3 | 4 | 5»
- **THEN** роль `numbering`, строки данных из неё нет
#### Scenario: Нумерация с двойником
- **WHEN** строка нумерации скана распознана как «1 | 2 | З | 4»
- **THEN** «З» читается как 3, роль `numbering`; строки `unreadable` с «суммами» 3 и 4 нет
#### Scenario: Нумерация без первой ячейки
- **WHEN** ИС потеряла первую ячейку нумерации: «2 | 3 | 4 | 5»
- **THEN** роль `numbering` (начало с 2 допускается)
#### Scenario: Нумерация с пропуском
- **WHEN** строка «1 | 2 | 4 | 5» (тройка не распознана)
- **THEN** роль `numbering` (один пропуск допускается); «1 | 3 | 5» — не нумерация
#### Scenario: Нумерация в Excel со «2» в столбце кодов
- **WHEN** в форме Межениновской под шапкой строка «1 | 2 | 3 | 4 | 5», где «2» стоит в столбце кодов
- **THEN** роль `numbering`; «2» кодом не считается
#### Scenario: Листы 2–5 ББ без шапки
- **WHEN** у первого листа ББ заголовок есть, у листов 2–5 — нет
- **THEN** листы 2–5 берут колонки первого, предупреждения о заголовке нет, документ может быть `done`
### Requirement: Шаг 2 — подпись и дата колонки
Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь
tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway —
обязательные тесты словаря. Результат — `period_date` (конец периода колонки) и `period_kind`:
- абсолютная подпись («На 31 марта 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «с
01.01.2025 по 31.03.2025», «2024», хвост «0:00:00», клетки «2|0|2|4|») → `absolute`;
- относительная: «На отчётную дату», «За отчётный период» → `relative:current` (= эффективная дата); «На 31 декабря
предыдущего года» → `relative:prev_year_end` (31.12 года эффективной даты − 1); «На 31 декабря года,
предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года − 2); «За аналогичный период предыдущего
года» → `relative:prev_period` (эффективная дата − 1 год, 29 февраля → 28 февраля);
- подписи нет → `none`.
Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни
той, ни другой → у относительных колонок `period_date = null`, `period_kind` остаётся относительным.
Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с
предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в
столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и
предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже
100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два
года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`,
`column_index`). Уверенность подписи — `label_confidence` (у Excel 100; подписи нет — `null`).
Если среди колонок значений сегмента есть хотя бы одна с подписью периода (абсолютной или относительной), столбец без
текста подписи правее **последней** такой колонки SHALL НЕ считаться колонкой сумм: его числа не попадают в значения
строк, `COLUMN_WITHOUT_DATE` не выдаётся, вместо этого выдаётся одно предупреждение `NUMBERS_OUTSIDE_TABLE` (`info`) с
адресами ячеек, собранными по всем сегментам формы (в тексте — первые 10, дальше «и ещё N»). Предупреждение SHALL НЕ
влиять на статус документа. Решение владельца 18.10.2026 (Р-44). Правило общее для Excel и OCR и живёт в пакете.
У сегмента без подписанных колонок (лист-продолжение без шапки) столбцы без текста подписи сверх числа колонок документа
SHALL считаться числами правее таблицы. Подписанный столбец без чисел в строках с кодами границей таблицы не служит.
Адрес ячейки — `<лист>!<A1>` у Excel и `row:<n>` у OCR. Без подписанных колонок, а также для столбца без подписи между
подписанными и для столбца с любым текстом подписи, не разобранным как период или исключение, поведение прежнее (`COLUMN_WITHOUT_DATE`).
Подтверждение SHALL опираться только на уверенные источники, чтобы неуверенное не подтверждало само себя: для
неуверенной подписи эффективная дата — дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с
уверенной подписью (`label_confidence = 100`); такой нет — подпись не подтверждена. Неуверенная дата отчёта (шаг 8, поля
шапки) подтверждается только самой поздней абсолютной датой колонок с уверенной подписью. Для периода документа и
относительных подписей эффективная дата считается, как выше, по всем источникам.
#### Scenario: Бланк 66н без впечатанных дат
- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года,
предшествующего предыдущему», дата отчёта шапки 30.09.2025
- **THEN** `period_date` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`,
`relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет
#### Scenario: Неуверенная подпись не подтверждает сама себя
- **WHEN** даты отчёта в шапке нет, единственная колонка с абсолютной датой — подпись OCR «На 30.09.2О25» с уверенностью
60, остальные колонки без подписи
- **THEN** эффективная дата для подтверждения строится без этой подписи — уверенных источников нет, подпись не
подтверждена: `COLUMN_DATE_UNCERTAIN` уровня `error`; период документа (`period_known`) по-прежнему определяется этой
датой
#### Scenario: Мусорная подпись над числами
- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы
- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня
`error` с `column_index`, документ `needs_check`
#### Scenario: Числа справа от таблицы
- **WHEN** у ББ подписи периодов стоят над столбцами M, Q, S, а правее таблицы в строках с кодами лежат формулы
аналитика (V45, W57, U58, V58, V72, W72) над пустой шапкой (Межениновская 2025-Q3)
- **THEN** колонок значений три, числа U–W в значения не попадают, `COLUMN_WITHOUT_DATE` и `SEGMENT_COLUMNS_MISMATCH`
нет; одно предупреждение `NUMBERS_OUTSIDE_TABLE` уровня `info` с адресами этих ячеек; документ `done`, если нет других
причин
#### Scenario: Столбец без подписи между подписанными
- **WHEN** между колонками с подписями периода стоит столбец без подписи с числами
- **THEN** он остаётся колонкой с `COLUMN_WITHOUT_DATE` уровня `error`, `NUMBERS_OUTSIDE_TABLE` нет
#### Scenario: Подписанных колонок нет
- **WHEN** ни у одного столбца значений нет подписи периода
- **THEN** правило «числа справа от таблицы» не применяется, столбцы с числами — колонки с `COLUMN_WITHOUT_DATE`
#### Scenario: Колонка «Изменение»
- **WHEN** правее периодов колонка «Изменение, %»
- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info`
### Requirement: Шаг 2 — номер колонки
`column_index` колонки значений (`columns[]`, `values[]`, предупреждения) SHALL быть номером столбца в сетке кандидата
шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд), у Excel — номер столбца листа первого сегмента с 0.
Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их
номера. Предупреждение о столбце, которого нет среди `columns[]` (`COLUMN_SKIPPED`), SHALL нести номер этого столбца в
сетке.
#### Scenario: Разреженные колонки ИС
- **WHEN** в таблице ИС код стоит в колонке 1, суммы — в колонках 2, 4, 5 (колонки 3 нет)
- **THEN** `columns[].column_index` — 2, 4, 5, у значений — те же номера
#### Scenario: Пропущенная колонка Excel
- **WHEN** на листе Excel коды в столбце C, периоды в D и E, «Изменение, %» в F
- **THEN** `columns[].column_index` — 3 и 4, предупреждение `COLUMN_SKIPPED` с `column_index = 5`
### Requirement: Шаг 2 — столбец наименования
Столбец наименования SHALL быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке
заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки —
ячейка этого столбца; пустая — ближайшая текстовая ячейка левее кода, кроме «Пояснения».
#### Scenario: Наименование без подписи
- **WHEN** у формы нет подписи «Наименование показателя», а левее кода два столбца — «Пояснения» (короткие номера) и
тексты статей
- **THEN** наименование берётся из столбца текстов статей
### Requirement: Шаг 3 — строки данных и их статусы
Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: в ячейке кода есть
хотя бы одна цифра (разобранный код или нет), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), или
непуста с уверенностью ниже 100, или её текст — «цифра под видом буквы»: после замены двойников без условия «вплотную к
цифре» в нём есть цифра и нет других букв (`'I'`, `'| О'`). Строка только с текстом наименования — не строка данных;
текст из настоящих букв в колонке значений при уверенности 100 (подписи «Руководитель … Косарев», «(расшифровка
подписи)» под формой Excel) содержимым не считается — осознанное исключение из «ничего не выбрасывать молча». У строки — `row_index` (порядковый номер в документе с 0,
по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:<row_index ИС>`, разлепленные
`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>` по правилу шага 1, без столбца кодов — первой непустой
ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`.
Статусы:
- `ok` — код разобран;
- `unreadable` — в ячейке кода есть хотя бы одна цифра, но код не разобран; ячейка кода без цифр (в том числе только из
букв-двойников вне соседства с цифрой: «изо :», «}», «:») считается пустой;
- `detail` — строка без кода с содержимым, над которой (в том же или в предыдущих сегментах документа) есть строка `ok`;
`parent_ref` — `ref` ближайшей строки `ok` выше (строки `unreadable` / `missing` между ними пропускаются);
- `missing` — строка без кода с содержимым, над которой во всём документе нет строки `ok`; строки роли `title`
сюда не попадают.
Строка без кода и без содержимого — не строка данных. Счётчиков отброшенных строк MUST NOT быть. Повтор кода — не
статус и не предупреждение: все строки с кодом — `ok`, порядок как в файле.
#### Scenario: Код-мусор (AURORA-1462)
- **WHEN** ячейка кода — `"ИЧ,250"`, в колонках суммы
- **THEN** строка `unreadable` с `line_code_text = "ИЧ,250"`, суммами и `ref`; строка не пропадает
#### Scenario: Мусор в ячейке кода без значений
- **WHEN** в ББ aep_6 ячейка кода — `"44 30\nI |"`, ячейки значений пусты
- **THEN** строка `unreadable` без значений: документ `needs_check` (не все строки `ok` / `detail`), подтверждение она не
блокирует (`LINES_UNRESOLVED` — только строки со значениями)
#### Scenario: Расшифровка «в том числе»
- **WHEN** под строкой 1150 стоит строка «в том числе: Оборудование к установке» без кода с суммой
- **THEN** строка `detail`, `parent_ref` = `ref` строки 1150
#### Scenario: Расшифровка под строкой-мусором
- **WHEN** порядок строк: 1150 (`ok`), «ИЧ,250» (`unreadable`), строка без кода с суммой
- **THEN** последняя — `detail` с `parent_ref` строки 1150 (ближайшая строка `ok`, `unreadable` пропускается)
#### Scenario: Код из букв-двойников без цифр (aep_3)
- **WHEN** ячейка кода — `"изо :"`, в колонке значений `'3'` с уверенностью 0, выше есть строки `ok`
- **THEN** цифр в ячейке кода нет — она пустая, строка `detail`; если это потерянная строка 1130, её сумма не войдёт в
1100, и соотношение `1100 = …` подсветит раздел (цена правила, S-61)
#### Scenario: Цифра под видом буквы (aep_6)
- **WHEN** в строке без кода под строкой `ok` единственная непустая ячейка значений — `'I'` с уверенностью 100
- **THEN** строка данных `detail` (раньше терялась молча); значение `amount = null`, `string_value = "I"`,
`is_recognized = false`
#### Scenario: Потерянный код в начале таблицы
- **WHEN** первая строка данных «Выручка» без кода с суммами (код уехал в ячейку значения), выше строк с кодом нет
- **THEN** строка `missing`
#### Scenario: Повтор кода (AURORA-1406)
- **WHEN** код 2410 стоит у двух строк с разными наименованиями
- **THEN** обе строки `ok`, порядок как в файле, предупреждения нет
### Requirement: Шаг 3 — разлепление склеенных строк
До разбора кода SHALL выполняться разлепление: ячейка кода содержит N ≥ 2 частей через перевод строки, и каждая
непустая ячейка значений — ровно N частей; разлепление делается, если хотя бы одна часть кода разобралась как код
диапазона столбца. Получается N строк с `ref` `row:<i>#<k>`; нечитаемая часть кода — строка `unreadable`; наименование —
k-я из непустых частей, если их тоже N, иначе целиком у каждой; `line_code_text` и `text` значений — k-е части. Значения разлепленных строк получают
`is_recognized = false`; разлепление — предупреждение `ROW_SPLIT` (`info`). Число частей не совпало или ни одна часть
не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`.
#### Scenario: Склейка «1500\n1700» (OQ-156)
- **WHEN** ячейка кода — `"1500\n1700"`, суммы — `"917 775\n2 371 333"`
- **THEN** две строки `ok` (`row:i#1`, `row:i#2`) с суммами 917775 и 2371333, `is_recognized = false`, предупреждение
`ROW_SPLIT`; при сошедшихся соотношениях §7 значения не подсвечиваются (Р-17)
#### Scenario: Часть не из диапазона
- **WHEN** в ББ ячейка кода — `"44 30\nI |"`
- **THEN** строка не разлепляется (4430 вне диапазона ББ)
### Requirement: Шаг 4 — значение ячейки
Шаг 4 SHALL выдавать `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` —
всегда сырой текст источника; `string_value` — только когда `amount = null` и текст непуст и не прочерк. Правила:
- `value` — число: `amount` квантуется до 2 знаков half-up; больше 16 целых цифр → `amount = null`, `string_value` =
текст, `is_recognized = false`, предупреждение `VALUE_OUT_OF_RANGE` (`info`);
- `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`;
- `value` нет → `parse_amount(text, source)` по стадиям:
1. пусто или прочерк (`-`, `–`, `—`, `−` без цифр) → `amount = null`, `is_recognized = true`; многострочное значение
в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`;
2. крайние `|` (след границы таблицы) удаляются до замены двойников, а не становятся единицей (`"|122477"` → 122477);
затем буквы-двойники вплотную к цифре → цифры (Р-11); и то и другое — исправление (`is_recognized = false`);
3. **ядро числа** — участок от первого из символов «цифра, `(`, `-`, `−`, `+`» до последнего из «цифра, `)`, `}`,
`]`»; всё вне ядра — **краевой мусор** (`|`, `:`, `=`, `■`, `•`, кавычки, точки, `{`, `[` и любые другие символы):
выбрасывается, а если среди выброшенного есть что-то кроме пробелов — `is_recognized = false`;
4. в ядре допустимы только цифры, пробелы (в том числе NBSP и тонкие), `,`, `.`, знак (`-`, `−` как минус, `+`) в
начале и одна пара скобок вокруг всего числа: `(` первым символом и `)`, `}` или `]` последним (`}` / `]` —
исправление); `{` и `[` открывающей скобкой не считаются. Иначе — буква в ядре (после стадии 2), скобка без пары
или не на краю ядра, знак не в начале, любой другой символ между цифрами (`:`, `/`, `=`, `|`) — `amount = null`,
`string_value` = текст, `is_recognized = false`;
5. цифр нет вовсе → `amount = null`, `string_value` = текст, `is_recognized = false`;
6. скобки → минус; разделители: у `ocr` запятая — тысячи при шаблоне `[+-]?[1-9]\d{0,2}(,\d{3})+`, точка —
тысячи при `[+-]?[1-9]\d*(\.\d{3})+`, иначе десятичный разделитель, три и более знаков после него → `amount = null`,
`string_value` = текст; у `excel` запятая и точка — десятичный разделитель; пробел допустим только как разделитель
тысяч (группы ровно по три цифры), иначе `amount` — цифры подряд («лучшая догадка», не длиннее 16 цифр, иначе
`null`) и `is_recognized = false`;
- `is_recognized = true` — текст разобран как число без выброшенных и исправленных символов (замена двойника, `}` или
`]` вместо скобки — исправление); `confidence` — от источника и в `is_recognized` не входит;
- позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence`
ячейки (позиции нет — 100).
`parse_amount` — единственное место шагов 2–8, где разбор зависит от `source`. Разбор сумм ОСВ этим правилом MUST NOT
затрагиваться.
#### Scenario: Потерянная цифра (AURORA-1511)
- **WHEN** ячейка OCR — `"42831 1"` с уверенностью 100
- **THEN** `amount = 428311`, `is_recognized = false`, `text = "42831 1"`
#### Scenario: Скобка `}` (OQ-147)
- **WHEN** ячейка OCR — `"(7744536}"`
- **THEN** `amount = -7744536`, `is_recognized = false`
#### Scenario: Скобка внутри цифр (aep_3)
- **WHEN** ячейка OCR — `"211(1 |"`
- **THEN** крайняя `|` — краевой мусор; в ядре `211(1` скобка без пары и не на краю → `amount = null`, `string_value =
"211(1 |"`, `is_recognized = false`
#### Scenario: Буква в ядре числа (aep_6)
- **WHEN** ячейка OCR — `"(Ю4)"`
- **THEN** «Ю» — не двойник и стоит в ядре → `amount = null`, `string_value = "(Ю4)"`, `is_recognized = false` (не −4)
#### Scenario: Краевой мусор с двух сторон
- **WHEN** ячейка OCR — `": : 2856983 = ■"` или `"| : 122477"`
- **THEN** `amount = 2856983` и `amount = 122477`, `is_recognized = false` (выброшен мусор вне ядра)
#### Scenario: Фигурная скобка не открывает
- **WHEN** ячейка OCR — `"{38378)"`
- **THEN** `{` — краевой мусор, в ядре `38378)` закрывающая скобка без пары → `amount = null`, `string_value = "{38378)"`,
`is_recognized = false`
#### Scenario: Запятая по источнику
- **WHEN** текст `"4,200"`
- **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20`
#### Scenario: Длинное тире (OQ-148, закрыт решением владельца 18.10.2026)
- **WHEN** ячейка OCR — `"—"` с уверенностью 100
- **THEN** `amount = null`, `string_value = null`, `is_recognized = true`
### Requirement: Шаг 4 — единицы и дробные значения
При единицах «тыс. руб.» и доле значений с дробной частью ≥ 10 % SHALL выдаваться предупреждение документа
`FRACTIONAL_IN_THOUSANDS` (`info`); то же сочетание в книге xlsb без числовых форматов — `XLSB_SCALE_SUSPECT`
(`error`). Книга xlsb без числовых форматов сама по себе — `XLSB_NO_FORMATS` (`info`); формат ячейки не разобран —
`NUMBER_FORMAT_UNPARSED` (`info`, одно на документ с перечнем форматов).
#### Scenario: xlsb с копейками при «тыс. руб.»
- **WHEN** книга xlsb без стилей, в шапке «тыс. руб.», 40 % значений дробные
- **THEN** предупреждение `XLSB_SCALE_SUSPECT` уровня `error`, документ `needs_check`
### Requirement: Шаг 5 — строка формы
Шаг 5 SHALL выдавать `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence,
line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` — разобранный код (4–5 цифр) или
`null` у `unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от
источника, у кода с заменой двойника — не выше 99; `line_name` — распознанный текст наименования как есть, с
уверенностью источника. Сервисы MUST NOT подставлять наименование из справочника и MUST NOT срезать уверенность кода
по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах нужна только для перечня кодов (шаг 8): проверяются обе
редакции, эффективная дата (`fsbu4` при дате после 2024-12-31) разрешает только равенство.
#### Scenario: Наименование из OCR (AURORA-1522)
- **WHEN** у строки 1230 OCR-наименование «Дебиторская задолжен-ность»
- **THEN** `line_name = "Дебиторская задолжен-ность"` с уверенностью ячейки; справочное имя показывает ядро
### Requirement: Шаг 6 — тип формы, вариант и документ
Шаг 6 SHALL собирать `StatementDocument {document_type, form_variant, locator, header, columns, lines}`. Тип —
большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`;
совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и предупреждение
`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2) SHALL выполняться всегда, в том числе
при `type_hint = DISCLOSURE`: если большинство кодов в отрезке ББ или ОФР, тип — по кодам и `TYPE_CONFLICT`. Без столбца
кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND`
уровня `error`); `type_hint` нет → у `ocr` таблица ИС с содержимым — предупреждение **файла** `TABLE_LOST` (`error`,
`locator` таблицы), таблица ИС без содержимого — предупреждение **файла** `TABLE_SKIPPED` (`info`, `locator` таблицы);
у `excel` — лист «прочее», предупреждение **файла** `SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа во всех
этих случаях нет. Содержимое таблицы ИС — хотя бы одна ячейка с цифрой или с «цифрой под видом буквы» (шаг 3); таблица
только из текста (подписи, пояснительный текст) содержимого не имеет. У `DISCLOSURE` (по `type_hint` без кодов форм ББ/ОФР или по кодам прочих форм) шаги 3–7
не выполняются: `columns` и `lines` — пустые массивы, `checks` нет, документ `done`.
`form_variant` SHALL определяться только по структуре кодов всех строк `ok` документа (независимо от уверенности
кода; у ядра — заново при каждом пересчёте после правки, точка входа ниже): ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`;
ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` — `null`. ОКУД
признаком варианта не служит. Шапка документа — поля `header` кандидата: `text` → `<поле>_text`, разобранное → поле,
`confidence` → `<поле>_confidence`.
#### Scenario: Упрощённый баланс
- **WHEN** в ББ есть 1150, 1210, 1600, 1310, 1700 и нет 1100 и 1200
- **THEN** `form_variant = simplified`; шаг 7 и перечень кодов берут упрощённый набор
#### Scenario: «Пояснения» с кодами баланса
- **WHEN** ИС подписала таблицу «Пояснения к бухгалтерскому балансу» (`type_hint = DISCLOSURE`), а в столбце кодов —
1110…1700
- **THEN** столбец кодов найден, `document_type = BALANCE`, предупреждение `TYPE_CONFLICT` уровня `error`; данные не
выброшены
#### Scenario: Лист «прочее»
- **WHEN** в книге Excel лист «Примечания» без заголовка формы, ОКУД и кодов
- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"`
#### Scenario: Заголовок спорит с кодами
- **WHEN** `type_hint = BALANCE`, а коды строк — 2110, 2120, 2100, 2200
- **THEN** `document_type = INCOME`, предупреждение `TYPE_CONFLICT` уровня `error`
### Requirement: Шаг 7 — самопроверки §7
Шаг 7 SHALL считаться у обоих источников одним словарём `statement_checks.yaml` и выдавать `StatementChecks
{relations_failed[], soft_relations_failed[]}`; каждое нарушение — `{column_index, relation, codes, expected,
actual}`, где `codes` — коды цели и членов соотношения. Правила:
- значение кода в колонке — сумма по всем строкам `ok` с этим кодом; строки `detail`, `unreadable`, `missing` в
соотношения не входят; нарушение относится ко всем строкам его кодов;
- соотношение **посчитано**, если непуста цель или хотя бы один член (есть строка `ok` с кодом и `amount` не `null`);
пусто всё — соотношение пропускается; пустая цель при непустых членах и непустая цель при пустых членах — нарушение;
- жёсткое соотношение не сошлось, если расхождение больше допуска ±1 в единицах формы **и** больше 0,001 % наибольшего
по модулю числа соотношения — тогда оно в `relations_failed`; расхождение в пределах округления и мягкие
соотношения — в `soft_relations_failed`;
- члены `abs_terms` (2120, 2210, 2220, 2330, 2350, 2410) сравниваются и как записаны, и по модулю; такое соотношение
подтверждает величину, но не знак (шаг 8);
- набор соотношений — по `form_variant` (полный или упрощённый); `form_variant = null` — полный. В упрощённом наборе
соотношения ББ жёсткие, а `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` ОФР — мягкое, как `2400` полного набора
(знак 2410, OQ-12);
- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из
первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста.
`checks` SHALL быть у каждого `BALANCE` / `INCOME` со строками и отсутствовать у документа без строк.
`duplicate_line_codes` MUST NOT выдаваться.
#### Scenario: «БАЛАНС» без кода
- **WHEN** строка «БАЛАНС» ушла в `detail` (код потерян), в колонке есть 1100 и 1200
- **THEN** соотношение `1600 = 1100 + 1200` посчитано и нарушено (пустая цель при непустых членах), в
`relations_failed`
#### Scenario: Лист только с активом
- **WHEN** в ББ заполнены 1100, 1200, 1600, а 1300–1700 отсутствуют
- **THEN** `1600 = 1700` нарушено (непустая цель при пустых членах), документ `needs_check`
#### Scenario: Повтор кода в соотношении
- **WHEN** две строки 2410 с суммами 100 и 649
- **THEN** в соотношениях код 2410 равен 749
#### Scenario: Упрощённый ОФР с доходом по налогу
- **WHEN** у упрощённого ОФР 2410 записан со знаком дохода, и `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` по модулю
не сходится
- **THEN** нарушение в `soft_relations_failed`, документ из-за него не `needs_check`
#### Scenario: Округление итогов
- **WHEN** `1600 = 1700` расходится на 9,02 при итогах ~17 млрд
- **THEN** нарушение в `soft_relations_failed`, на статус и подсветку не влияет
### Requirement: Шаг 8 — `needs_review` значения
Сервис SHALL вычислять `needs_review` у каждого значения строк `ok`, `unreadable` и `missing`. У значений строк
`detail` `needs_review = false` всегда (Р-23): у них нет кода, покрыть их соотношением нельзя, в проверки §7, агрегаты и
статус документа они не входят; сомнение остаётся видно в подсказке ячейки по `text` и `confidence`. Для остальных
значений:
- значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не
нуждаются;
- значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое
посчитано и сошлось: жёсткое — в пределах допуска или округления; мягкое — только в пределах допуска ±1, без
«округления» (Р-21; так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки — у них жёстких
соотношений нет);
- **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака совпадает с формой
строгого большинства остальных членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без
правки аналитика (сама ячейка в голосование не входит); форма знака — по результату разбора `parse_amount`: «скобки»
(пара скобок, в том числе с `}` / `]`), «минус», «без знака»; голосующих нет или равенство голосов — знак не
подтверждён;
- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех
ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности;
- иначе `needs_review = false`.
#### Scenario: Догадка подтверждена соотношением (Р-7)
- **WHEN** у 1230 `"42831 1"` → 428311, `is_recognized = false`, и `1200 = 1210 + 1220 + 1230 + …` в колонке посчитано
и сошлось
- **THEN** `needs_review = false`; `text` и уверенность остаются в значении
#### Scenario: Знак расхода в фигурной скобке
- **WHEN** в колонке ОФР 2210 и 2220 записаны в скобках, а 2120 — `"(7744536}"`, и `2100 = 2110 − 2120` сошлось
- **THEN** у 2120 знак подтверждён, `needs_review = false`
#### Scenario: Знак вне конвенции
- **WHEN** 2210 и 2220 в скобках, а сомнительная 2120 — `"38378"` с уверенностью 60 (форма «без знака»), и `2100 = 2110 −
2120` сходится по модулю
- **THEN** `needs_review = true` у 2120 независимо от соотношения
#### Scenario: Скобка, которая не открывает
- **WHEN** 2120 — `"{38378)"`
- **THEN** разбор даёт `amount = null` (шаг 4), значение не покрыто, `needs_review = true`
#### Scenario: Расшифровки не держат документ (Р-23, РБ 1 кв. 2025)
- **WHEN** в ББ и ОФР РБ «Бухгалтерская отчетность 1 кв.25» все неуверенные значения строк с кодом покрыты сошедшимися
соотношениями, неуверенные коды подтверждены перечнем `fsbu4`, а неуверенные значения остались только у строк `detail`
(в ОФР — `'1 425 130'` с уверенностью 78, `'134 796'` с уверенностью 71, `'(15 567)'` с уверенностью 75)
- **THEN** у значений `detail` `needs_review = false`, они статус не держат; документ получает `done`, если выполнены
остальные условия функции статуса (шапка, период, предупреждения); подсказка у ячеек `detail` показывает `text` и
уверенность
#### Scenario: Справочная строка без проверки
- **WHEN** значение 2900 с уверенностью 80 (строк 29xx нет ни в одном соотношении)
- **THEN** `needs_review = true`
#### Scenario: Чистая прибыль подтверждена мягким соотношением (Р-21)
- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` в колонке сошлось с расхождением 0
- **THEN** у 2400 `needs_review = false`
#### Scenario: Мягкое соотношение в пределах округления не подтверждает
- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` расходится на 9 при итогах ~17 млрд (в пределах
0,001 %, но больше ±1)
- **THEN** у 2400 `needs_review = true`; нарушение в `soft_relations_failed`
### Requirement: Шаг 8 — подтверждение неуверенного кода структурой
Код строки SHALL считаться неуверенным, если `line_code_confidence < 100` или при разборе заменён двойник; только к
неуверенным кодам применяется структурная проверка (Р-12). Данные — упорядоченные перечни кодов по редакциям (`66n`,
`fsbu4`) и вариантам (`full`, `simplified`), источник — `docs/contracts/statement-code-orders.yaml`; законные повторы
кода (Р-20) — часть перечня (`repeats`: у `fsbu4` 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО).
Четырёхзначные коды строк `ok` документа образуют последовательность; коды, которых нет в перечне редакции, в
последовательность этой редакции не входят. Для **обеих** редакций берётся лучшая подпоследовательность, согласованная
с порядком перечня варианта формы (итоги после слагаемых; код с повтором может войти столько раз, сколько он стоит в
перечне); «лучшая» — лексикографически: сначала наибольшее число уверенных кодов (уверенные коды — якоря: включаются
все, какие можно включить вместе), затем наибольшая длина. Якоря, противоречащие друг другу по порядку бланка, все
вместе войти не могут — входит наибольшее согласованное их множество; неуверенный код проверяется против выбранного.
Редакция — с лучшей подпоследовательностью (то же сравнение), при равенстве — по эффективной дате (`fsbu4` после
2024-12-31). Неуверенный код подтверждён, только если он входит во **все** лучшие подпоследовательности выбранной
редакции. Вариант формы для перечня — `form_variant` документа (шаг 6, по всем строкам `ok`). Пятизначный код подтверждён, если
подтверждён его родитель и подстрока стоит после него. Повтор кода, которого нет в перечне редакции (или копий больше,
чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review = true` у неуверенной копии (Р-16); повтор из
перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `StatementLine.needs_review =
true`. У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться.
#### Scenario: Неуверенный код на своём месте
- **WHEN** код 1230 с уверенностью 50 стоит между 1220 и 1240 (уверенными)
- **THEN** код подтверждён, `needs_review = false`
#### Scenario: Переставленные неуверенные соседи
- **WHEN** порядок кодов 1210, 1230 (уверенность 50), 1220 (уверенность 50), 1240
- **THEN** у обеих неуверенных строк `needs_review = true`, документ `needs_check`
#### Scenario: Якоря противоречат друг другу
- **WHEN** порядок кодов 1210 (уверенный), 1240 (уверенный), 1230 (уверенный), 1250 (уверенность 50)
- **THEN** 1240 и 1230 вместе войти не могут; лучшие подпоследовательности — 1210, 1240, 1250 и 1210, 1230, 1250; код 1250
входит в обе и подтверждён
#### Scenario: Законный повтор 1220 (Р-20, AURORA-1431)
- **WHEN** в ББ печатной формы 1С по ФСБУ 4/2023 стоят подряд 1215, 1220 «НДС по приобретенным ценностям» (уверенность
100), 1220 «НДС, начисленный по отгрузке» (уверенность 50), 1230
- **THEN** обе строки 1220 подтверждены структурой (повтор есть в перечне `fsbu4`), `needs_review = false`
#### Scenario: Повтор вне перечня
- **WHEN** в ББ код 1230 стоит дважды подряд, у второй копии уверенность 50
- **THEN** у второй копии `needs_review = true`, документ `needs_check`
#### Scenario: Бланк 66н в 2025 году
- **WHEN** скан ББ за 3 кв. 2025 на бланке 66н с неуверенным кодом 1120 между 1110 и 1130
- **THEN** проверяются обе редакции, подпоследовательность `66n` длиннее, код 1120 подтверждён
#### Scenario: Нестандартный код Excel
- **WHEN** в Excel-форме код 1231 или пятизначная подстрока 11101 вне перечня
- **THEN** `needs_review` строки не ставится; проверка «код не из редакции» — `info` ядра
### Requirement: Шаг 8 — поля шапки
Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок с
уверенной подписью (`label_confidence = 100`; неуверенная подпись дату отчёта не подтверждает, требование «Шаг 2 —
подпись и дата колонки»); иначе
поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field =
report_date`). У бланка только с относительными подписями колонок абсолютных дат нет, и неуверенная дата отчёта
структурой не подтверждается никогда: документ `needs_check`, аналитик подтверждает или исправляет дату в ядре (PATCH
шапки). `needs_review_fields` — поля, которые держат документ в `needs_check`; ИНН и организация с уверенностью ниже 100
— предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN` (`info`, с `field`), в `needs_review_fields` не попадают
(подсветка у поля — по предупреждению); единицы и прочие поля на статус не влияют.
#### Scenario: Неуверенная, но верная дата
- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, самая поздняя дата колонок с уверенной подписью 30.09.2025
- **THEN** `needs_review_fields` пуст, предупреждения о дате нет
#### Scenario: Неуверенные дата и подпись не подтверждают друг друга
- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, единственная абсолютная подпись колонки «На 30.09.2025» — с
уверенностью 60
- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` и `COLUMN_DATE_UNCERTAIN` уровня `error`;
снимаются подтверждением даты отчёта и даты колонки аналитиком
#### Scenario: Неуверенный год
- **WHEN** дата отчёта 30.09.2023 с уверенностью 60, колонки на 30.09.2025 и 31.12.2024
- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`, документ `needs_check`
#### Scenario: Бланк 66н с относительными подписями
- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, подписи колонок — «На отчетную дату», «На 31 декабря предыдущего
года»
- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`; снимается подтверждением даты
аналитиком
### Requirement: Шаг 8 — предупреждения
Предупреждение SHALL быть структурой `{code, severity, message, line_refs?, column_index?, field?}`: `severity` —
`error` | `info`; `line_refs` — список `ref` строк только из `lines[]` (о строках ролей `title` / `header` /
`numbering` — в `message`); `column_index` — номер столбца сетки (требование «Шаг 2 — номер колонки»); `field` — поле
шапки. Место в файле — `locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно
имеют `locator?` (json набора ИС, таблица ИС или лист) и SHALL выдаваться там, где документа нет; предупреждение файла
уровня `error` делает конверт `needs_check` и блокирует `RECOGNIZED` всех документов файла в ядре. Уровни SHALL быть
такими:
| Код | Уровень | Когда |
|---|---|---|
| `JSON_FAILED` | error (файл) | json набора ИС ошибочен при успешном соседе |
| `TABLE_PARSE_ERROR` | error (файл) | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`); документа нет |
| `TABLE_LOST` | error (файл) | таблица ИС с содержимым (ячейка с цифрой или «цифрой под видом буквы») без колонки кодов и без `type_hint` |
| `TABLE_SKIPPED` | info (файл) | таблица ИС без содержимого (только текст), без колонки кодов и без `type_hint`; документа нет |
| `CODE_COLUMN_NOT_FOUND` | error | у формы с `type_hint` `BALANCE`/`INCOME` нет столбца кодов |
| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в том числе лист-продолжение) |
| `SHEET_NOT_JOINED` | error | лист с ≥ 3 кодами и другим числом колонок не присоединился |
| `CONTINUATION_TYPE_CONFLICT` | error | тип маркера продолжения противоречит форме |
| `SEGMENT_COLUMNS_MISMATCH` | error | колонки сегмента при сопоставлении по порядку не совпали по числу с первым |
| `COLUMN_WITHOUT_DATE` | error | числовая колонка без разобранной подписи |
| `COLUMN_DATE_UNCERTAIN` | error | неуверенная подпись не попала в ожидаемые даты |
| `HEADER_ROW_NOT_FOUND` | error | заголовка нет ни у одного сегмента |
| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` |
| `NO_ORGANIZATION_NO_INN` | error | в шапке нет ни организации, ни ИНН |
| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок |
| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок |
| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов при «тыс. руб.» и ≥ 10 % дробных |
| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден или неуверен при найденной организации |
| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена или неуверенна при найденном ИНН |
| `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок |
| `UNITS_NOT_FOUND` | info | единицы не найдены |
| `FILE_TYPE_MISMATCH` | info (файл) | формат файла не совпал с `file_type` запроса |
| `XLS_NO_CODEPAGE` | info (файл) | книга xls без кодовой страницы — текст мог прочитаться неверно |
| `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена |
| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) |
| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны |
| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр |
| `SHEET_OTHER` | info (файл) | лист Excel «прочее» без `type_hint` и кодов; документа нет |
| `ROW_SPLIT` | info | разлеплена склеенная строка |
| `NUMBERS_OUTSIDE_TABLE` | info | числа правее последней подписанной колонки значений не учтены; адреса ячеек в тексте |
Предупреждения пути ОСВ — уровня `info`, код с префиксом `OSV_`. Новый код предупреждения вводится словарём пакета и
таблицей §11 `05-domain-rules.md`; `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление.
#### Scenario: Исключение при разборе таблицы ИС
- **WHEN** разбор второй таблицы json упал с исключением, первая разобрана
- **THEN** документ первой таблицы со своим статусом; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с
`locator = "file:<имя json>;table:1"`; конверт `needs_check`
#### Scenario: НПЗ ОФР без ИНН
- **WHEN** в шапке ОФР есть организация и нет ИНН
- **THEN** `INN_NOT_FOUND` уровня `info`, документ может быть `done`
#### Scenario: Привязка к ячейке
- **WHEN** не сошлось `1200 = 1210 + … + 1260` в колонке 1
- **THEN** нарушение в `checks.relations_failed` с `column_index = 1` и `codes`, `needs_review = true` у ячеек этих
кодов в колонке 1
### Requirement: Шаг 8 — статус документа и конверта
Статус документа SHALL вычисляться одной функцией общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧
период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет
значений, строк и полей шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет
предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и
проверках. Вход функции SHALL быть одинаковым у сервиса и ядра и не зависеть от источника: `document_type`,
`layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть строка из источника,
`row_index` не `null`), `has_organization_or_inn`, `period_known`, строки `{line_status, needs_review}`, значения
`{line_status строки, needs_review}`, число жёстких нарушений вне допуска и округления, `accepted`, уровни
предупреждений документа (у ядра — записи `document_issue` вида `warning`). `needs_review` на входе уже учитывает правки
и подтверждения аналитика (правленое или подтверждённое значение и строка — `false`); `accepted` — жёсткие нарушения не
учитываются. Значения строк `detail` функция MUST NOT учитывать, даже если на входе у них `needs_review = true` (Р-23). Статус ОСВ —
`done`, когда контрольные суммы §1.4 листа сошлись. Статус конверта: `done` ⇔
все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error`
(`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser). Квартал периода, сверка ИНН и наименований
со справочником в `done` MUST NOT входить.
#### Scenario: Excel-форма без замечаний
- **WHEN** Excel-ББ с найденной раскладкой, организацией, датой, всеми строками `ok`, сошедшимися соотношениями
- **THEN** документ `done`; уверенность 100 у Excel выполняет условия покрытия автоматически
#### Scenario: Строка без кода с суммой
- **WHEN** в форме есть строка `unreadable` с суммой
- **THEN** документ `needs_check`
#### Scenario: Ядро считает статус той же функцией
- **WHEN** ядро после PATCH вызывает функцию статуса по строкам БД формы, у которой все строки `ok`, правленая ячейка
была `needs_review`, нарушений и предупреждений `error` нет
- **THEN** функция даёт `done` — тот же результат, что дал бы сервис по тем же данным с подтверждённой ячейкой
#### Scenario: Соседний документ не тянет файл
- **WHEN** ББ `done`, ОФР того же файла `needs_check` из-за одной ячейки, предупреждений файла нет
- **THEN** конверт `needs_check`, у ББ `document.status = done`
#### Scenario: Сомнительная ячейка расшифровки
- **WHEN** у формы все строки `ok` или `detail`, нарушений и `error` нет, единственное значение с уверенностью ниже 100 —
в строке `detail`
- **THEN** документ `done`
### Requirement: Точка входа пакета для пересчёта по сохранённым данным
Пакет SHALL предоставлять функцию пересчёта формы по сохранённым данным без сетки и `FormCandidate` — ею ядро
пересчитывает форму после PATCH и при приёме результата `1.x`, сервисы ею не пользуются. Вход — тип пакета
`StoredForm`, собираемый хостом из своих данных:
- `document_type`; `effective_date` (дата отчёта, иначе самая поздняя абсолютная дата колонок — у ядра
`document.reporting_date`); `has_organization_or_inn`; `period_known`; `needs_review_fields` (как хранит ядро: поле
уходит из него только правкой или подтверждением даты отчёта — сам пересчёт поля шапки не переоценивает); `accepted`;
`warnings` — предупреждения документа `{code, severity}` (у ядра — записи `document_issue` вида `warning`);
- `columns[] {key, column_index, period_date, period_kind}` (`key` — идентификатор хоста, у ядра `column_id`);
- `lines[] {key, row_index | null, line_status, line_code, line_code_confidence, line_name, code_fixed, parent_key,
values[] {column_key, amount, text, confidence, is_recognized, value_fixed}}`: `row_index = null` — строка, созданная
правкой (участвует в §7, как строка `ok`, пока у неё есть сумма); `code_fixed` — код назначен, сменён или подтверждён
аналитиком (`line_code_modified` или `is_confirmed` строки); `value_fixed` — значение правлено или подтверждено
(`is_modified` или `is_confirmed`); `text` — сырой текст источника (`source_text`), по нему считается форма знака;
у значения, созданного правкой, `text = null`.
Выход — `{form_variant, edition, checks {relations_failed, soft_relations_failed}, line_needs_review {key → bool},
value_needs_review {key → bool}, detail_parents {key → parent_key | null}, status}`. Правила — те же шаги 6–8, что у
сервисов, с поправками: `form_variant` определяется заново по кодам строк `ok` (код мог смениться правкой); `edition` —
редакция с лучшей подпоследовательностью кодов документа по всем строкам `ok` (как в структурной проверке, при
равенстве — по `effective_date`; кодов нет — по `effective_date`); `value_fixed` ⇒ `needs_review = false`, такое значение
в голосование знака не входит; `code_fixed` ⇒ `needs_review` строки = `false`; значения строк `detail` — `false`;
`accepted` ⇒ жёсткие нарушения статус не держат; `detail_parents` — ближайшая строка `ok` выше по `row_index` (строки
`row_index = null` родителями не бывают), нет такой — `null`. Пересчёт не меняет `line_status`, суммы и коды — только
выводит признаки.
#### Scenario: Пересчёт после правки по строкам БД
- **WHEN** ядро передаёт форму: ячейка 1230 правлена (`value_fixed`), код 1250 назначен строке бывшей `unreadable`
(`code_fixed`), шаблонная строка 1260 с суммой (`row_index = null`), строка `detail` с уверенностью 60
- **THEN** `checks` посчитаны с 1250 и 1260 в `1200 = …`; у 1230 и у значения `detail` `needs_review = false`, у строки
1250 `needs_review = false`; `status` — по общей функции статуса; `form_variant` и `edition` — по кодам после правки
#### Scenario: Смена кода меняет вариант
- **WHEN** у упрощённого по структуре ББ (есть 1600, нет 1100 и 1200) аналитик назначает строке код 1100
- **THEN** точка входа возвращает `form_variant = full`, нарушения считаются полным набором