arm-ka-docs/context/05-domain-rules.md
Andrey Epifancev 2c27a9ecdd 05 §1.3–1.4: периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858; #177)
§1.4: строки разреза «Период» сверяются цепочкой (обороты — Σ, сальдо начала — первый
период, конца — последний, непрерывность между периодами); развёрнутый родитель над
свёрнутым субсчётом сверяется по нетто только при цепочке нетто-строк (узкий вариант,
решение владельца 28.10.2026), риск RR-5. §1.3: уточнение иерархии по отступам —
подуровень счёта на том же отступе, безымянная строка закрытия, вложенность по суммам
у родителя с нарушением с тремя защитами; риск RR-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:57:07 +04:00

1994 lines
295 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.

# 05. Правила предметной области (выжимка из ТЗ, имена — v5.19)
Каждый раздел: источник в ТЗ → правило → где в модели. Где ТЗ молчит или противоречит — пометка **[открыто]** и
ссылка на `07-open-questions.md`.
---
## §1. Парсинг ОСВ (tabular-parser + маппинг в ядре)
Источник: «Парсинг ОСВ» (DOC-260010), FNR01 (DOC-037740), реальные файлы `fixtures/osv/`. Реализация — `tabular-parser`,
Срез 2a (change `slice-2a-osv-parser`); что именно сделано и как выглядит результат — §1.6.
### 1.0 Главный принцип: структура определяется динамически, не по координатам
ОСВ приходят из разных конфигураций 1С (8.2/8.3, УПП, ERP, БП 3.0), из других учётных систем и после ручной правки
бухгалтером. **Ни одна буква колонки, ни один номер строки, ни одно имя листа не являются контрактом.** Фикстуры в
`fixtures/osv/` — это примеры двух организаций, а не спецификация формата. Парсер обязан:
1. **Найти лист(ы) с ОСВ** по содержимому: ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости всегда
над таблицей), матчащая `оборотно[- ]сальдовая ведомость` (без учёта регистра, «ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколько `document`.
Листы без признаков ОСВ/ББ/ОФР — пропускать с warning, не падать.
2. **Найти заголовок таблицы** по содержимому: строка(и), где встречаются одновременно «Сальдо на начало…»,
«Оборот(ы) за период», «Сальдо на конец…» (с вариантами: «Нач. сальдо», «Обороты», «Кон. сальдо», «Дебет/Кредит»,
«Дт/Кт», «Д-т/К-т»). **Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на
начало»** (иначе заголовок не найден → `INVALID_FORMAT`, §1.6). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет»
в первой колонке, так и строкой «Субконто», так и перечислением через запятую (вариант 2).
3. **Определить колонки сумм по тексту**, а не по позиции: для каждой из трёх групп («начало», «оборот», «конец»)
найти подзаголовки «Дебет»/«Кредит» в следующей строке под ней (с учётом merged-диапазонов). Допустимы ОСВ без
«начала» (только обороты и конец), с одной свёрнутой колонкой сальдо (знак = сторона), с дополнительными
колонками («Количество», «Валюта», «ИНН», «КПП», «Код»). Неизвестные колонки — сохранять в `extra` строки, не терять.
4. **Определить колонку текста строки** — первая непустая слева от сумм (обычно A, но не обязательно); ИНН — колонка с
заголовком `ИНН` **или** колонка, где ≥ 80 % непустых значений — ровно 10/12 цифр (число из 9/11 цифр при этом выборе
нулём не дополняется, иначе числовой КПП сошёл бы за ИНН — R-24).
5. **Определить иерархию строк** по outline level Excel; если уровней нет (файл пересохранён, csv, другая система) —
по отступу (ведущие пробелы / `alignment.indent`), по жирности счёта, по шаблону кода счёта и, в крайнем случае, по
факту «сумма детей = родитель». Стратегии применяются каскадом, выбранная стратегия пишется в `metrics.warnings` как
`sheet=<имя листа> hierarchy=<outline|indent|account_pattern|sums|trivial>`. Строка «Итого» в каскаде не участвует
(всегда уровень 0); при меньше чем двух строках данных (пустая ОСВ) — `trivial`.
6. **Шапку документа** (организация, счёт, период, «выводимые данные») искать регулярками по всем ячейкам выше заголовка
таблицы; отсутствие любого поля — не ошибка, а `null` + warning (ядро попросит аналитика уточнить при проверке).
7. **Самопроверка результата** (контрольные суммы §1.4) — единственный способ понять, что структура угадана верно.
Если тождества не сходятся — не «подгонять», а вернуть `needs_check` с диагностикой, какой шаг не уверен.
8. Правила парсера — **данные, а не код**: словари синонимов заголовков, группировок, периодов лежат в
`tabular-parser/api/rules/*.yaml` (`osv_headers`, `osv_groups`, `periods`, `data_types`) и расширяются без релиза. Новый формат ОСВ = новая фикстура + при необходимости новый
синоним, а не новая ветка `if`.
Тесты парсера строятся по принципу «свойств»: для любого входного файла результат удовлетворяет инвариантам §1.4,
число строк-контрагентов ≥ 0, `Итого` = корень. Фикстуры — регрессия, не оракул формата.
### 1.1 Шапка — два варианта 1С (как **примеры**, не исчерпывающий список)
**Вариант 1** (все наши фикстуры):
```
r1 <Наименование организации> ← владелец ОСВ (сверить с legal_entity → document.counterparty_check)
r2 Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г. ← счёт после «по счету», период после «за»
r4 Выводимые данные: БУ (данные бухгалтерского учета) ← data_type шапки
r7 Параметры: | Валюта БУ, НУ: RUB (или «руб.»)
r9 Счет | Сальдо на начало периода | Обороты за период | Сальдо на конец периода
r10 Контрагенты | [ИНН] | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r11 Договоры ← может отсутствовать
```
Группировки — отдельные строки первой колонки блока заголовка (`Счет`, `Подразделение`, `Контрагенты`, `Договоры`),
**порядок строк = порядок вложенности**. Ячейка `ИНН` в той же строке, что `Контрагенты` → `has_inn = true`.
**Вариант 2** (встречается у других клиентов):
```
Оборотно-сальдовая ведомость по счету 60
Период: 1 квартал 2026 г.
Детализация по субсчетам, субконто: Контрагенты, Договоры ← группировки через запятую, первая неявно ACCOUNT
Выводимые данные: сумма
Субконто | Сальдо на начало периода | Оборот за период | Сальдо на конец периода
| Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
```
**Реквизит субконто в списке группировок (OQ-162, решение владельца 13.10.2026).** Элемент вида `<группировка>.ИНН`
(вариант 2 — «Детализация по субсчетам, субконто: Контрагенты, Контрагенты.ИНН»; вариант 1 — строка `Контрагенты.ИНН` в
блоке заголовка под строкой `Контрагенты`; суффикс после последней точки — по словарю `service_columns.inn`, префикс —
имя группировки `COUNTERPARTY` этой же шапки; реквизит может стоять до или после владельца) — не уровень аналитики, а реквизит
этой группировки. В таблице 1С выводит его отдельной строкой **под** контрагентом (уровень глубже, те же суммы, ИНН часто
числом); элемент может стоять и **перед** владельцем («субконто: Контрагенты.ИНН, Контрагенты» / строка
`Контрагенты.ИНН` выше строки `Контрагенты` в варианте 1): тогда 1С выводит ИНН **над** наименованием. В `group_schema`
такой элемент не попадает, у группировки-владельца `has_inn = true`; элемент — подсказка для строк ИНН (§1.3, OQ-163):
задаёт направление и снимает проверку контрольной суммы ИНН. Строки ИНН склеиваются с наименованием по §1.3.
**Плоская форма 1С по одному счёту (реализовано, tabular-parser #59, AURORA-1661, решения владельца 16.10 и 19.10.2026).**
Пример — выгрузка «Оборотно-сальдовая ведомость по счету: 76.Н.1»:
```
r2 Оборотно-сальдовая ведомость по счету: 76.Н.1 ← код счёта — и через двоеточие
r3 Контрагенты ← строка вида субконто (у файла-письма — «Организации»)
r4 за 2025 г.
r5 ООО "Товарное хозяйство" ← организация (или хвост «План счетов <организация>»)
r9 Субконто | Сальдо на начало периода | Обороты за период | Сальдо на конец периода
r11 Август - Агро ООО | … ← сразу контрагенты, строки-счёта нет
```
- Код счёта после «по счету» читается и через двоеточие («по счету: 76.Н.1»). **Счёт шапки важнее счёта строки
таблицы:** фолбэк «код первой строки-счёта» (выше, OQ-134) работает, только если в шапке кода нет.
- Строка шапки **между названием ведомости и периодом** — строка вида субконто, а не организация. Её элементы
(через запятую) — виды субконто: известный вид (`osv_groups.yaml`, явная запись `mapping`: «Контрагенты», «Организации» —
синоним `COUNTERPARTY`, «Договоры» …) становится группировкой схемы после `ACCOUNT`, если схема варианта 1 — одна
`ACCOUNT` («Субконто»); неизвестный вид («Материалы») в схему не попадает.
- Организация — по порядку: хвост «План счетов <организация>» (`header_markers.chart_of_accounts`) → первая не-метка
шапки вне строки вида субконто → хвост названия ведомости.
**Шапка выгрузки «ОСВ по счёту … по контрагентам» (AURORA-1714, решение владельца 27.10.2026; tabular-parser #71).**
Пример — выгрузка 1С «ОСВ по счету 60 по контрагентам» (в файле нет слов «по счету»):
```
r1 MBC2009 | Оборотно-сальдовая ведомость
r2 15 июля 2025(18:40) | За период | 01.01.25..30.06.25 ← метка и значение в разных ячейках
r3 | Счет Поставщика | 60* ← метка и код счёта в разных ячейках
r4 | Счет Клиента | ← метка без значения
r6 Субконто | Начальное сальдо | …
r8 | | Поставщики ← строка вида субконто внутри таблицы
```
- **Счёт шапки.** Метки `Счет Поставщика` / `Счет Клиента` (`header_markers.account`, данные) — как «по счету»; код — в той же
ячейке после метки либо, если там кода нет, **в следующей ячейке строки**. «60*» — все субсчета счёта: код «60» (знак `*`
кодом не является). Метка без значения пропускается; первая метка со значением определяет счёт шапки.
- **Период.** Метка `За период` (`header_markers.period`) со значением в **соседней ячейке** строки; ячейки метки и
значения организацией не считаются. Диапазон дат `01.01.25..30.06.25` (разделитель `..` — наравне с пробелом/«по»;
`periods.yaml`) разбирается как «с 1 января по конец квартала»; год из двух цифр — 20yy (год конца диапазона). Ожидаемо
для примера: 2025, Q2, нарастающий (§1.5); границы 01.01.2025–30.06.2025.
- **Вид субконто в таблице.** Если первая строка данных состоит из одной текстовой ячейки **вне колонки текста** (в колонке
суммы), а текст — известный словарю групп вид субконто (`osv_groups.yaml`: «Поставщики» — синоним `COUNTERPARTY`, как
«Контрагенты»), строка — не данные: она не получает суммы, предупреждение «нечисловое значение в колонке сумм» не
выдаётся, а вид субконто добавляется в `group_schema` после ACCOUNT так же, как строка вида субконто шапки плоской формы
выше (схема из одного ACCOUNT). Текст в колонке текста («Поставщики» как название контрагента) этим правилом не затрагивается.
Нормализация значений шапки в типы:
| Текст в шапке | `group_type` |
|---|---|
| Счет, Счета, Субконто (первая) | ACCOUNT |
| Подразделение, Подразделения | DEPARTMENT |
| Контрагент, Контрагенты | COUNTERPARTY |
| Договор, Договоры, Договоры контрагентов | CONTRACT |
| прочее (Валюта, Номенклатура, Статьи…) | OTHER |
Результат шапки → `recognition_job.group_schema` (по листу): `{"locator":"sheet:Лист_1","account":"60",
"period_text":"1 квартал 2025 г.","data_type":"BU","groups":[{"type":"ACCOUNT"},{"type":"COUNTERPARTY","has_inn":true},
{"type":"CONTRACT"}]}` и → `document(document_type=OSV, osv_account_code, period_year, period_quarter, is_cumulative, locator)`.
**Шапка без меток и без строки организации (реализовано, PR tabular-parser #13, OQ-134; ОСВ 63 Новоросметалл).** Период
может стоять отдельной ячейкой («Бухгалтерский | 9 месяцев 2025 г.»: `periods.yaml` — `standalone`, `data_types.yaml` —
`standalone_header`), организация — хвостом названия ведомости, а колонки счёта — подписями «Счет: Код / Наименование»
(`osv_headers.yaml` — `column_captions`: «Код» и «Наименование» под группой «Счет» не группировки). Код счёта берётся из
первой строки-счёта таблицы, если «по счету …» в шапке нет, — с предупреждением «код счёта … из строки таблицы». Текст
строки может стоять правее колонки текста: уровень задаёт колонка, в которой он найден.
### 1.2 Колонки сумм
Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту
(§1.0 п. 3). Всё правее последней колонки «Кредит» — не часть таблицы (в фикстурах там встречаются посторонние
числа): игнорируется, в суммы и в `extra` не попадает. В `rows[].extra` (контракт v1.1) идут только колонки **внутри**
таблицы, не сопоставленные с шестью суммами и ИНН (ключ — заголовок колонки: «КПП», «Количество»…). Числа могут быть numeric **или** текстом («1 234 567,89», «(1 234)»,
«-», «—», неразрывные пробелы) — нормализовать в Decimal; пустая ячейка/прочерк = NULL (не 0). Отрицательные возможны.
### 1.3 Строки
Для каждой строки табличной части: `row_number` (исходный номер), `level` (outline level Excel, в xls — `rowinfo_map`; без группировок — каскад §1.0 п. 5),
`row_value` (текст первой колонки; целое число из ячейки — без дробной части: xls хранит числа как float, и
`2370012036.0` — артефакт чтения, а не значение файла), шесть сумм, ИНН (**только из колонки ИНН**; в ячейке оставляются цифры, результат
только при 10 или 12 цифрах — «ИНН 352819549855» → `352819549855`, иначе NULL; число из 9/11 цифр — ИНН региона 01–09
с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке
приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем **строка ИНН** рядом с наименованием (ниже, §1.3 «Строки ИНН», OQ-163), затем конец текста названия
(«Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»).
**Строки ИНН (OQ-163, решение владельца 13.10.2026; заменяет правила OQ-130 и OQ-162).**
Строка, подпись которой целиком — ИНН (`inn_only_row`: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП»; число из 9/11
цифр дополняется нулём слева — R-24). Без подсказки `<группировка>.ИНН` в шапке ИНН строки **должен пройти контрольную
сумму ИНН** (алгоритм ФНС); строка из 10/12 цифр с неверной суммой рядом с наименованием остаётся как есть (обычная
строка), предупреждение «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», документ —
`needs_check`
(решение H-1: синтетика `s28`). С подсказкой сумма не проверяется, пустая строка-ребёнок наименования — «ИНН не указан»
(рулинг R-1 #32), непустая не-ИНН на этом месте — «ИНН не распознан» (такой ребёнок бывает только у наименования, которое по
схеме контрагент; у подразделения единственный ребёнок — наименование). Восемь раскладок — ИНН над / под наименованием × в
дереве / не в дереве × с суммами / без:
| Положение строки ИНН | Пара |
|---|---|
| у неё есть дети (над, в дереве) | единственный ребёнок-наименование, он поднимается на её место; несколько детей — без пары. Если подсказки в шапке нет, в `group_schema` больше одной группировки и в листе нет строк глубже схемы (R-3), строка — наименование по схеме (контрагент, названный ИНН) |
| ребёнок наименования (под, в дереве) | родитель; её дети — к нему; несколько строк ИНН у одного наименования — без пары. Родитель — пара только при подсказке «под» или если он по схеме контрагент (R-2); строка ИНН с детьми под контрагентом остаётся у него и при подсказке «над» (с предупреждением о противоречии) |
| сосед того же родителя (не в дереве) | с суммами — сосед с теми же суммами; без сумм (или совпали оба) — сосед по направлению листа |
Суммы строки ИНН, если есть, должны совпасть с суммами пары по всем шести колонкам (пусто = 0). Направление листа —
подсказка шапки (порядок элемента `<группировка>.ИНН`), а без неё — сведения пар соседей по суммам и блоков детей одного
родителя вместе (начинается строкой ИНН — над, кончается — под, начинается и кончается — блок сломан); противоречие
сведений или их отсутствие — направление неизвестно, строки без сумм без пары. Две строки ИНН на одно наименование — обе без пары. ИНН колонки, отличный от ИНН строки,
остаётся, документ — `needs_check`. Склеенная строка в `rows` не попадает, предупреждение «строка N: ИНН … отнесён к
контрагенту в строке M»; **без пары** — строка в `rows` с `group_type = OTHER` (в свод не попадает), предупреждение с
причиной, документ — `needs_check`.
**Противоречие подсказке шапки (решение H-2).** Если пара найдена, но фактическая раскладка противоречит направлению
подсказки `<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него; подсказка «под»,
а строка ИНН — родитель наименования или сосед выше него), строка всё равно склеивается, но добавляется отдельное
предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка («…») указывает <над/под> —
проверьте», документ — `needs_check`. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С),
предупреждения не даёт; для соседей без сумм направление берётся из подсказки, конфликта нет.
**Реализовано** (tabular-parser #35, модуль `inn_rows.py`; синтетика `s18`, `s20`–`s28`).
Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст; только для строк
`COUNTERPARTY`, договоры и прочее не трогаются; правила — `osv_headers.yaml` (`counterparty_inn_suffix`, `inn_only_row`,
`inn_kpp_cell`); **реализовано**, OQ-100, OQ-163 (строки ИНН; ранее OQ-130, tabular-parser #9, PR #15; синтетика `s17`–`s19`),
`data_type` (строки «USD», «Вал.», «Кол.», «Кол-во», «В валюте» под контрагентом → CURRENCY/QUANTITY; метка колонки
«Показатели» — ниже; по умолчанию — из шапки, а если шапка вид данных не называет — BU с предупреждением).
«Вал» **без точки** — не метка валюты: так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код
валюты из словаря (**реализовано**, tabular-parser #47, AURORA-1663). «Кол-во» — строка количества, как «Кол.» и
«Количество» (там же); то же с единицей измерения после запятой — «Кол-во, га», «Кол-во, кг» (tabular-parser #66,
AURORA-1709).
Строки «Вал.», «Кол.», «Кол-во», «В валюте» и трёхбуквенный код валюты («USD») — **строки-показатели**: не уровень
группировки, а вид сумм родителя: `group_type = OTHER`, `data_type = CURRENCY | QUANTITY`, позицию в `group_schema` не
занимают, **не наследуют** `counterparty_name`/`counterparty_inn` (контрагента потребитель находит по родителю),
`account_code_raw` наследуют от ветки; их суммы остаются в шести полях, но в проверки родителя и «Итого» не входят
(§1.4). Родитель строки-показателя — **предыдущая строка данных** (ближайшая строка выше, которая сама не показатель);
уровень — на один глубже родителя; в каскад иерархии строки-показатели не передаются, так что не ломают дерево строк
БУ, даже если стоят на одном outline-уровне с ним. Строка «Валюта <код>» («Валюта USD») — **не** показатель: обычная
строка аналитики (OTHER) с рублёвыми суммами; код валюты отдельно не хранится (решение владельца 19.10.2026,
AURORA-1661; прикладной сценарий валютных строк — аналитика AURORA-1670).
**Колонка «Показатели»** (решение владельца 19.10.2026, AURORA-1613). 1С печатает вид суммы строки отдельной колонкой
между колонкой текста и суммами (заголовок «Показатели», часто с переносом «Показа-тели»; словарь
`osv_headers.yaml` — `service_columns.indicator`): на каждую строку аналитики — строка «БУ», под ней «НУ», «Кол.»,
«Вал.», иногда «ПР»/«ВР» (постоянные и временные разницы); текст строк-показателей в колонке текста пуст или повторяет
объединённую ячейку строки БУ. Метка читается по словарю `data_types.yaml` — `indicator_column`: «БУ» — обычная строка с
`data_type = BU`; «НУ» → NU, «Кол.» и «Кол. доп.» (дополнительное количество, сч. 11; AURORA-1709) → QUANTITY,
«Вал.» → CURRENCY — строки-показатели (как выше); «ПР», «ВР» и метка не
из словаря — строки-показатели с `data_type = null` (в контракте нет значений для разниц) и, для метки не из словаря,
предупреждением. `row_value` строки-показателя — метка. Строки НУ хранятся как пришли (OQ-45), собственных проверок
«дети = родитель» и «Итого» у них нет (прикладной сценарий НУ — аналитика AURORA-1669); тождество строки (начало +
обороты = конец) проверяется у всех строк, как и раньше. Итоговые строки «Итого» с меткой показателя («НУ», «Кол.»)
сразу за основной «Итого» — тоже TOTAL, в подвал не уходят и в сверку «Итого = корень» не входят. Колонка
«Показатели» остаётся в `extra` строки под своим заголовком.
Классификация строки (п. 6–10 «Парсинг ОСВ»):
1. Строка — счёт, если текст матчится `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` (флаги `re.ASCII | re.DOTALL`:
цифры только 0–9, название после запятой может быть многострочным; регистр учитывается;
сегмент субсчёта — 1–2 цифры **или 1–3 заглавные буквы**: буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ» встречаются в
8 реальных файлах; после запятой — опциональное название, пробел необязателен: «60.01, Расчеты с поставщиками»)
→ ACCOUNT, `account_code_raw` — код как в файле без названия, `account_code` (нормализованный) строит ядро, все
дочерние строки наследуют счёт. Остаточный риск: имя контрагента вида «12.ООО» будет принято за счёт. Счета с буквой в
**первом** сегменте (забалансовые «МЦ.04», «ГТД», «НЕ.01.1») не распознаются как ACCOUNT — **[открыто]** OQ-103.
2. Не-счёт: позиция в ветке = количество не-счётных предков + 1 → соответствующая группировка из `group_schema`
(после последнего ACCOUNT). Увеличение уровня — ребёнок, равный — сосед, уменьшение — конец группы.
3. После COUNTERPARTY дочерние продолжают классифицироваться по схеме (CONTRACT, затем OTHER).
4. Строка «Итого» (level 0, последняя) — контрольная сумма всего документа; не сохранять как `osv_entry`
(OQ-01 закрыт). Парсер отдаёт её в `rows` как `group_type = TOTAL` (level 0, без родителя); **ядро (Срез 2c)
при маппинге строку TOTAL отбрасывает** (`osv_entry` её не содержит, `TOTAL` — не значение хранимого `group_type`); в
`metrics.rows` она не считается.
5. Все остальные строки сохраняются (п. 11 «Парсинг ОСВ») с `parent_entry_id` по `parent_row_number` (OQ-01 закрыт):
дерево строится по номеру родителя, строка с несуществующим родителем сохраняется с `parent_entry_id = NULL` и даёт
предупреждение маппинга (документ `NEEDS_CHECK`). Суммы и `data_type` (включая `NU`) пишутся как в результате.
**Плоский список под счётом шапки (реализовано, tabular-parser #59, AURORA-1661).** Если в шапке есть код счёта, а в теле
нет ни одной строки-счёта (п. 1), каскад иерархии (§1.0 п. 5) после `outline`, `indent`, `account_pattern` и до `sums`
пробует стратегию `header_account`: все строки таблицы — уровень 0, предупреждение `OSV_HIERARCHY_BY_HEADER_ACCOUNT`
(«плоский список под счётом шапки»). Условие — определение плоской формы 1С; при наличии outline или отступов побеждают
они (счёт шапки при этом всё равно наследуют строки). **Счёт шапки — счёт корня ветки:** строки до первой строки-счёта
(и все строки плоского списка) получают `account_code_raw` шапки, а не `null`.
**Контрагенты расчётных счетов без названия вида (решение владельца 16.10.2026; риск OQ-158).** Если схема группировок —
одна `ACCOUNT` (вид субконто в шапке не назван), строка на позиции 1 (первая не-счётная) под счётом из списка
`counterparty_accounts` (`osv_groups.yaml`, данные) — `COUNTERPARTY` без имени группировки, ИНН — по общим правилам (колонка,
строка ИНН, хвост названия). Список: 58, 60, 62, 66, 67, 75, 76, 80 (без 71 и 73 — подотчётные и персонал, R-9); запись
`76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`. Глубже позиции 1 — `OTHER`; именованные группировки схемы («Учредители»,
«Работники организаций») правило не переопределяет. Предупреждение `OSV_ROWS` (по одному на различный код счёта):
«вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте».
Риск: физлица и подотчётные на расчётных счетах без названия вида попадут в «Осталось N» сопоставления (OQ-158).
**Блоки субсчетов с кодами контрагентов (AURORA-1714, решение владельца 27.10.2026; tabular-parser #71).** Выгрузка 1С
«ОСВ по счету 60 по контрагентам» (плоский лист без outline и отступов, счёт — в шапке, см. §1.1) строит таблицу блоками:
```
60.111 ← субсчёт, ячейки сумм пусты
П005482 | 0 | 108000 | 108000 | 0 | 0 | 0 ← код контрагента, суммы есть
ВЕСТ БИР ООО ← название контрагента, сумм нет
ДП87825;З-139/21-1 | 0 | 108000 | … ← договор контрагента, суммы есть
ИТОГ по 60.111 | … суммы блока … ← итог субсчёта
…
ИТОГ | … суммы листа … ← итог листа
```
1. **Строка-счёт с тремя цифрами в сегменте.** Код вида `60.111`, `60.243` — строка-счёт (ACCOUNT, п. 1 выше), **только если
он начинается со счёта шапки** (`60.xxx` при шапке `60`; сегмент — 1–3 цифры). Без счёта шапки, а также для кода, не
начинающегося со счёта шапки, шаблон п. 1 прежний (сегмент — 1–2 цифры или заглавные буквы). Голый числовой код без
точки, не совпадающий со счётом шапки («441», «199»), при известном счёте шапки счётом не считается: это текст строки
аналитики (код контрагента, номер договора).
2. **«ИТОГ по <субсчёт>»** (`subaccount_sums`, данные) — суммы субсчёта: строка-счёт с таким же кодом, у которой **нет ни одной
суммы**, получает шесть сумм из своей строки «ИТОГ по …»; сама строка «ИТОГ по …» отдельной строкой в `rows` не
выдаётся (контракт не меняется). Субсчёт сверяется «сумма детей = родитель» как обычный родитель (§1.4). Наличие
хотя бы одной такой пары включает п. 3–4 для листа.
3. **Код с суммами → название без сумм → договоры.** Внутри блока субсчёта (между строкой-счётом и следующей строкой-счётом
или итогом):
- строка **с суммами, за которой следует строка без единой суммы** — контрагент: `COUNTERPARTY` (вид субконто — «Контрагенты»
или его синоним из шапки/таблицы, §1.1), `row_value` — код как в файле;
- строка **без сумм**, идущая сразу за такой строкой, — название этого контрагента: **склеивается с ним** (как строка ИНН
с наименованием, OQ-163): в `rows` не попадает, текст — в `counterparty_name`; `counterparty_inn` — по общим правилам
(ИНН в файле нет — пустой ИНН штатен);
- остальные строки **с суммами** до следующего контрагента, субсчёта или итога — **договоры** этого контрагента:
`CONTRACT` (§1.3, п. 3: после COUNTERPARTY дочерние продолжают классифицироваться по схеме; вид договоров здесь не
назван в шапке, поэтому тип задаёт сама раскладка), на уровень глубже контрагента. Суммы контрагента = Σ его договоров.
Иерархия листа: субсчёт → контрагент → договор (стратегия каскада `code_blocks`, `OSV_HIERARCHY_BY_CODE_BLOCKS`).
4. **«ИТОГ» — итог листа.** Строка с текстом ровно «ИТОГ» (`total_row_exact`, наравне с «Итого») — TOTAL; таблица кончается
на ней, а не на первой «ИТОГ по …» (та не начинается с «Итого» и итогом листа не является). Сверка «Итого = Σ строк
уровня 0» (§1.4) идёт по субсчетам.
**Иерархия по отступам: подуровень счёта, строка закрытия, вложенная аналитика** (AURORA-1858, tabular-parser #78).
ОСВ 1С без outline («Субконто», вид аналитики в шапке не назван) задаёт уровни только отступом: 0 у счетов, 1 у
аналитики. Отступов не хватает в трёх случаях, стратегия `indent` их уточняет:
1. **Подуровень счёта на том же отступе.** `68.01.1` стоит на одном отступе с `68.01`. Счёт, код которого продолжает код
более раннего счёта того же отступа в том же блоке (`68.01.1` → `68.01`), становится его ребёнком, вместе со своей
аналитикой. Только если все такие пары листа на одном отступе, раскладка однозначна и сумма детей перекладываемого
родителя после перекладки сходится; иначе лист остаётся по отступам (защищает плоские списки `60`, `60.01`, `60.02`).
2. **Безымянная строка закрытия.** Строка без текста и кода сразу за строкой счёта, с оборотами, равными оборотам счёта
(1С печатает её под 90.09 и 91.09), — ребёнок счёта. Пустую ячейку отступа читатель не хранит, поэтому без правила
строка попадала на уровень 0 и удваивала сумму корней.
3. **Вложенность аналитики по суммам** (решение владельца 28.10.2026 после ревью). Если аналитика родителя на одном
отступе, а вложенность видна только по суммам (91.01: «Прочие внереализационные» = две строки ниже), то **только у
родителя с нарушением «сумма детей = родитель»** строка, равная сумме следующих соседей, жадно становится их
родителем (как стратегия `sums`). Перестройка принимается, только если после неё родитель сходится, иначе — откат.
Защиты — правило применяется, только если одновременно:
- в схеме шапки после счёта нет названных видов аналитики («Субконто» без вида), то есть вложение не меняет
`group_type` и имя группы строки (иначе контрагент мог бы стать «договором» соседа);
- все дети родителя стоят на одном отступе (если отступ различает уровни, ему верим);
- ни вкладываемая строка, ни её новый родитель не несут ИНН.
Вложение меняет дерево в результате (`level`, `parent_row_number`), а не только проверку. Родитель без нарушения не
перестраивается; равные суммы соседей (`50 + 50 = 100`) сами по себе вложенности не дают. Остаточный риск:
- RR-6 — на листе «Субконто» без вида дубль строки аналитики с той же суммой, что у соседа выше, неотличим от
«группа → единственная статья» (на 91.01: «Проценты к получению» = «по векселям») и принимается за вложенность:
ошибка «лишняя строка» в такой форме не ловится.
Правила 1 и 3 опираются на сходимость, но это не «подгонка» (§1.0 п. 7): дерево меняется только в узких случаях, где
структура подтверждается и кодом/формой выгрузки, и суммами; реальное расхождение в остальных случаях даёт `needs_check`.
Нормализация кода счёта (ядро, `domain/osv_mapping.py`): `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1` —
числовой **второй** сегмент (1–3 цифры) дополняется нулём слева до двух цифр, первый и третий сегменты и буквенные
(`76.АВ`, `МЦ.04`) остаются как в файле. `account_code` строки хранится как в результате (`account_code_raw`),
нормализация нужна для `document.osv_account_code` и сверки с планом счетов. `account_root` = первый сегмент кода, если он
цифровой (`66`, `001` — забалансовый), иначе NULL (`ASSUMPTION(OQ-103)`). `account_id` = точное совпадение
нормализованного кода с `chart_of_account.code`, иначе NULL (администратор добавит; в seed плана счетов нет части
субсчетов — OQ-113).
### 1.4 Контрольные суммы парсера
Допуск ±0.01, пустое значение = 0.
- По каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit == closing_debit − closing_credit` (±0.01).
**Исключение — счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 90, 91 и 99 строки аналитики несут
только обороты, сальдо закрывается на субсчёт закрытия (91.09), поэтому тождество строк на них не проверяется; список
счетов — данные в `rules/*.yaml` tabular-parser (состав 90, 91, 99; счёт 90 добавлен решением владельца 19.10.2026: 4 файла
контура с колонкой «Показатели» оставались `needs_check` на счёте 90 (667d1ee1, 7983e91b, 3e74526e, ec075a82) — у
родителей 90.xx.y сальдо на конец есть, у аналитики его нет; tabular-parser#58, O-1). Проверки «сумма детей = родитель» и «Итого» на этих счетах сохраняются в таком виде (R-3 PR
arm-tabular-parser#26, подтверждено владельцем 08.10.2026): **обороты** родителя сверяются с суммой детей всегда;
**сальдо** — только у родителя с дочерними субсчетами (91 → 91.01/91.02/91.09); у субсчёта, под которым одна аналитика,
сальдо с детьми не сверяется (у аналитики оно пусто, у субсчёта — равно оборотам). Счёт строки сопоставляется со
списком по ближайшему счёту ветки: запись `91` покрывает `91`, `91.01`, `91.09`, но не `910`; строка
«Итого» проверяется как обычно.
- По каждому родителю: суммы родителя = Σ детей того же `data_type`. Строки-показатели (§1.3: «Вал.», «Кол.», код
валюты, «В валюте», метки колонки «Показатели» кроме «БУ») в суммирование не входят и сами родителями не проверяются. **Обороты** сверяются по колонкам (дебет с дебетом, кредит с кредитом). **Сальдо**
(начало, конец) — по колонкам либо, **если у родителя сальдо свёрнуто** (не больше одной ненулевой стороны), по нетто
`дебет − кредит` (R-21): 1С показывает счёт свёрнутым сальдо (62 = 16,1 млн Дт), а субсчета и контрагентов —
развёрнутым (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт). Без этого правила 32 из 66 реальных файлов не сходились бы
по шести колонкам только из-за способа показа. **[открыто]** OQ-105: подтвердить, что нетто-свёртка родителя —
штатное поведение 1С для всех версий выгрузки. Остаточные риски (проверками §1.4 не ловятся):
- RR-1 — у свёрнутого родителя сверяется только нетто: распределение сальдо детей между Дт и Кт (перестановка сторон
при совпавшем нетто) не проверяется;
- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя с потерянной в файле стороной
неотличимо от нетто-показа 1С и проходит при совпавшем нетто;
- RR-3 — нетто допускается на любом уровне дерева (не только «счёт → субсчета»): ошибка распределения сальдо между
договорами под свёрнутым контрагентом не ловится;
- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
строки, ловится лишь сверкой с развёрнутым родителем по колонкам;
- слепая зона: обе стороны сальдо развёрнутого ребёнка увеличены на одно X, а его непосредственный родитель свёрнут —
не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто, нетто ребёнка не изменилось,
значения родителя и выше не затронуты);
- родитель (0, 0) при детях (X Дт, X Кт) принимается (нетто 0) — осознанное поведение, закреплено тестом;
- вне R-21: текст вида «12.ООО» принимается за счёт (§1.3, R-16).
**Развёрнутый родитель над свёрнутым субсчётом** (AURORA-1863, решение владельца 28.10.2026, tabular-parser #78).
1С считает развёрнутое сальдо счёта по нижнему уровню аналитики, а субсчёт может показать свёрнутым: счёт 76 —
47,1 млн Дт / 12,9 млн Кт, его субсчёт 76.07 — 8,2 млн Кт при аналитике 4,6 млн Дт / 12,8 млн Кт. По колонкам счёт
с субсчетами не сходится, по нетто сходится до копейки. Сальдо развёрнутого родителя сверяется по нетто, **только если
среди его прямых детей есть «нетто-ребёнок»** — ребёнок, который сам сошёлся со своими детьми лишь по нетто (по
колонкам — нет). Признак идёт вверх только по непрерывной цепочке таких строк: строка, сошедшаяся по колонкам, его не
передаёт, так что слепая зона не растёт до корня. Обороты — по колонкам всегда. Развёрнутый родитель без
нетто-ребёнка сверяется по колонкам, как раньше: «нетто всегда» (широкий вариант) отклонён — он прячет любую ошибку,
сохраняющую нетто. Так в банке первичных документов остаются `needs_check` 14 файлов одной организации, где счёт 68
(и 71) расходится с субсчетами на одинаковую сумму по Дт и Кт (0,05; 137,00; 5 939,76) без свёрнутого ребёнка:
отличить схлопнутую пару от потерянной по данным нельзя. Остаточный риск:
- RR-5 — у развёрнутого родителя с нетто-ребёнком колонки сальдо не информативны: ошибка, сохраняющая нетто родителя
(одинаковый сдвиг Дт и Кт, перестановка сторон), не ловится, как у свёрнутого родителя (RR-1).
**Строки разреза «Период»** (AURORA-1823, tabular-parser #78). В выгрузке 1С с группировкой «Период» в шапке под
контрагентом идут строки «Обороты за <месяц> <год>» (или «… <N> кв. <год>»), у каждой своё сальдо на начало и конец
периода; сальдо конца периода — сальдо начала следующего. Группировка распознаётся по имени в схеме шапки (словарь
`period_groups` в `osv_groups.yaml`; тип группы остаётся `OTHER`, контракт не меняется). Если **все** дети родителя —
строки-периоды, родитель сверяется цепочкой: обороты = Σ оборотов периодов (по колонкам), сальдо начала = сальдо начала
**первого** периода, сальдо конца = сальдо конца **последнего** (в порядке строк; по колонкам либо по нетто, как выше),
и цепочка непрерывна: сальдо конца каждого периода = сальдо начала следующего (пропущенные месяцы разрыва не дают —
1С не печатает месяц без движения, сальдо переходит дальше). Сумма сальдо периодов не считается. У родителя со смешанными детьми (периоды и обычные строки) суммы считаются как
прежде.
- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0); если корней уровня 0 несколько (лист без
строки корневого счёта: верхний уровень — субсчета 58.01, 58.03, OQ-133, PR #13) — «Итого» == **сумме строк уровня 0**
(показатели без суммы и сама «Итого» не входят; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя
выше, R-21); при одном корне сверка прежняя по колонкам. **Единственный корень и свёрнутая «Итого»** (AURORA-1708, tabular-parser #73, arm-ka-docs #170): у счёта с развёрнутым сальдо 1С показывает корень развёрнутым (Дт и Кт), первую «Итого» — свёрнутой, а «Итого развернутое» равной корню до копейки. «Итого» сверяется с корнем как родитель с ребёнком (R-21): обороты по колонкам, сальдо начала и конца по нетто `дебет − кредит`, если сальдо «Итого» свёрнуто; если «Итого развернутое» совпадает с корнем по колонкам, основная «Итого» сверяется с корнем по оборотам (колонки) и нетто сальдо. Реальное расхождение нетто или оборотов остаётся нарушением; развёрнутая (обе стороны) «Итого», не равная корню по колонкам, нетто-сверкой не спасается. Плоский список под счётом шапки (§1.3, `header_account`) — тоже случай
«корня ACCOUNT нет»: «Итого» == сумме строк уровня 0 (прежняя сверка пустой/нулевой «Итого» — только когда строк уровня 0
нет). Вторая итоговая строка («Итого развернутое») — тоже `TOTAL`, **в любом порядке относительно «Итого»**: и сразу за ней,
и **перед** ней (плоская форма 1С, tabular-parser #59); сама в сверку «Итого = корень» не входит (только подтверждает нетто-сверку при единственном корне, AURORA-1708 выше); тождество строки на ней не проверяется, только если обороты пусты и на листе есть другая
«Итого» (перед «Итого» в 1С — без оборотов, тождество не проверяется; после «Итого» — с оборотами, проверяется как обычная строка;
tabular-parser #59, R-16, R-19) — сверяется основная «Итого» (если на листе только «Итого развернутое», она единственная итоговая и сверяется как «Итого»); строка сумм без подписи в конце таблицы принимается за «Итого» с предупреждением.
**Таблица кончается на первой «Итого»** (на корневом уровне оформления; плюс «Итого развернутое» сразу за ней): всё
ниже (подпись «Ответственный:», блоки, дописанные аналитиком, в том числе строки, начинающиеся с «Итого») — вне
документа, в `rows` не попадает. Сначала ищется первая строка корневого уровня с текстом ровно «Итого»
(`total_row_exact`), и только если такой нет — первая, начинающаяся с «Итого» (`total_row`): так контрагент
«Итого-Сервис» на плоском листе без outline и отступов не обрезает таблицу. Остаточный риск — только когда точного
«Итого» нет вовсе (ловит проверка «TOTAL = корень»). Числа в хвосте **в колонках таблицы** (от колонки текста до
правого края сумм) — предупреждение «ниже таблицы … есть числа» без смены статуса (статус решают контрольные суммы);
числа правее таблицы (контрольные суммы) хвостом не считаются. **Реализовано**, tabular-parser #47, AURORA-1664 (закрывает tabular-parser #7, M-2).
Если корня нет (**пустая ОСВ**: заголовок и
сразу «Итого» — в фикстурах две, schastl 58 и 66 за 2 кв. 2025) — «Итого» должна быть пустой или нулевой; такая ОСВ
штатна: `done`, `hierarchy=trivial`, `metrics.rows = 0`, предупреждение «нет строк данных». Если строка «Итого» не
найдена вовсе — сверка невозможна: `total_row_matches_root = false` и предупреждение «строка «Итого» не найдена»
(результат `needs_check`, R-20).
Нарушение → `recognition_job.status = needs_check`, `document.status = NEEDS_CHECK`, причина — в `metrics.warnings`,
номера строк — в `checks` результата. Парсер структуру под сходимость не подгоняет.
### 1.5 Маппинг периода (FNR01) → `period_quarter`, `is_cumulative`
| Текст | `period_quarter` | `is_cumulative` |
|---|---|---|
| 1 квартал, I кв., 3 месяца, январь-март, 01.01–31.03 | Q1 | true |
| 2 квартал, II кв. | Q2 | **false** [открыто: 1С может считать нарастающим] |
| полугодие, 1 полугодие, 6 месяцев, январь-июнь | Q2 | true |
| 3 квартал, III кв. | Q3 | **false** [открыто] |
| 9 месяцев, январь-сентябрь | Q3 | true |
| 4 квартал, IV кв. | Q4 | **false** [открыто] |
| год, за 2025 год, 12 месяцев, годовая | Q4 | true |
**Период-диапазон месяцев** («Январь - Май 2025 г.», «Октябрь 2024 г. – Сентябрь 2025 г.»; `periods.yaml`: `months`, `range`;
OQ-131, PR #13 и 2b): парсер заполняет границы `period_start`/`period_end`/`is_from_year_start` (контракт v1.2; для квартала
и года: конец — последний день квартала, начало — 1 января у нарастающего и первый день квартала у квартального;
`is_from_year_start` — `period_start` = 1 января). Диапазон «январь – конец квартала» — нарастающий
(`is_cumulative = true`, квартал по месяцу конца), ровно один квартал — квартальный, остальное (скользящие 12 месяцев,
полгода с апреля, конец не на границе квартала) — `period_year` = год конца, `period_quarter = null`,
`is_cumulative = null`, границы по тексту и предупреждение «не с начала года» / «конец диапазона не совпадает с концом
квартала»: ядро такой документ к версии не привязывает (OQ-131).
**Диапазон дат с точками и год из двух цифр (AURORA-1714, решение владельца 27.10.2026).** «За период | 01.01.25..30.06.25»
(метка в одной ячейке, диапазон в соседней, §1.1) разбирается как «01.01–30.06» по таблице выше: Q2, нарастающий;
разделитель `..` допустим наравне с пробелом и «по». Год из двух цифр в дате (`.25`) — 20yy; берётся год даты конца
диапазона. Год из четырёх цифр, если есть в тексте, имеет приоритет.
`ASSUMPTION(OQ-02)`: парсер определяет `period_quarter` и `is_cumulative` **только по тексту периода**
(`rules/periods.yaml`, правила идут от специфичных к общим): квартальная ведомость Q2–Q4 считается не нарастающей;
сверку по данным (`Q3.opening == Q1.opening`) выполняет ядро и может переопределить признак. `period_year` — четыре
цифры года 19xx/20xx из текста, не являющиеся частью длинного числа.
Практика на фикстурах: ОСВ «за 3 квартал 2024» у обеих компаний — **квартальные** (opening ≠ сальдо на 01.01), а
ОСВ «за 2024 г.» — годовые. Признак можно проверить по данным: если есть ОСВ Q1 того же года и
`Q3.opening == Q1.opening` → нарастающая.
**Сверка по данным (ядро, Срез 2c, `ASSUMPTION(OQ-02)`).** Для документа с `period_quarter ∈ {Q2, Q3, Q4}` и
`is_cumulative = false` маппер берёт документ `Q1` того же ЮЛ, года и счёта (файл не `SUPERSEDED`; при нескольких —
последний по `created_at`) и сравнивает сальдо на начало корневой строки листа (дебет и кредит, `NULL` = 0, допуск ±0.01
включительно). Совпало → `is_cumulative = true` и предупреждение маппинга «нарастающая по данным Q1»; предупреждение
делает документ `NEEDS_CHECK` (§9.3, OQ-116). Нет документа Q1 или корня — признак остаётся по тексту периода.
Сверка периода документа с периодом версии → `document.period_check` (MATCH / MISMATCH / UNKNOWN); загрузка не блокируется;
до появления версии консолидации (Срез 4) ядро пишет `UNKNOWN`.
### 1.6 Результат парсера (Срез 2a): статусы, коды, лимиты, словари
Результат — файл по контракту v1.1: по одному `osvDocument` на лист ОСВ (`locator = sheet:<имя листа>`; имя листа
подставляется в `locator` и сообщения, но поиск по нему не ведётся). Форма и пример — `contracts/examples/
result-osv-needs-check.json`.
| Исход | Условие |
|---|---|
| `done` | контрольные суммы §1.4 сошлись на всех листах |
| `needs_check` | нарушена любая проверка §1.4 хотя бы на одном листе (или «Итого» не найдена); причины — `metrics.warnings`, номера строк — `checks` |
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ; в сообщении — число просмотренных листов |
| `error` / `INVALID_FORMAT` | лист ОСВ есть, но нет заголовка таблицы, обязательной группы сумм (§1.0 п. 2) или иерархию определить не удалось; в сообщении — лист и шаг |
| `error` / `UNREADABLE` | книга не открывается (лог WARNING) |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура файла не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек) |
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение; одна строка `Тип: сообщение`, стек — в лог ERROR |
Формат определяется по сигнатуре байтов (zip → XLSX, либо XLSB при наличии `xl/workbook.bin`; OLE2 → XLS), а не по
`file_type` запроса; несовпадение — предупреждение. Листы без признаков ОСВ и форм пропускаются с
предупреждением (`sheet=<имя> skipped: нет признаков ОСВ или формы`; листы форм теперь разбираются, §11.2). Другие предупреждения:
`sheet=<имя> hierarchy=<стратегия>` (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное
поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение
«xlsb: outline level недоступен»). xls (BIFF5/7) без записи CODEPAGE читается как cp1251 (кодовая страница 1С и Excel в
русской локали; по содержимому кодировка не угадывается) с предупреждением «xls без кодовой страницы — строки прочитаны
как cp1251»; без этого xlrd декодирует строки как latin-1 и лист ОСВ не находится. BIFF8 хранит строки в Unicode и от
CODEPAGE не зависит. **Реализовано**, tabular-parser #47, AURORA-1662.
Имена частей xlsx сравниваются без учёта регистра, как в Excel: часть, которая отличается от ссылки `[Content_Types].xml`
или `*.rels` только регистром (`xl/SharedStrings.xml` при ссылке на `sharedStrings.xml` — так пишут некоторые выгрузки),
читается под именем из ссылки; без этого книга не читается (`UNREADABLE`), а лист с таким именем пропадает молча. Две
части под одну ссылку не угадываются. **Реализовано**, tabular-parser #66, AURORA-1707.
**С контракта v2.0** (change `unified-statement-normalization`, §11.9) у каждого `osvDocument` есть `status` (`done`, когда
контрольные суммы §1.4 листа сошлись, иначе `needs_check` — те же условия, что выше, но по листу) и `warnings[]` —
перечисленные предупреждения листа структурами `{code: "OSV_…", severity: "info", message}`; строки `sheet=<имя> …` в
`metrics.warnings` больше не пишутся (предупреждения уровня книги — `metrics.warnings` структурами, например
`FILE_TYPE_MISMATCH`). Разбор ОСВ не меняется.
Лимиты: `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) и `PARSE_TIMEOUT_SECONDS` (120 с; кооперативный — проверка между листами,
в циклах по листу и между стратегиями; чтение книги библиотекой он не прерывает). Поэтому до библиотеки чтения действуют
лимиты чтения (R-23, R-25) → `UNSUPPORTED_FORMAT`: распакованный zip (xlsx/xlsb) ≤ 10 × `PARSE_MAX_BYTES`, суммарная
площадь объединённых ячеек книги ≤ 200 000 (реальный максимум 10 090; xlsx — потоковый expat-скан всех XML-частей до
openpyxl, после чтения — только диапазоны, которые индекс листа раскладывает по ячейкам). Объём самих ячеек,
`sharedStrings`/стили и XML-бомбы (`defusedxml`) не ограничены — **[открыто]** OQ-108. Любой реальный файл разбирается быстрее 5 с (максимум ≈ 0,6 с).
Словари: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет»/«Кредит», колонка ИНН, «Итого», метки шапки),
`osv_groups.yaml` (группировка → `group_type`), `periods.yaml` (период → квартал и нарастающий итог), `data_types.yaml`
(БУ/НУ/сумма, строки «Вал.»/«Кол.»/«Кол-во»/«Кол-во, <единица>», метки колонки «Показатели», коды и написания валют). Каждая запись словаря обязана срабатывать хотя бы на одной
фикстуре или синтетике (тест покрытия).
---
## §2. Приведение периодов (DOC-259006; Срез 4, решения владельца 06.10.2026: OQ-03, OQ-19)
Комплект (ББ + ОФР + ОСВ) за один период нарастающим итогом. Если ОСВ квартальные (`is_cumulative = false`), при построении
сводной ОСВ (§3) строится **расчётное представление** (не хранится, DECISION-5). Реализация —
`domain/consolidation_periods.py:select_sources` (выбор документов) и `domain/consolidated_osv.py:build` (склейка).
**Выбор источников на пару (ЮЛ, счёт документа)** среди документов, привязанных к версии (§3 п. 1); `n` — квартал версии:
1. есть привязанный документ с `is_cumulative = true` и `period_quarter = n` — он один, строки берутся как есть;
2. иначе нужны привязанные квартальные документы за **каждый** квартал `Q1..Qn`; любого нет — **пробел**
(`Gap(missing_quarters)`), строк пары в своде нет, пробел показывается в сводке (`gaps[]`) и в листе «Пробелы» экспорта;
3. нарастающий и квартальные одновременно — берётся нарастающий (он полнее по определению);
4. **Q1-эквивалентность ОСВ (R-14, design №81; OQ-124):** документ ОСВ за `Q1` равнозначен и нарастающему, и квартальному
(парсер помечает ОСВ за Q1 как нарастающую: период с начала года = Q1) — в версиях Q2+ он служит квартальным источником
Q1. ББ/ОФР за Q1 версиям Q2–Q4 не подходят (R-14a: баланс на конец Q1).
**Склейка квартальных строк** — ключ `(account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn))`;
договоры (`group_type = CONTRACT`) в свод не входят — строка COUNTERPARTY уже содержит их сумму:
```
opening_* = opening_* строки Q1
turnover_* = Σ turnover_* по кварталам Q1..Qn
closing_* = closing_* строки Qn
```
Отсутствующая в квартале строка ключа = нули; `None` в ячейке = 0; две строки с одним ключом внутри квартала
суммируются (design №1); `entry_ids` строки свода перечисляет все источники. Нарастающий документ отдаётся строка в
строку (OQ-125: различие с квартальным Q1 версии Q1 — только на дублях ключа и `None`). Строки свода сортируются:
имя ЮЛ, счёт (натуральный порядок: `9 < 60 < 60.2 < 60.10`), номер строки.
---
## §3. Сводная ОСВ и сопоставление контрагентов (DOC-221278, FNR01 п. 9)
Вход: `consolidation_version_id`. Периметр — `consolidation_member` версии (не текущий `related_group_member`!).
1. Для каждой компании периметра: `document` типа OSV, входящие в `consolidation_document` версии (**явная привязка**, а не
«последние подтверждённые»). Документ **подходит** версии, если ЮЛ — член периметра, `period_year` = год версии, и:
нарастающий (ББ/ОФР — всегда) — `period_quarter` = квартал версии; квартальный — `period_quarter` ≤ квартал версии;
ОСВ за `Q1` подходит версии любого квартала года (§2 п. 4, OQ-124). Привязываются только `CONFIRMED` документы
незаменённых файлов. Точки привязки: подтверждение документа (ко всем версиям «в работе»), создание версии и добавление
ЮЛ в периметр (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`);
при привязке документ с тем же ключом у того же ЮЛ заменяется. Точки отвязки: автозамена файла (§9.5 п. 3), удаление ЮЛ
из периметра, уточнение периода или счёта подтверждённой ОСВ (§9.5 п. 12, v5.20). Версии `CANCELLED` привязок не меняют. Выбор источников и склейка кварталов — §2.
2. Строки `osv_entry` этих документов с `group_type = COUNTERPARTY`; `data_type IN (BU, SUM)`.
3. Строки **не агрегируются**: сто строк по 60.01 у компании — сто строк в своде. `counterparty_name/inn` показываются
как в файле.
4. Резолв контрагента (при маппинге результата — Срез 2c, и повторно при построении свода, если `counterparty_id IS NULL`):
ИНН → `legal_entity.inn`; иначе точное имя → `legal_entity.name`; иначе `normalize(name)` →
`legal_entity_alias.normalized_alias`. Несколько кандидатов → NULL (ручное сопоставление). На шаге нормализованного
имени **алиас приоритетнее имени** (решение владельца 07.10.2026, OQ-128; реализация — RSHB/arm-core#57): алиас уникален
(`409 ALIAS_TAKEN`), поэтому совпадение с `normalized_alias` однозначно — берётся владелец алиаса, даже если есть ЮЛ-тёзка
по нормализованному имени; NULL — только при нескольких совпадениях по имени без алиаса.
`normalize` = lower, trim, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы.
Реализация при маппинге (`repositories/counterparties.py:CounterpartyIndex`): справочник читается целиком **без policy
видимости** (контрагент может быть ЮЛ другого филиала); применяется к строкам `group_type = COUNTERPARTY` с
`data_type ∈ {BU, SUM}`; ИНН строки сравнивается точно после удаления пробелов (мусор вроде «нерез.» не ищется, строка
проваливается к имени); каждый шаг считает кандидатов отдельно: единственное точное совпадение имени побеждает
неоднозначность нормализованного; имя из одних ОПФ (пусто после `normalize`) не ищется.
5. Признак контрагента: ГСК / ГК / Внешний (см. `04-data-model-guide.md`).
6. Количество строк свода = количеству отобранных `osv_entry` для нарастающей ОСВ; для квартальных документов строки
склеиваются по ключу (§2), `entry_ids` строки свода перечисляет источники.
7. **Дорезолв на чтение.** Строка без `counterparty_id` резолвится на лету тем же `CounterpartyIndex`; результат **не
сохраняется** (производное, DECISION-5): в ответе `match_stored = false`, `match_kind` — шаг резолва. Сохранённое значение
`osv_entry.counterparty_id` приоритетнее; при расхождении между кварталами берётся значение самого позднего квартала
(расхождение — в `warnings` строки).
8. **Способ сопоставления** `osv_entry.counterparty_match_kind` (DBML v5.16): `INN`/`NAME`/`ALIAS` — шаг, на котором при
маппинге нашёлся единственный кандидат; `MANUAL` — ручное сопоставление; `PROPAGATED` — автоприменение; `NULL` ⇔
`counterparty_id IS NULL`. Отдельное поле нужно, потому что после ручного сопоставления появляется алиас и ручное решение
становится неотличимым от `ALIAS`.
**Ручное сопоставление** (алгоритм в DOC-221278; Срез 4, `POST …/osv/entries/{entry_id}/counterparty`): правка версии —
право `CONSOLIDATION_EDIT`, статус «в работе» и своя блокировка (§10).
- **Кандидаты:** `scope=group` (по умолчанию) — ЮЛ периметра и ЮЛ той же ГК, `q` необязателен; `scope=all` — весь справочник
без policy видимости, `q` не короче 3 символов (иначе 422 `QUERY_TOO_SHORT`); поиск — ИНН по префиксу, имя и алиасы —
подстрока без регистра; не больше 100; `recommended` — нормализованное имя/алиас совпало с `normalize_alias` имени
строки `entry_id` (точное совпадение, OQ-127); рекомендуемые первыми.
- **Строка** — строка свода версии: `group_type = COUNTERPARTY`, `data_type ∈ {BU, SUM}` привязанного ОСВ-документа; строки
`NU`/`ACCOUNT` — 404 и в автоприменение не входят (R-11, §3 п. 2; OQ-126).
- **Шаги в одной транзакции:** выбор → проверка дубля `normalized_alias` у другой компании (`409 ALIAS_TAKEN` со списком
владельцев; с `move_alias = true` алиасы владельцев удаляются и алиас переносится — право `DIRECTORY_DELETE` для переноса
не требуется, решение владельца 06.10.2026) → создать `legal_entity_alias` (`LEGAL_ENTITY_CHANGE`) →
`osv_entry.counterparty_id = выбранное`, `counterparty_match_kind = MANUAL`; `counterparty_name/inn` не трогать →
автоприменить к строкам **всех** ОСВ-документов версии с тем же `counterparty_name.strip()`, пустым `counterparty_id` и
ИНН пустым или равным ИНН выбранного (`PROPAGATED`; ранее связанные строки не перевязываются) →
`audit_event(OSV_COUNTERPARTY_MAP)` с `before/after` и `propagated_entry_ids`.
- Строка `osv_entry` общая у всех версий, куда входит документ: сопоставление в одной версии меняет её для всех (OQ-123).
- Пустое имя (после нормализации) — алиас не создаётся и автоприменения нет. Тёзка по имени после ручного сопоставления не мешает: алиас приоритетнее имени (п. 4, OQ-128).
Наблюдение по фикстурам: у «Счастливочной» контрагент «Русский бисквит» идёт **с ИНН 3528052718** в колонке E, а у
«Русского бисквита» ОСВ без колонки ИНН — контрагент «Пекарня Счастливочная ООО» только текстом → резолв по алиасу.
9. **Пары участников без встречных записей** (решение владельца 27.10.2026, AURORA-1746, arm-core #195). Для участников
периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна строка
ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение, п. 7) и нет ни одной строки ЮЛ Б с
`counterparty_id = А`. А — `holder` (записи есть), Б — `missing` (встречных записей нет). Пара, где записей нет ни у
А, ни у Б, не входит; строки с контрагентом — самим собой не учитываются; счета, суммы и `data_type` на признак не
влияют. Это чистая функция от строк свода и периметра, не хранится (DECISION-5). Порядок — по имени Б, затем по
имени А. Поля пары: `holder_id/name`, `missing_id/name`, `holder_rows` (строки А с контрагентом Б),
`missing_unmapped_rows` (строки Б с `unmapped = true`), `missing_rows_total` (все строки Б в своде; 0 — ОСВ не
привязаны или по ним пробелы). Назначение — показать аналитику перед шахматкой места, где в строках без ИНН может
прятаться участник периметра; переход к шахматке при непустом списке **не блокируется**, несопоставленные строки
по-прежнему считаются «Внешний» (п. 5; AURORA-1388, AURORA-1865). Закрывается ручным сопоставлением (см. выше).
Строки Б, у которых ИНН в файле есть, но его нет в справочнике, сопоставления не требуют и в
`missing_unmapped_rows` не входят — отдельная задача AURORA-1866.
---
## §4. Правила элиминации — пары счетов (Прил. 3 ТЗ, DOC-223044, FNR03)
Хранятся как данные: `elimination_rule` (+ `elimination_rule_account`), версионируются; версия фиксируется в
`consolidation_version.elimination_rules_version`. Для версии консолидации берётся версия правил, **действующая на дату
составления консолидации** (создания версии), а не на её отчётный период; фиксируется при создании версии и дальше не
меняется (OQ-174, решение владельца 21.10.2026). С DBML v5.23 поле обязательно (решение владельца 23.10.2026, AURORA-1536):
версия консолидации без действующих правил не создаётся, у версий, созданных раньше, номер проставлен миграцией по дате
создания. Seed-набор для ББ (`statement_type = balance`, `measure = closing`):
| № | Статья А (Дт) | Счета А (closing_debit) | Статья Б (Кт) | Счета Б (closing_credit) | mode | Примечание |
|---|---|---|---|---|---|---|
| 1 | 1150 ОС | 03, 76.07.9 (01 — резервный) | 1450 / 1550 Прочие обязательства | 76.07.1 | manual | лизинг/аренда внутри группы; у Б: 76.07.1 − 76.07.5 − 76.07.9. Если у А нет ОСВ 03 — результат в строку «ППА» [открыто] |
| 2 | 1170 ФВ долгосрочные | 58.01 | 1310 / 1350 УК / добавочный | 80, 83 | manual | **гудвилл**; если у Б нет ОСВ 80/83 — элиминируется 58.01 А; `1310(Б) − 58.01(А) > 0` → строка «Гудвилл» |
| 3 | 1170 ФВ долгосрочные | 58.03 | 1410 Займы долгосрочные | 67.03 | min | |
| 4 | 1240 ФВ краткосрочные | 58.03 | 1510 Займы краткосрочные | 66.03 | min | |
| 5 | 1230 Дебиторка | 76 (кроме 76.07.5, 76.07.9) | 1510 Заёмные краткосрочные | 66.04, 67.04 | min | проценты |
| 6 | 1230 Дебиторка | 60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76 (кроме 76.07.5, 76.07.9) | 1520 Кредиторка | 60.01, 60.03, 60.21, 60.31, 62.02, 62.22, 62.32, 76 (кроме 76.07.1) | min | основная пара |
| 7 | 1230 Дебиторка | те же, что № 6 | 1410 Заёмные долгосрочные | 67 | min | |
| 8 | 1230 Дебиторка | те же, что № 6 | 1510 Заёмные краткосрочные | 66 | min | |
Правило 5 % для пар: суммы равны → маппим; расхождение ≤ 5 % (к меньшей) → берём `min`; > 5 % → подсветка, авто не
маппится, ждёт корректировки (`ADJUSTMENT`).
ОФР (`statement_type = income`, `measure = turnover`), из FNR03 — **[открыто]**: формализовать с методологами:
| Статья | Сторона А | Сторона Б | mode / НДС |
|---|---|---|---|
| 2110 Выручка | Кт 90.01 (с НДС, приоритет) либо Дт 62.01/62.21/62.31 по контрагенту | Кт 60.01/60.21/60.31 + 76 (кроме 76.07.*, 76.03.*, плюс 76.07.2) | `full` — берётся сторона А; без НДС = с НДС / (1 + средневзвешенная ставка/100) |
| 2120 Себестоимость | — | — | элиминация = ВГО по 2110 (зеркально, без НДС) |
| 2310 Доходы от участия | Дт 76.03 | Кт 76.03 | сторона А |
| 2320 Проценты к получению | Дт 76.03/76.05/76.08/76.09 | Кт 66.02/.04/.06/.22/.24/.26, 67.02/.04/.06/.22/.24/.26 | min |
| 2330 Проценты к уплате | Кт чётных субсчетов 66/67 + 76.07.5/76.08/76.28/76.38 | Дт 76.03/.05/.08/.09 | min |
Дополнительные ручные корректировки ОФР (ТЗ 6.4, `correction.reason_code`): нереализованная выручка, акцизы
(`osv_entry.is_excisable` для 90), агентское вознаграждение, внутригрупповая амортизация по аренде, проценты по
внутригрупповым займам, доходы от участия, прочее.
---
## §5. Автоматическое сопоставление ВГО (DOC-223044; OQ-07, OQ-173 — решения владельца 19.10, 21.10 и 23.10.2026)
Границы: только `closing_debit` / `closing_credit`; только компании периметра версии; правила и счета сторон — из §4
(версия правил — `consolidation_version.elimination_rules_version`), только правила ББ (`statement_type = balance`,
`measure = closing`; правило с другим показателем — ошибка `INVALID_RULE`); строки `group_type = COUNTERPARTY`,
`data_type ∈ (BU, SUM)`. Единица расчёта — **тройка** (правило r, компания A, компания B). Правила считаются для каждой
упорядоченной пары компаний — и (A, B), и (B, A); поэтому зеркальные правила № 9–16 Прил. 3 в seed не нужны (№ 14 для
(A, B) — это № 6 для (B, A)).
Два уровня: **сумма элиминации** по тройке — агрегат, идёт в КФО; **упаковка** — какие строки ОСВ с какими сопоставлены
(детализация, строки без пары). Способ упаковки сумму элиминации не меняет (решение владельца 19.10.2026).
**1. Сумма элиминации по тройке — каждое правило отдельно.** Каскад по остатку в порядке `rule_no` **отменён** (решение
владельца 21.10.2026 по созвону с заказчиком 19.10.2026, OQ-173): счета правил пересекаются (дебет № 5–8 общий; кредит
67.03 — № 3 и маска 67 у № 7, 66.03 — № 4 и маска 66 у № 8), и строка такого счёта пока входит в Σ **каждого** подходящего
правила. Двойной учёт снимает разнесение строк по правилам и ручной перенос между правилами (AURORA-1751); до него
сумма элиминации по паре компаний может превышать сумму стороны — это известное ограничение, не дефект.
```
ΣA = Σ строк A с counterparty_id = B по счетам стороны 1 правила r (с исключениями is_excluded), сумма стороны side_1
ΣB = Σ строк B с counterparty_id = A по счетам стороны 2 правила r, противоположная сторона
deviation_amount = |ΣA − ΣB|; deviation_percent = deviation_amount / min(ΣA, ΣB) * 100
mode = min: расхождение ≤ 5 % → элиминация_r = min(ΣA, ΣB); > 5 % → подсветка, элиминация_r = 0, ждёт корректировки
mode = manual: элиминация_r = 0 — только корректировка / гудвилл (§8)
```
**Знак** (решение владельца 23.10.2026): суммы сторон — **со знаком**, как в шахматке §6 (одно правило и одна пара дают
одинаковые ΣA и ΣB на обоих экранах). Отрицательное сальдо строки (§1: возможно) — встречная позиция, например аванс или
переплата: у A по B строки 150 и −50 дают ΣA = 100, а не 200. Если `min(ΣA, ΣB) ≤ 0` (одна сторона пуста или ушла в
минус), процент не считается (`null`), элиминация 0, тройка подсвечивается при любом расхождении — разбирает аналитик
(в шахматке тот же случай — OQ-175).
**2. Упаковка строк в тройке** — группами **1:1, 1:N, N:1** (правило охватывает несколько счетов, поэтому одной
строке на одной стороне могут соответствовать несколько строк на другой). N:M нет. Выбирается **минимальная упаковка** —
группа с меньшим числом строк: если сумму закрывают и «1 Дт ↔ 2 Кт», и «1 Дт ↔ 3 Кт», берётся 1:2. Каждое правило
упаковывается независимо (каскада нет): строка может войти в пары разных правил, пока её не разнесли (AURORA-1751).
```
вход правила r: строки a стороны 1 (A → B) и b стороны 2 (B → A) по счетам правила r с суммой > 0
строки с суммой < 0 в упаковку не идут → «без пары», причина NEGATIVE_AMOUNT (разбирает аналитик)
строки с суммой 0 → «без пары», причина ZERO_AMOUNT
для k = 2 … 6 (число строк группы: 1:1, затем 1:2 и 2:1, … до 1:5 и 5:1; N ≤ 5):
кандидаты размера k из ещё не сопоставленных строк, «закрывающие» сумму:
точные (Σ одной стороны == Σ другой), затем с расхождением ≤ 5 % к меньшей — по возрастанию ПРОЦЕНТА расхождения
(решение владельца 23.10.2026)
группа без соперников → пара, её строки выбывают
две и более группы с тем же k и тем же процентом расхождения, делящие строку → не сопоставляем, на ручное (AMBIGUOUS)
строки, не вошедшие ни в одну группу → «без пары» (NO_MATCH)
результат группы: rule, A, B, osv_entry.id обеих сторон, Σ A, Σ B, deviation_amount, deviation_percent
```
Результат **не хранится** (DECISION-5) — возвращается как структура для шахматки, корректировок и КФО. Ручное решение по
паре — `correction` с естественным ключом (`rule_id`, `legal_entity_id`, `counterparty_id`).
**3. Выдача.** Два запроса (контракт — `dev/frontend-api-guide.md`): **список пар версии** — все тройки «правило ББ ×
упорядоченная пара компаний периметра», где хотя бы одна сторона не ноль, с ΣA, ΣB, расхождением, процентом, суммой
элиминации, статусом (сопоставлено автоматически / с расхождением > 5 % / без встречной стороны / ручное правило) и
`osv_entry.id` строк обеих сторон; сводная ОСВ строится один раз на запрос. **Детализация тройки** — упаковка п. 2:
точные группы, группы с расхождением ≤ 5 %, строки без пары с причиной.
**Алгоритм — по ТЗ** (решение владельца 08.10.2026, OQ-155): пары, `min`, порог 5 %, а не полная элиминация каждой
стороны, как в эталонном Excel. Пример на фикстурах (Q3 2024, правило № 6): у Счастливочной на «Русский бисквит» 62.01
«БП Производство» 106 311 116,46, 62.01 «БП Аренда» 1 401 330, 76.06 350 000, 76.АВ 2 366 666,69; у РБ — одна строка
60.01 106 311 116,46. Упаковка: точная пара 1:1 (106 311 116,46), остальные три строки Счастливочной — без пары.
Сумма элиминации: ΣA = 110 429 113,15, ΣB = 106 311 116,46, расхождение 3,87 % ≤ 5 % → min = 106 311 116,46. Строки 76.06
и 76.АВ входят и в правило № 5, если у РБ есть 66.04/67.04 на Счастливочную, — двойной учёт до AURORA-1751.
---
## §6. Шахматка и результирующая таблица (DOC-221277)
Вход: версия + набор дебетовых счетов (`kind ∈ A, AP`) и кредитовых (`kind ∈ P, AP`); предустановки: «58.03 vs
67.03 + 66.03», «60 + 62 + 76 vs 60 + 62 + 76», «все счета» — только балансовые счета, без забалансовых 001–012
(`is_off_balance = true`; решение владельца 16.10.2026, AURORA-1651: 009 «Обеспечения выданные» давал ложное
расхождение); при ручном выборе забалансовые счета допускаются. Дополнительно пресетом может быть **правило ББ из §4**
(`statement_type = balance`): дебетовые и кредитовые счета берутся из сторон правила (с исключениями `is_excluded`),
показатель — `measure` правила (у всех правил ББ seed v1 — `closing`); каскада §5 нет — это просмотр сумм по счетам правила, не сумма элиминации (решение владельца
21.10.2026, arm-core #141). Правила ОФР пресетом не являются (ОФР в §4 открыт).
Шахматка — суммы первички: корректировки (§8) в ячейках не учитываются, список «строк без пары» шахматка не даёт —
строки ОСВ ячейки — AURORA-1653 (`osv_entry_ids_*` ячейки), строки без пары — упаковка §5 п. 2 (AURORA-1536).
Строки/столбцы — компании периметра; диагональ пуста.
```
amount_a(A,B) = Σ closing_debit строк A с counterparty_id = B по дебетовым счетам
amount_b(A,B) = Σ closing_credit строк B с counterparty_id = A по кредитовым счетам
variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amount_b) * 100 (null при min = 0)
one_sided = ровно одна из amount_a, amount_b равна 0 (знак ненулевой не важен)
подсветка = variance_pct > 5 % ИЛИ one_sided
ячейка: amount_a / amount_b / (variance)
```
Пара без встречной стороны (`one_sided`) — по суммам, а не по наличию строк ОСВ: строка с нулевым сальдо (одни обороты)
стороной не считается; «0 против 0» не подсвечивается (AURORA-1651). Подсветка одинакова в API, в xlsx и на экране.
Процент при отрицательной меньшей стороне — OQ-175; счёт `000` в «все счета» — OQ-176.
Фильтры строк — как в §3 (COUNTERPARTY, BU/SUM, `account_id IS NOT NULL`).
Результирующая таблица «компания против группы» по статьям: Дебиторка (Дт 60.02, 60.22, 60.32, 62.01, 62.03, 62.21,
62.31, 76 кроме 76.07.5/76.07.9), Кредиторка (Кт 60.01, 60.03, 60.21, 60.31, 62.02, 62.22, 62.32, 76 кроме 76.07.1),
ФВ (Дт 58.01, 58.03), Долгосрочные займы (Кт 67, 67.03), Краткосрочные (Кт 66, 66.03, 66.04); колонка «Итого по группе».
---
## §7. Логические проверки ББ/ОФР (DOC-221281, FNR03 шаг 1)
Выполняются по **каждой колонке-дате** отдельно; ничего не меняют; результат не хранится.
ББ: `1700 − 1600 = 0`; `1100 = 1110 + 1150 + 1160 + 1170 + 1180 + 1190` (+1120, 1130, 1140 если есть);
`1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260`; `1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370`;
`1400 = 1410 + 1420 + 1430 + 1450`; `1500 = 1510 + 1520 + 1540 + 1550`; `1600 = 1100 + 1200`; `1700 = 1300 + 1400 + 1500`.
ОФР: `2100 = 2110 − 2120`; `2200 = 2100 − 2210 − 2220`; `2300 = 2200 + 2310 + 2320 − 2330 + 2340 − 2350`;
`2400 = 2300 − 2410 + 2420` (знаки 2410/2420/2430 — по ФСБУ 4/2023 уточнить [открыто]).
Межформенная: `1370(отчётная дата) − 1370(начало года) = 2400(ОФР за период)` — при расхождении КА может акцептовать
с комментарием (`financial_statement_meta.checks_accepted_*`).
**Самопроверка в сервисах (общий код, §11.8; change `unified-statement-normalization`, контракт v2.0).** Оба сервиса
(tabular-parser и scan-gateway) считают §7 одним кодом `arm_statements` по одному словарю `statement_checks.yaml` и кладут
результат в `documents[].checks` (`relations_failed`, `soft_relations_failed`; у нарушения — `column_index`, текст
соотношения, `codes` — коды цели и членов, `expected`, `actual`). Ядро хранит `checks` как пришли (`document_issue`) и
пересчитывает тем же кодом только после правок аналитика; своей копии словаря у ядра нет. Правила счёта:
- **Жёсткие** (нарушение → `needs_check`): ББ — `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`,
`1700 = 1300 + 1400 + 1500`, `1600 = 1700`; ОФР — `2100`, `2200`, `2300`. **Мягкие** (`info`, `ASSUMPTION(OQ-12)`): `2400 =
2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`, `2410 = 2411 + 2412`), `2500 = 2400 + 2510 +
2520 + 2530`, «сумма пятизначных подстрок = родитель» (по колонке — только если хотя бы одна подстрока родителя в ней
непуста: `amount` не `null`, ноль — значение; AURORA-1527). Мягкое соотношение на статус и подсветку не влияет, но
**сошедшееся точно** (расхождение в пределах допуска ±1, без «округления») подтверждает неуверенные значения своих
кодов (Р-21, §11.9): так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки.
- **Значение кода** в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, Р-4); строки
`detail`, `unreadable`, `missing` в соотношения не входят; нарушение относится ко всем строкам его кодов.
`duplicate_line_codes` не выдаётся.
- **Посчитанное соотношение.** Соотношение считается, если непуста цель или хотя бы один член (есть строка `ok` с кодом
и непустой суммой); пусто всё — пропускается. Пустая цель при непустых членах — нарушение («БАЛАНС» без кода ушёл в
`detail` — 1600 пуст, `1600 = 1100 + 1200` подсвечивает родителя); непустая цель при пустых членах — тоже нарушение
(лист только с активом: `1600 = 1700` при отсутствующем 1700 — `needs_check`). До v2.0 соотношение с отсутствующей
целью пропускалось у обоих сервисов и в ядре — поведение изменено; посчитанное соотношение из одних `null` ничего не
подтверждает.
- **Допуск и округление:** жёсткое соотношение не сошлось, если расхождение больше ±1 в единицах формы **и** больше
0,001 % наибольшего по модулю числа соотношения — только тогда оно в `relations_failed`; расхождение в пределах
округления (OQ-138) и мягкие соотношения — в `soft_relations_failed`, это `info`, на статус и подсветку не влияют.
Соотношения считаются по отображаемым значениям (§11.2, числовой формат Excel).
- **Члены по модулю (`abs_terms`: 2120, 2210, 2220, 2330, 2350, 2410).** Правая часть считается дважды — как записано и
с этими членами по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Такое соотношение сходится при
любом знаке члена и потому **подтверждает величину, но не знак**: знак сомнительной ячейки подтверждается конвенцией
колонки (§11.9).
- **Упрощённый набор.** Набор выбирается по `form_variant` документа (§11.7): у упрощённой формы нет подытогов 1100 /
1200 (ББ) и 2100 / 2200 / 2300 (ОФР), свои равенства по бланку упрощённой формы (приказ 66н, прил. 5;
`ASSUMPTION` до первого образца в fixtures): ББ — `1600 = 1150 + 1170 + 1210 + 1230 + 1250` (жёсткое), `1700 = 1300 +
1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550` (жёсткое), `1600 = 1700` (жёсткое); ОФР — `2400 = 2110 − 2120 − 2330 +
2340 − 2350 − 2410` (**мягкое**, как `2400` полного набора: знак 2410 не определён, OQ-12; `abs_terms` те же; решение
владельца 18.10.2026, Р-22 / S-5). Оба набора — в одном словаре `statement_checks.yaml`. Без упрощённого набора полный
дал бы нарушения «пустой цели» на каждой упрощённой форме. `form_variant = null` (документ, оставшийся в БД с времён до
v5.19, если база не очищена) — полный набор; у результата 1.x вариант определяется по структуре кодов при приёме.
- **Родитель пятизначной подстроки** — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код
из первых четырёх цифр (12310 под 1230); то же правило ядро отдаёт экрану как `parent_code`.
- **Межформенная** `1370(отчётная дата) − 1370(начало года) = 2400` сервисами не считается — только ядром (ниже).
**Ядро.** При загрузке результата v2.0 ядро §7 **не пересчитывает**: `checks`, `needs_review` и статус документа
берутся из результата (§9.3, §9.5 п. 2). После правки аналитика (`PATCH …/statement`) ядро пересчитывает §7 копией
`arm_statements` по текущим суммам (`financial_statement_value.amount` после правок) — с теми же правилами, что в
сервисах. Пересчёт идёт точкой входа пакета для пересчёта по сохранённым данным (спека `statement-normalization`):
она же заново определяет вариант формы и редакцию документа для проверок ядра. Результат 1.x (окно выкладки) `checks`
не несёт: ядро **один раз при приёме** считает §7 той же точкой входа (вариант — по структуре кодов) и пишет нарушения в
`document_issue` (`source = core`, `relation` / `soft`); условие D-6 «проверки сошлись» для 1.x берётся из этого
расчёта. Формы, существовавшие до миграции v5.19, не пересчитываются: перед выкладкой база очищается от форм ББ/ОФР
(Р-24, §9.5 п. 2), а у формы, оставшейся в неочищенной базе, записей `document_issue` нет — до первой правки её
нарушения не показываются и подтверждение их не проверяет (корректность не гарантируется). Экран (`GET …/statement`) и подтверждение (`CHECKS_NOT_PASSED`) читают нарушения **только** из
`document_issue` — у любой формы, какой бы версией контракта она ни пришла. Межформенная `1370(отчётная дата) − 1370(начало
года) = 2400` — **мягкая**, считается ядром по агрегату формы по коду (§9.5 п. 2) и не блокирует подтверждение: пара —
другой документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый
предпочтительнее, далее самый поздний `created_at`); считается, только если найдены пара и обе колонки. Результат в
ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в том числе «в пределах округления»), `cross_form`, `accepted`;
у нарушения — `codes` и `line_ids`.
Показ пятизначных подстрок (AURORA-1527, решение владельца 16.10.2026): ядро отдаёт у каждой строки формы вычисляемое
`parent_code` (правило выше); экран показывает подстроки под родителем, пустые подстроки свёрнуты за переключателем.
**Акцепт** — по жёстким нарушениям, с комментарием (1–2000 символов), `POST /documents/{id}/statement/checks/accept`;
без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы** (с v2.0 — правкой данных:
подтверждение ячейки или кода без изменения его не сбрасывает, §9.5 п. 9). Аудит акцепта —
`RECOGNIZED_DATA_EDIT`. Формулы одни для редакций `66n` и `fsbu4` (`ASSUMPTION(OQ-12, OQ-146)`); редакцию ядро определяет
по дате периода документа и отдаёт в ответе (`edition`: `66n` при дате ≤ 2024-12-31, `fsbu4` — позже, `null` без даты;
OQ-145); тем же правилом сервисы разрешают ничью между редакциями упорядоченного перечня кодов (§11.6, §11.9).
**Полный состав формы (arm-core#80, решение владельца 09.10.2026).** Экран проверки показывает все строки формы
редакции документа (`edition`): справочник `arm-core/api/rules/statement_lines.yaml` (66н и ФСБУ 4/2023, ББ и ОФР, из
шаблонов банка `fixtures/statements-xlsx/templates/`). Строки и ячейки, которых нет в источнике, ядро достраивает в
ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку (признак «не из
источника» — до v5.19 `line_code_original_value = NULL`, с v5.19 — `row_index = NULL`) и ячейку как правку аналитика
(аудит `RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная
правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не
достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом. **С v2.0** (change
`unified-statement-normalization`): справочник ядра — только словарь имён (код → наименование по редакциям; у кода может
быть несколько имён — синонимы и законные повторы кода в бланке, Р-20: 1220 «Налог на добавленную стоимость по
приобретенным ценностям» и «НДС, начисленный по отгрузке», 1520 «Кредиторская задолженность» и «Обязательство по
аренде»; загрузчик повтор кода не запрещает, сверка наименования — с любым именем кода, шаблонная строка берёт первое);
порядок и состав редакций ядро берёт из копии упорядоченного перечня кодов `arm_statements` (данные —
`contracts/statement-code-orders.yaml`; `sync-statements --check` сверяет, что все коды словаря имён есть в перечне); код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не
достраивается; строки `unreadable` / `missing` шаблонной строкой молча не заменяются — показываются отдельно со статусом.
Наименование строки из источника хранится как распознано; экран показывает справочное имя по коду, файловое — рядом
(Р-3, AURORA-1406 / 1522).
Когда: в сервисе при распознавании; в ядре после «Сохранить» правок. Непройдено → `document.status = NEEDS_CHECK`;
пройдено/акцептовано + нажато «Подтвердить» → `CONFIRMED` + `audit_event(DOCUMENT_CONFIRM)`. Отрицательные значения в
формах — в скобках `(2100)` → `-2100`. Подсветка ячеек — только по `needs_review` (§11.9): уверенность, `is_recognized` и
нарушения §7 сведены в него сервисом; «подтверждение проверкой» — сошедшееся посчитанное жёсткое соотношение снимает
подсветку с сомнительной ячейки (Р-7).
---
## §8. Корректировки, гудвилл, КФО, пороги
**Корректировка** (DOC-227278, ТЗ 6.8): запись на пару (правило, компания 1, компания 2) и форму (ББ/ОФР); `debit_line_code
/ debit_adjustment`, `credit_line_code / credit_adjustment`; распределение суммы одной стороны по нескольким строкам —
дочерние записи (`parent_correction_id`, глубина 1). Контроль: `Σ debit_adjustment` ≈ `Σ credit_adjustment` (±5 %),
иначе ВГО распределено не полностью. Комментарий обязателен. История — `audit_event(CORRECTION_*)`, `version_no++`.
**Гудвилл** (DOC-259025, ТЗ 6.7): по каждой паре ГО→ДО периметра:
`FV = Σ closing_debit (58.01, контрагент ДО)`; `UK = 1310 ББ ДО на отчётную дату` (FNR03: Кт 80 + Кт 83, при
отсутствии — 1310); `share` — ручной ввод; `participation = (1310 [+ 1350?]) × share / 100`;
`goodwill = FV − participation` (может быть < 0); `include_in_kfo` + комментарий обязателен. Положительный итог —
строка `GOODWILL` в разделе I КФО.
**КФО** (ТЗ 6.4, Прил. 10): по каждому коду строки ББ/ОФР:
`sum_amount = Σ по компаниям` (значения отчётной колонки ББ / ОФР за период, в тыс. руб.) →
`eliminated_auto` (Σ сумм элиминации троек, §5 п. 1; пересчёт в тыс. руб.) → `eliminated_manual = Σ correction APPLIED` → `goodwill_adjust` →
`consolidated_amount`; `vgo_amount = eliminated_auto + eliminated_manual`; разрез `kfo_result_line_company` (`amount`,
`share_percent` = доля компании в консолидированном показателе). Эталон колонок — `Консолидация_Группа РБ.xlsx`, лист
`01.10.2024`: «значение на дату | в т. ч. взаимные обязательства | очищенные показатели | доля в показателе по группе».
**Пороги** (ТЗ 6.8, FNR03 шаги 4–5), `kfo_result.threshold_*`:
- ББ: если `1300 < 0.1 × 1600` → Σ расхождений ≤ 1 % × 1600 (ВБ), иначе ≤ 10 % × 1300 (ЧА).
- ОФР: если `2200 / 2110 < 10 %` или отрицательна → ≤ 1 % × 2110 (выручка), иначе ≤ 10 % × 2200.
Превышение → `threshold_justification` обязателен (принятие риска), подсветка.
**Сравнение с КФО клиента** (`scenario = CONFIRM`): `client_kfo_line` построчно: `client_amount`, `client_vgo_amount`
(ручной ввод), `bank_amount`, `bank_vgo_amount` (из `kfo_result_line`), `deviation_percent` к наименьшему, причина.
---
## §9. Статусные модели
### 9.1 КФО (`consolidation_status`, DOC-037734 + ТЗ 6.10)
| Из | В | Кто | Когда |
|---|---|---|---|
| — | INITIATED | КА РФ / КА ГО | создана карточка, зафиксирован периметр, сценарий, подход |
| INITIATED | DOCUMENTS_PROCESSING | система/КА | загрузка и распознавание |
| DOCUMENTS_PROCESSING | DATA_CORRECTION | КА | есть документы NEEDS_CHECK |
| DATA_CORRECTION | CONSOLIDATION_RF | КА РФ | все обязательные документы CONFIRMED; инициатор — РФ |
| DATA_CORRECTION | CONSOLIDATION_GO | КА ГО | инициатор — ГО |
| CONSOLIDATION_RF | ADJUSTMENT | система | расхождение ≥ 5 % в парах (подстатус) |
| CONSOLIDATION_RF | VERIFICATION_RF | КА РФ | «отправить на верификацию» |
| VERIFICATION_RF | CONSOLIDATION_GO | РКА РФ | подтвердил |
| VERIFICATION_RF | CONSOLIDATION_RF | РКА РФ | вернул (комментарий обязателен) |
| CONSOLIDATION_GO | CONSOLIDATION_RF | КА ГО | вернул на доработку (комментарий) |
| CONSOLIDATION_GO | VERIFICATION_GO | КА ГО | если инициатор — КА ГО, верифицирует РКА ГО [открыто] |
| CONSOLIDATION_GO / VERIFICATION_GO | VERIFIED | КА ГО / РКА ГО | внесены № и дата решения УОБ; версия read-only; пишется `ex_snapshot` |
| любой нефинальный | INITIATED | КА | нужен другой периметр/сценарий/комплект |
| любой нефинальный | CANCELLED | КА | аннулировано |
| VERIFIED | — | — | только «новая версия» (`previous_version_id`) |
| из макета | REJECTED, ARCHIVED | | ANL-035 — подтвердить с заказчиком |
Каждый переход → `consolidation_status_history` + `audit_event(CONSOLIDATION_STATUS_CHANGE)`.
**Срез 4 реализует только переходы, не требующие маршрута согласования** (`consolidation_status.transition` — единственное
место смены статуса; остальные переходы таблицы — Срез 7, вручную дают 409 `TRANSITION_NOT_ALLOWED`):
| Событие | Переход | Кто (`changed_by`) |
|---|---|---|
| загрузка файла в консолидацию (`POST /consolidations/{id}/legal-entities/{le}/files`), принят ≥ 1 файл | INITIATED → DOCUMENTS_PROCESSING у **каждой** версии консолидации «в работе», где ЮЛ в периметре | загрузивший |
| маппинг создал документ `NEEDS_CHECK`, подходящий версии | DOCUMENTS_PROCESSING → DATA_CORRECTION | `stored_file.uploaded_by` (нет — переход пропускается, лог) |
| подтверждение документа, привязанного к версии | INITIATED / DOCUMENTS_PROCESSING → DATA_CORRECTION | подтвердивший |
| `POST …/status {CANCELLED, comment}` (комментарий обязателен, 422 `COMMENT_REQUIRED`) | любой «в работе» → CANCELLED | пользователь с блокировкой |
| `POST …/status {INITIATED}` | DOCUMENTS_PROCESSING / DATA_CORRECTION → INITIATED (привязки сохраняются) | пользователь с блокировкой |
«В работе» = INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION. `CANCELLED` снимает активную блокировку и `is_in_work`.
Загрузка и подтверждение блокировкой не ограничиваются; запрет загрузки/подтверждения после DATA_CORRECTION (ТЗ 03_9 A1) —
Срез 7 (OQ-118). Условие «все обязательные документы CONFIRMED» для DATA_CORRECTION → CONSOLIDATION_* — Срез 7.
### 9.2 Файл (`file_status`): UPLOADED → PROCESSING → PROCESSED | ERROR; любой → SUPERSEDED (замена файлом целиком,
`superseded_by_file_id`, `version_no + 1`). Статус меняют только по **текущему** заданию файла
(`current_recognition_job_id`): `PROCESSING` — ack сервиса (или результат `done`/`needs_check`, если ack потерян);
`ERROR` — задание `error`/`timeout`/`dlq` либо ошибка маппинга; `PROCESSED` — маппинг завершён; `SUPERSEDED` — автозамена.
`PROCESSED` и `SUPERSEDED` событиями обмена не перезаписываются. Физическое удаление: ANL-020 запрещает его после
INITIATED; в Срезе 2c консолидаций ещё нет, поэтому удаление разрешено, кроме: (а) файла с документом, подтверждённым пользователем; (б) файла с автоподтверждённым документом, привязанным к версии вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION; (в) файла, заменившего другие (§9.5); поведение после появления консолидаций — OQ-110.
### 9.3 Документ (`document_status`): создаётся ядром при маппинге результата, по одному на лист ОСВ:
`RECOGNIZED` — задание `done`, `checks` парсера без нарушений, `counterparty_check ≠ MISMATCH` и нет предупреждений
маппинга **этого листа** (повтор счёта в файле, строки без родителя, нарастающая по данным Q1); иначе `NEEDS_CHECK`
(OQ-05 закрыт: `RECOGNIZED` нужен). **Автоподтверждение (OQ-157, решение владельца 08.10.2026; ТЗ 03_9 п. 4, 10_10):** документ, получивший при маппинге `RECOGNIZED`, в той же транзакции становится `CONFIRMED` от имени системы (`confirmed_by` NULL, `audit_event(DOCUMENT_CONFIRM)` с `user_id` NULL и описанием «автоподтверждение: без замечаний»), привязывается к версиям и двигает их, как при ручном подтверждении (§3 п. 1); переход версии — от `stored_file.uploaded_by` с комментарием «автоподтверждение документа …». Форма ББ/ОФР — при тех же условиях, что ручное подтверждение (п. 4: период определён, нет непройденных жёстких проверок §7; мягкие не учитываются); `RECOGNIZED` формы после правки или акцепта — только вручную. `NEEDS_CHECK` — только вручную. `DISCLOSURE`/`OTHER` (без данных) подтверждаются автоматически тоже (в версии не привязываются; удаление — по §9.5 п. 5). «Подтвердить данные» → `CONFIRMED`; `ERROR` — зарезервирован (в Срезе 2c ядро
документ в `ERROR` не создаёт: файл без разбираемой ОСВ получает `ERROR` на уровне файла). `counterparty_check`: ИНН шапки
равен ИНН владельца файла → `MATCH`; ИНН нет — нормализованное имя шапки совпало с именем или алиасом владельца → `MATCH`;
иначе `MISMATCH`; пустая шапка → `UNKNOWN`. `period_check` — `UNKNOWN`: подходящесть документа версии — функция (документ, версия), а один документ входит в несколько версий (§3 п. 1, Срез 4; OQ-120).
**Документ формы ББ/ОФР (Срез 3b).** `RECOGNIZED` — только если одновременно: задание `done`; нет непройденных
неакцептованных **жёстких** проверок §7; `counterparty_check ≠ MISMATCH`; определены `period_year` и `period_quarter`; у
каждого значения либо `is_modified`, либо `is_recognized` не `false` и `confidence` равна 100 или пуста; нет предупреждений
маппинга по документу (повтор кода, значение вне колонок, пропущенная вторая форма); есть хотя бы одна строка. Иначе
`NEEDS_CHECK`. Статус пересчитывается при маппинге, после каждой правки формы и после акцепта (кроме `CONFIRMED`); при
пересчёте после правки/акцепта предупреждения маппинга не учитываются — на уровне документа они не хранятся
(`metrics.mapping_warnings` — на уровне файла). Расшифровки (`DISCLOSURE`) и прочие (`OTHER`) — один документ на тип на
файл без данных, статус `RECOGNIZED` при маппинге, затем автоподтверждается → `CONFIRMED` (OQ-23, OQ-157). Документ ББ/ОФР, созданный до Среза 3b и не имеющий данных формы
(`financial_statement_meta` нет), подтверждается без условий п. 9.5.4, а `GET`/`PATCH …/statement` по нему — 409
`DOCUMENT_NOT_STATEMENT`.
**Документ формы при приёме контракта v2.0** (change `unified-statement-normalization`, Р-19; правило выше остаётся для
результатов 1.x на окне выкладки). `RECOGNIZED` — только если одновременно: `document.status = done` из результата и он согласован с
содержимым документа (нет строк `unreadable` / `missing` с суммой или текстом в значениях, нет строк, значений и полей
шапки с `needs_review`, нет `checks.relations_failed` и предупреждений документа уровня `error`; иначе ядро считает
документ `needs_check` и пишет `RESULT_STATUS_INCONSISTENT` `info` — страховка от ошибки сервиса; проверка — вызов
общей функции статуса §11.9 по входу, собранному из результата, а не пересчёт §7: значения строк `detail` в неё не
входят); в `metrics.warnings`
файла нет предупреждений уровня `error` (потерянный json, таблица ИС или исключение разбора таблицы — решение № 28 среза
3a; `needs_check` соседнего документа и статус конверта не учитываются); `counterparty_check ≠ MISMATCH`; определены
`period_year` и `period_quarter` (дата — конец квартала); у каждой строки `ok` с неуверенным кодом (`line_code_confidence
< 100`) распознанное наименование совпало со справочным по коду (Р-16, правило совпадения — §11.9); в файле нет второй
формы того же типа и периода (Р-18; «того же периода» — та же пара `period_year`, `period_quarter`, формы без квартала не
считаются). Иначе `NEEDS_CHECK`. Проверки §7, `needs_review` и предупреждения сервиса при загрузке не пересчитываются.
После правки или акцепта статус пересчитывается общим кодом (§11.9) с подтверждениями аналитика и теми же условиями ядра;
`RECOGNIZED` после правки — только вручную, как и прежде. Документ, принятый по 1.x, после правки пересчитывается так же
(общим кодом), его `LEGACY_WARNING` и `MAPPING_WARNING` — `info` и статус не держат.
**Документ ОСВ при приёме контракта v2.0.** `RECOGNIZED` — только если `osvDocument.status = done` (контрольные суммы
§1.4 листа сошлись), `checks` листа без нарушений, в `metrics.warnings` файла нет предупреждений уровня `error`,
`counterparty_check ≠ MISMATCH` и нет предупреждений маппинга ядра по этому листу; статус конверта не учитывается (как
Р-19 у форм: ОСВ в книге с сомнительной ОФР автоподтверждается). Предупреждения листа (`osvDocument.warnings`, `OSV_*`,
`info`) хранятся в `document_issue` документа ОСВ и отдаются в `DocumentOut.issues`.
### 9.4 Задание (`recognition_status`): queued → published → processing → done | needs_check | error | timeout → dlq;
автоповтор публикации без ack (`OUTBOX_MAX_REPUBLISH`) — то же задание, `attempt` не меняется, счётчик —
`metrics.republish_count`, после исчерпания — `dlq` (`NO_ACK`); ручной повтор по кнопке пользователя — **новое** задание
с `attempt + 1`, прежнее остаётся в истории (§9.5 п. 13). Note DBML у `recognition_job` («replay = сброс в queued с
attempt+1») устарела — исправить в следующей версии DBML. `max_attempts`: OCR 1, парсер 3; `reply_deadline` OCR 20 мин (решение владельца 09.10.2026; note DBML «5 мин» — OQ-159), парсер 5 мин.
### 9.5 Первичные документы: загрузка, маппинг, замена, удаление, подтверждение (Срез 2c)
1. **Загрузка** (`POST /legal-entities/{id}/files` — без контекста, `stored_file.consolidation_id = NULL`; в консолидацию —
п. 8; право `DOCUMENT_EDIT`, ЮЛ в видимости): тип файла по расширению и
сигнатуре (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`, `jpg`); лимит `UPLOAD_MAX_BYTES` (50 МиБ); дедуп по
`sha256` в пределах ЮЛ среди файлов не `SUPERSEDED`. Принятый файл: объект S3 `uploads/{file_id}/{job_id}/{имя}`,
`stored_file(UPLOADED, version_no = 1)`, `recognition_job(queued)`, `audit_event(FILE_UPLOAD)`. Тип документа и период
при загрузке не запрашиваются: их определяет парсер.
2. **Маппинг** (фоновый цикл ядра, отдельный от inbox): задание `done`/`needs_check` + файл `PROCESSING` → проверка ключа
результата (бакет и префикс `results/{file_id}/{job_id}/`, OQ-70) → чтение S3 вне транзакции → одна транзакция:
прежние строки файла удаляются, на каждый лист ОСВ создаётся `document` (`osv_account_code` — нормализованный счёт
шапки, `period_year/quarter`, `is_cumulative` по §1.5, `locator`), вставляются `osv_entry` (§1.3), резолвится
`counterparty_id` (§3 п. 4), пишутся `recognition_job.group_schema` и `metrics.mapping_warnings`, файл → `PROCESSED`,
выполняется автозамена. Второй лист того же файла с тем же счётом документа не создаёт (предупреждение, первый
документ — `NEEDS_CHECK`). Повторяемые сбои (БД, S3) — до `MAPPING_MAX_ATTEMPTS` (3) попыток, затем файл `ERROR`
(`metrics.mapping_error`); неповторяемые (ключ вне папки, нет объекта, результат другого задания, пустой список
`documents`) — сразу `ERROR`. **Формы (Срез 3b):** после листов ОСВ, в той же транзакции, каждый `statementDocument`
`BALANCE`/`INCOME` (Excel и OCR) становится `document` (`osv_account_code = NULL`, `is_cumulative = NULL` — отчёт
нарастающий, `locator` из результата) + `financial_statement_meta` (шапка: ИНН, дата отчёта, единицы, ОКУД/ОКПО/ОКОПФ/ОКФС,
адрес; поля `*_original_value` заполняются как распознано и больше не меняются) + `financial_statement_column` +
`financial_statement_line` (строки с кодом, `(meta_id, line_code)` уникален: повтор кода — остаётся первая строка,
предупреждение) + `financial_statement_value` — **на каждую пару «строка × колонка» одна запись** (ячейки, которых нет
в результате, — с пустыми суммой и уверенностью; значение с `column_index` вне колонок формы пропускается с
предупреждением; повтор `column_index` в колонках результата или повтор пары «строка × колонка» — остаётся первое, пишется
предупреждение, документ `NEEDS_CHECK`). `checks` результата ядро игнорирует: §7 считает само (§7). **Период формы:** дата = `header.report_date`,
а если даты нет — **самая поздняя `period_date` колонок** (дата отчёта в OCR часто мусор; колонки надёжнее); `period_year` —
год даты, `period_quarter` — только если дата — последний день марта, июня, сентября или декабря, иначе квартал пуст и
предупреждение «дата … не конец квартала»; без даты год и квартал пусты. У колонки с самой поздней датой `is_current = true`.
`counterparty_check` — как у ОСВ (§9.3). **Повтор формы:** второй `BALANCE`/`INCOME` того же типа в файле не создаётся
(предупреждение, первый документ — `NEEDS_CHECK`, OQ-77); `DISCLOSURE`/`OTHER` — один документ на тип на файл без данных
(OQ-23). Форма без строк (нет колонки кодов, OQ-139) создаётся с шапкой и колонками, если они есть в результате, статус
`NEEDS_CHECK`. Файл только с формами — не ошибка; `ERROR` по результату — только при пустом `documents`. Страницы одной
формы из разных файлов не склеиваются (OQ-140), строки без кода не хранятся (OQ-36, OQ-141, OQ-149). Статус документа формы —
§9.3.
**Формы по контракту v2.0** (change `unified-statement-normalization`; правила выше — для результатов 1.x на окне
выкладки, у них поля v2.0 заполняются по умолчанию: `line_status = ok`, `row_index` по порядку строк результата с 0,
`line_code_original_value = line_code`, `source_ref = null`, `text = string_value`, `needs_review` значений = **НЕ**
«правлено или уверенность 100 и `is_recognized`» (чистое значение — `false`), `form_variant` — по структуре кодов,
`file_ordinal` — индекс в `documents[]`, `header_source` — из полей шапки 1.x; нарушения §7 ядро считает один раз
точкой входа `arm_statements` и пишет в
`document_issue` (`source = core`, §7 «Ядро»); предупреждения маппинга 1.x по документу (повтор кода, значение вне
колонок) — `document_issue` `MAPPING_WARNING` `info` (`source = core`; статус при приёме — по D-6, после правки они
статус не держат); строки `metrics.warnings` 1.x — в `recognition_job.metrics.warnings` структурами `{code:
"LEGACY_WARNING", severity: "info", message}` уровня файла; `statements_version` задания — `null`, и
`STATEMENTS_VERSION_MISMATCH` у такого документа не пишется).
Результат пишется как пришёл, без повторной дедупликации: каждая строка — `financial_statement_line` с `row_index`
(у строки из результата всегда не `null`: `row_index IS NULL` — признак строки, созданной правкой),
`source_ref` (`ref`, уникален в форме), `line_status`, `line_code` (`null` у `unreadable` / `missing` / `detail`),
`line_code_original_value` (= `line_code` результата; правками не меняется), `line_code_text`, `parent_row_index`
(родитель `detail`), `needs_review`; повтор кода — законный случай, хранятся все
строки (Р-4; строки без кода хранятся — Р-8, отменяет OQ-36 / OQ-141). Значения — с `source_text` (`values[].text`) и
`needs_review`; колонки — с `period_kind` и `label_confidence`; шапка — с `form_variant`, `needs_review_fields` и
`header_source` (`<поле>_text` и `<поле>_confidence` всех полей; `*_original_value` — как прежде: `inn`,
`report_date_text`, `units_text`; `inn_confidence` результата, `organization_name_original_value` =
`organization_name`); `checks`
и предупреждения документа — в `document_issue` (источник `service`; нарушение — `kind = relation`, `code =
RELATION_FAILED`, `error` или `kind = soft`, `code = RELATION_SOFT`, `info`, с `codes`, `relation`, `expected`,
`actual`, `column_id`; предупреждение о столбце вне колонок формы — с `column_index`) вместе с проверками ядра уровня
`info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`); версия общего кода —
`recognition_job.statements_version`. **Каждый** документ результата (ОСВ, формы, `DISCLOSURE`, `OTHER`) при любом
MAJOR получает `file_ordinal` — индекс в `documents[]` (несозданный документ номер не занимает и не сдвигает
остальные). Предупреждения файла хранятся в `recognition_job.metrics.warnings` структурами (у 1.x — `LEGACY_WARNING`)
и отдаются в `JobBrief.warnings` объектами (строки старых заданий конвертируются при чтении). Каждый `BALANCE` /
`INCOME` становится отдельным документом:
вторая форма того же типа в файле **хранится** (Р-10, Р-15; ни одна из двух форм одного типа и периода не
автоподтверждается — Р-18); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл.
Все чтения формы «по коду» (межформенная проверка, суммы формы для консолидации и выгрузок, `parent_code`, раскладка
экрана, правка по `line_code`, метрики `fields_*`) идут через одну чистую функцию **«агрегат формы по коду»**: сумма
`amount` строк `ok` с кодом по колонке, список `line_id`, справочное имя (DECISION-5, не хранится). Строки `detail`,
`unreadable`, `missing` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8. Записи `document_issue` удаляются вместе
с документом при перезагрузке файла.
**Перед выкладкой ядра v5.19** база очищается от форм ББ/ОФР (Р-24; скрипт ядра `scripts/purge_statements.py`,
`--dry-run` с отчётом, затем `--apply`; владелец или платформенная команда по его заявке, после резервной копии):
документы `BALANCE` / `INCOME` с `financial_statement_*`, комментариями и привязками к версиям «в работе»; файлы без
ОСВ — целиком с заданиями (объектам S3 — `expires_at`); смешанные «ОСВ + формы» — только формы, или целиком по
решению владельца; формы в верифицированных версиях — отказ без изменений; ОСВ, консолидации и справочники не
трогаются; системный аудит `FILE_DELETE`. Миграция v5.19 только добавляет колонки и значения по умолчанию (спека
`arm-core`, «Миграция DBML v5.19», «Очистка форм перед выкладкой»).
3. **Автозамена.** После успешного маппинга другие файлы того же ЮЛ (не `SUPERSEDED`), содержащие документ с теми же
`document_type`, `osv_account_code`, `period_year`, `period_quarter`, переводятся в `SUPERSEDED`
(`superseded_by_file_id` = новый файл); `version_no` нового = максимум заменённых + 1. Новый файл без контекста (`consolidation_id IS NULL`) наследует `consolidation_id` первого (по `id`) заменённого файла, у которого он задан; файл, загруженный в консолидацию, сохраняет свою (v5.18). Заменяется и файл с `CONFIRMED`
документом (решение владельца 05.10.2026); документ без года или квартала в замене не участвует. Документы и строки
заменённых файлов сохраняются, но по умолчанию не показываются. Если новый файл содержит лишь часть документов
заменяемого, заменяемый уходит в `SUPERSEDED` целиком (OQ-06: замена файлом целиком; частный случай — OQ-115). Для форм
поведение то же (решение владельца 07.10.2026, OQ-154): исправленный отдельный ОФР заменяет всю книгу «ББ + ОФР» того же
периода. Чтобы это не происходило молча, документы заменённого файла, ключа которых нет в новом, перечисляются в
предупреждении `metrics.mapping_warnings` нового файла: тип, счёт, период, был ли `CONFIRMED`, из каких версий сняты
привязки (реализация — RSHB/arm-core#58).
**Чужая организация в шапке (AURORA-1644, решение владельца 21.10.2026, уточнено 27.10.2026).** Пара «новый документ —
прежний» с `counterparty_check = MISMATCH` (§9.3) у любого из двух в замене не участвует: скан другой организации,
загруженный в карточку ЮЛ, не вытесняет ни документ организации карточки, ни другой чужой скан, и сам ими не
вытесняется. `UNKNOWN` (пустая шапка) несовпадением не считается — замена как прежде. Файл с документом того же ключа,
оставленный только из-за `MISMATCH`, — предупреждение «автозамена не выполнена» в `metrics.mapping_warnings` нового
файла (имя и версия оставленного файла, его документы этого ключа). Цена: исправленный скан с искажённым OCR ИНН шапки
ляжет рядом с прежним, прежний удаляется вручную; файл, заменённый по другому документу, уходит в `SUPERSEDED` целиком
(OQ-115), даже если в нём есть документ `MISMATCH`. Организация документа по ИНН шапки вместо карточки загрузки и единая
область видимости панели, чек-листа и автозамены — AURORA-1752.
Аудит: системное `FILE_REPLACE` (`user_id = NULL`, `object_id` — заменённый файл, `after_value` — новый, в `description`
перечень документов и `confirmed=true`). **Срез 4:** автозамена снимает привязки (`consolidation_document`) **всех**
документов заменённого файла из всех версий «в работе» (`CANCELLED` не трогаются); версии, где привязка снята, —
`FILE_REPLACE.after_value.unbound_version_ids`; новый документ привязывается только после подтверждения (§3 п. 1).
4. **Подтверждение** (`POST /documents/{id}/confirm`, `DOCUMENT_EDIT`): `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`
(`confirmed_by/at`, `audit_event(DOCUMENT_CONFIRM)`); повтор — 409 `ALREADY_CONFIRMED`; документ заменённого файла —
409 `FILE_SUPERSEDED`; прочие статусы — 409 `DOCUMENT_NOT_CONFIRMABLE`. Автоподтверждённый документ (`confirmed_by` NULL) повторно подтвердить нельзя — 409 `ALREADY_CONFIRMED`. Ручной правки строк ОСВ нет (решение владельца
07.10.2026, OQ-114; возможен пересмотр по качеству парсинга и документов): ошибка в ОСВ исправляется перезагрузкой файла
(п. 3), поля `is_modified`/`original_values` у `osv_entry` — резерв; корректировки — §8.
**Форма ББ/ОФР (Срез 3b)** дополнительно: период не определён (`period_year` или `period_quarter` пуст) — 409
`PERIOD_REQUIRED` (поправьте дату отчёта, п. 9); непройденные неакцептованные жёсткие проверки §7 — 409
`CHECKS_NOT_PASSED` (список нарушений — в `detail.failures`: `column_index`, `relation`, `expected`, `actual`); порядок
409: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`.
**С v2.0** (Р-13, Р-18) порядок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` →
`PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя бы в одной ячейке
непустая сумма или нераспознанный текст — `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index,
line_code_text}`; строки без значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же
типа и периода — та же пара `period_year`, `period_quarter`; `detail.document_id`; между файлами — автозамена п. 3) →
`CHECKS_NOT_PASSED` (нарушения — записи `document_issue` вида `relation` без акцепта, у любой формы).
Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не допускает).
При подтверждении пишется `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента (`MISMATCH`) и низкая
уверенность значений подтверждение **не блокируют** (как у ОСВ): защита — статус `NEEDS_CHECK` и подсветка. Мягкие проверки
(в том числе межформенная) не блокируют. `DISCLOSURE`/`OTHER` — без условий. Привязка к версиям и переходы статуса версии —
как у ОСВ (§3 п. 1, §9.1): ББ/ОФР за квартал версии (`Q1` — только версии за `Q1`, R-14a Среза 4).
5. **Удаление** (`DELETE /files/{id}`, `DOCUMENT_EDIT`): запрещено, если у файла есть документ, подтверждённый пользователем (`confirmed_by` не NULL), или автоподтверждённый документ привязан к версии вне `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`FILE_HAS_CONFIRMED_DOCUMENTS`; OQ-157, уточняет OQ-110); привязки автоподтверждённых документов снимаются, статус версий не меняется, `FILE_DELETE.after_value.unbound_version_ids`; запрещено также, если файл заменил другие (`FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить
можно. Удаляются строки, документы (вместе с данными форм `financial_statement_*` и комментариями документов), задания и `stored_file`; объектам S3 ставится `expires_at = now()` (физически не
стираются — OQ-111); аудит `FILE_DELETE` с карточкой файла в `before_value`. **OQ-110 закрыт для Среза 4:** к версии
привязываются только `CONFIRMED` документы, а файл с таким документом удалить нельзя — удалить файл с документом в
версии невозможно; расширять удаление не требуется (уточнено OQ-157: для автоподтверждённых документов — см. выше).
6. **Аудит.** `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `FILE_REPROCESS` (п. 13, v5.21), `DOCUMENT_CONFIRM` (раздел
`PRIMARY_DOCUMENTS`); правка формы и
акцепт проверок — `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type = document`, `before/after` изменённых полей и
ячеек); смены статусов задания/файла в обмене и в цикле маппинга `audit_event` не пишут (OQ-55). Не аудируются (Срез 3b):
скачивание исходного файла, CSV чек-листа, комментарии к документу (`comment` сам хранит автора и время); экспорт сводной
ОСВ в xlsx, напротив, пишет `audit_event(EXPORT)` (Срез 4). Скачивание первички (это же и просмотр PDF) и CSV чек-листа
не журналируются — решение владельца 07.10.2026 (OQ-153).
7. **Порядок блокировок** (против взаимоблокировок): задание → файл → документы (`FOR NO KEY UPDATE`) → advisory-lock ГК →
версии консолидации (`FOR UPDATE`, по возрастанию `id`); автозамена дополнительно берёт advisory-lock по ЮЛ. Подробности —
`03-architecture.md`.
8. **Загрузка в консолидацию** (`POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files`,
`DOCUMENT_EDIT`; v5.18, решения владельца 09.10.2026, RSHB/arm-ka-docs#90): до приёма — консолидация видима (404
`NOT_FOUND`), ЮЛ видимо (404), есть версия консолидации «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`), ЮЛ — член
периметра хотя бы одной такой версии (422 `ENTITY_NOT_IN_PERIMETER`). Принятый файл получает
`stored_file.consolidation_id`; `FILE_UPLOAD.after_value.consolidation_id`. После приёма хотя бы одного файла, отдельной
транзакцией, каждая версия консолидации «в работе» с этим ЮЛ в периметре и статусом INITIATED переходит в
DOCUMENTS_PROCESSING (§9.1; версии блокируются `FOR UPDATE` по возрастанию `id`, статус перечитывается); сбой перехода не
отменяет принятые файлы. Блокировка версии для загрузки не нужна. Query `consolidation_version_id` у
`POST /legal-entities/{id}/files` (Срез 4) удалён.
9. **Правка формы ББ/ОФР** (`PATCH /documents/{id}/statement`, `DOCUMENT_EDIT`, Срез 3b; решения владельца 07.10.2026).
Правятся **суммы** ячеек (`values[] {line_id, column_id, amount}`) и три поля шапки: `inn` (10 или 12 цифр, иначе 422
`INVALID_INN`, в том числе `null` — очистить ИНН нельзя; пересчитывается `counterparty_check`), `report_date` (пересчитываются `period_year`, `period_quarter`,
`reporting_date` по правилу периода п. 2) и `units` (непустая строка до 50 символов). Код и название строки, добавление и
удаление строк и колонок не правятся (до v2.0; с v2.0 код и статус строки, дата колонки и наименование организации
правятся — см. ниже, название строки и удаление строк и колонок — по-прежнему нет). **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY`
(исправление подтверждённой формы — новым файлом через автозамену, п. 3); файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не
форма — 409 `DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Исходное сохраняется: у значения при
первой правке `original_amount` = прежняя сумма, `is_modified = (amount ≠ original_amount)` (возврат к исходному снимает
признак), `modified_by/at`; у поля шапки `*_modified = true`, `*_original_value` не меняется. После правки: акцепт проверок
сбрасывается (§7; прежний акцепт, включая комментарий, остаётся в `before_value.checks_accepted` записи аудита), проверки и статус пересчитываются (§9.3), пишется одно `audit_event(RECOGNIZED_DATA_EDIT)`; запрос
без изменений — 200 без аудита. Автозамена при правке не запускается (только при маппинге): правка даты, давшая период
другого файла ЮЛ, привязывает документ к версии лишь при подтверждении. Порядок блокировок — как у подтверждения (файл →
документ).
**С v2.0** (Р-6, Р-13; change `unified-statement-normalization`) PATCH дополнительно принимает:
`header.organization_name` (непустая строка до 1000 символов; `organization_name_modified = true`,
`organization_name_original_value` не меняется; пересчитывается `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN`;
аудит — как у ИНН); `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты без
изменения: поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит (у бланка 66н только с
относительными подписями неуверенная дата иначе не подтверждается ничем, §11.9);
`values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения (`is_confirmed`,
`confirmed_by/at`, `needs_review = false`, `is_modified` не ставится) или снять подтверждение (`is_confirmed = false`,
`needs_review` пересчитывается); `columns[] {column_id, period_date}` — дата колонке без подписи или с неуверенной
подписью (обязательная дата, `null` — 422 ошибка валидации; пересчитывает `is_current` и период документа; ставит
`period_kind = absolute`; та же дата у колонки с `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` — подтверждение,
предупреждение снимается);
`lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить
расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить неуверенный код или снять подтверждение.
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `ok` → `ok` сменой кода. Обратного перехода `detail` → `unreadable` / `missing` нет: ошибочная отметка
исправляется назначением кода, исходное состояние — перезагрузкой файла. `line_status` в PATCH принимает только
`"detail"`: `"ok"` без кода — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только `line_code`). Назначенный
код — `line_code_confidence = 100`, `line_code_modified`, `needs_review` строки = `false`; подтверждённый код —
флаг `is_confirmed`, `needs_review` строки = `false`, `line_code_confidence` и `line_code_modified` не меняются,
снятие подтверждения возвращает `needs_review` по прежнему правилу (Р-34); `line_code_original_value` не меняется (код из источника; у строк без кода — `null`); после правки родитель **всех**
строк `detail` пересчитывается как ближайшая строка `ok` выше по `row_index` (нет такой — `null`). Код — 4–5 цифр (422 `INVALID_LINE_CODE`), может
совпадать с существующим (Р-4); незнакомый словарю — `info`. **Строка, созданная правкой** — `row_index IS NULL`
(шаблонная): если строка с назначаемым кодом — такая и у неё есть суммы, — 409 `LINE_CODE_TAKEN`; без сумм — она
удаляется (её `id` убирается из `document_issue.line_ids`), назначенная строка источника занимает её место. Строки
источника (`row_index` не `null`) с тем же кодом не удаляются и не мешают — повтор кода законен. Элемент без действия
или с несколькими — 422 `INVALID_LINE_PATCH`; чужая строка — 422 `UNKNOWN_LINE`, чужая колонка — 422 `UNKNOWN_COLUMN`;
правка суммы по `line_code` при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления
строк источника нет (первичка не мутируется, `detail` — способ вывести строку из сумм). Строки, созданные правкой:
`row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Правка
`header.report_date` пересчитывает `period_date` колонок с `period_kind = relative:*` общим кодом (`null` — `null`).
После правки ядро пересчитывает `checks`, `needs_review` и статус копией `arm_statements` (§11.9): правленое или
подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения; записи `document_issue`
видов `relation` / `soft` заменяются целиком, предупреждения сервиса сохраняются, кроме снимаемых правкой (таблица
§11.9; значения строк `detail` — без `needs_review`, Р-23). **Акцепт** сбрасывается правкой данных — суммы, кода или
статуса строки, даты колонки, поля шапки; подтверждение ячейки или кода (`confirmed`) и подтверждение даты отчёта или
колонки тем же значением суммы не меняют и акцепт не сбрасывают (решение владельца 18.10.2026, Р-25). Расхождение `statements_version`
задания и копии ядра — `info` `STATEMENTS_VERSION_MISMATCH` (у заданий 1.x, где версии нет, — не пишется). Один PATCH —
один пересчёт и одна запись `RECOGNIZED_DATA_EDIT` (`before/after` по кодам, статусам, родителям, подтверждениям).
10. **Исходный файл и комментарии (Срез 3b).** `GET /files/{id}/content` (`DOCUMENT_VIEW`, policy по ЮЛ) отдаёт исходный
объект из S3 потоком через ядро (в контуре MinIO для пользователя может быть недоступен, а через ядро действует
видимость); работает и для `SUPERSEDED`; объекта нет в хранилище — 404 `FILE_CONTENT_NOT_FOUND`. Комментарии к документу —
`POST/GET /documents/{id}/comments` (таблица `comment`, `object_type = 'document'`); добавлять можно к документу в любом
статусе; правки и удаления нет; при удалении файла (`DELETE /files/{id}`, п. 5) комментарии его документов удаляются вместе с ними
(связь полиморфная, FK нет — удаляет сервис).
11. **Рабочий стол консоли** (`GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files`,
`DOCUMENT_VIEW`; 404 на невидимые консолидацию или ЮЛ; `limit`, `offset`, `include_superseded`): файлы ЮЛ с
`consolidation_id` этой консолидации **плюс** файлы ЮЛ, у которых есть документ, привязанный (`consolidation_document`)
к любой версии этой консолидации (подхваченные ОСВ за `Q1` и файлы, загруженные без контекста); новые первыми
(`uploaded_at` desc, `id`); `SUPERSEDED` — только при `include_superseded=true` (скрыт по умолчанию). Карточка файла та же, что в
`GET /legal-entities/{id}/files` (`FileOut` + `consolidation_id`). Рабочие версии одного периода видят один стол;
верифицированная версия заморожена привязками и `ex_snapshot` (Срез 7), отдельного «замороженного списка» в панели
нет. Панель [39] из карточки версии показывает стол консоли; переключатель «Все документы организации» —
`GET /legal-entities/{id}/files` с подписью консолидации у файла.
**Чек-лист версии — по столу консоли (AURORA-1646, решение владельца 21.10.2026).** Статусы ячеек «Требуется
проверка» / «Распознан» (неподтверждённые документы) и «Обработка» / «Ошибка загрузки» (состояние файлов ЮЛ)
чек-лист берёт только из файлов стола этой консолидации; «Загружен» — по привязанным документам, как прежде.
Документы ЮЛ из других консолидаций и из карточки ЮЛ, которых нет на панели, ячейку не меняют: без документов —
«Отсутствует». Цена: неподтверждённый документ, загруженный из карточки ЮЛ, в чек-листе не виден, пока его не
подтвердят (тогда он привязывается и попадает на стол). Единая область видимости панели, чек-листа и привязки
целиком — AURORA-1752 (после аналитики AURORA-1378).
12. **Уточнение периода и счёта ОСВ** (`PATCH /documents/{id}/osv-header`, `DOCUMENT_EDIT`, policy по ЮЛ; v5.20, решения
владельца 21.10.2026, OQ-161 вариант 2, AURORA-1442). Аналитик исправляет период или счёт документа ОСВ, распознанные
неверно. Тело — любые из полей `period_year`, `period_quarter`, `is_cumulative`, `osv_account_code`; отсутствующее поле
не меняется. Проверки, по порядку: документ не ОСВ — 409 `DOCUMENT_NOT_OSV`; файл `SUPERSEDED` — 409
`FILE_SUPERSEDED`; `period_year` — четыре цифры 19xx/20xx, `period_quarter` — `Q1`…`Q4`, `is_cumulative` — `true` /
`false`; если в теле есть хоть одно поле периода, после правки все три не пусты и у `Q1` `is_cumulative = true`
(§1.5), иначе 422 `INVALID_PERIOD` (правка одного счёта период без квартала не трогает);
`osv_account_code` — непустой, нормализуется как при маппинге (§1, `60.1 → 60.01`), пустой — 422
`INVALID_ACCOUNT_CODE`; счёт уже есть у другой ОСВ того же файла — 409 `DUPLICATE_ACCOUNT` (`detail.document_id`;
одна ОСВ по счёту на файл, §9.5 п. 2); документ привязан к версии не «в работе» и не `CANCELLED` — 409
`VERSION_NOT_EDITABLE` (`detail.version_ids`; версия после `VERIFIED` — read-only). Блокировка версии
(`consolidation_lock`) не нужна — как для подтверждения.
**Правка на месте, исходное рядом** (первичка не мутируется): новые значения пишутся в те же поля `document`; при
первой правке прежние значения всех четырёх полей копируются в `document.original_values` и дальше не меняются;
`is_modified` = текущие четыре поля отличаются от `original_values` (возврат к исходному снимает признак);
`modified_by/at`. Копия `document` не создаётся (`file_ordinal` — номер документа в результате, не номер редакции).
Строки `osv_entry` не меняются: их `account_code`, `account_id`, `account_root` — свои коды субсчетов, счёт документа —
только слот чек-листа и привязки.
**Подтверждённый документ** (`CONFIRMED`, в том числе автоподтверждённый): правка сбрасывает подтверждение —
`status = NEEDS_CHECK`, `confirmed_by/at = NULL`; привязки документа (`consolidation_document`) снимаются из всех
версий «в работе» (`CANCELLED` не трогаются), статус версий не меняется (как при удалении, п. 5). Документ
подтверждается заново вручную (п. 4) и тогда привязывается по новому периоду и счёту (§3 п. 1); автоподтверждения
после правки нет (только при маппинге). Неподтверждённый документ статус сохраняет.
Пересчитывается то, что выводится из периода и счёта: подходящесть версиям, чек-лист, колонка «ОСВ» (чистые функции,
отдельно не хранятся); `period_check` и `counterparty_check` не меняются (§9.3). Автозамена при правке не запускается
(только при маппинге, п. 3): документ с ключом, как у другого файла ЮЛ, тот файл не заменяет; при привязке документ с
тем же ключом в версии заменяется (§3 п. 1). Ключ документа после правки — исправленные период и счёт: при маппинге
следующего файла ЮЛ автозамена (п. 3) сравнивает уже их, а не распознанные значения. Перезагрузка файла создаёт
документы заново из результата — уточнения прежних документов не переносятся.
Аудит — одно `audit_event(RECOGNIZED_DATA_EDIT)` (`object_type = document`): в `before/after` изменённые поля, при
сброшенном подтверждении — `status`, `confirmed_by`, `confirmed_at`, в `after_value.unbound_version_ids` — версии, из
которых снята привязка. Запрос без изменений — 200 без аудита. Порядок блокировок — п. 7 (файл → документ → версии по
возрастанию `id`). Ответ — карточка документа с `is_modified` и `original_values`.
Временный флаг `CONSOLIDATION_PERIOD_FROM_VERSION` (OQ-161) остаётся, пока правка периода не появится на экране, и
снимается отдельной задачей.
13. **Повторная обработка файла** (решения владельца 19.10.2026 и 22.10.2026, AURORA-1442; аудит — v5.21). Разрешена, только
если текущее задание файла (`current_recognition_job_id`) — табличного парсера (`pipeline = tabular`) и завершилось
сбоем обмена: `timeout` (`error_code = TIMEOUT`) или `dlq` (`NO_ACK`). OCR (ИС Сканирование) не входит. От такого
задания нет ни документов, ни подтверждений, ни правок — переносить и сбрасывать нечего. Ядро создаёт новое
`recognition_job` с `attempt + 1` (уникальность `(file_id, attempt)`), делает его текущим и публикует через outbox;
файл снова уходит в обработку (§9.2). Прежнее задание остаётся в истории. Предел — `max_attempts` задания (у парсера
3): на нём — 409 `MAX_ATTEMPTS_REACHED`; не подходит под условие — 409 `FILE_NOT_RETRYABLE`. Права — `DOCUMENT_EDIT`
и policy по ЮЛ. Ответ — 202 и карточка файла с новым заданием. Файл, загруженный в консолидацию
(`stored_file.consolidation_id`), повторяется, только если у этой консолидации есть версия «в работе» с ЮЛ файла в
периметре, иначе 409 `VERSION_NOT_EDITABLE`; файл без контекста — без этого условия.
**Пакет версии** (`POST /consolidations/versions/{id}/files/retry`) — то же одним запросом. Версия должна быть «в работе»
(после `VERIFIED` и `CANCELLED` — 409 `VERSION_NOT_EDITABLE`); блокировка версии (`consolidation_lock`) не нужна.
Рассматриваются файлы в `ERROR`, загруженные в консолидацию этой версии (`stored_file.consolidation_id`), ЮЛ которых
в периметре версии; файлы без контекста и файлы других статусов в пакет не входят и не блокируются. Ответ — 202
`{accepted[] {file_id, job_id, attempt}, skipped[] {file_id, code}}`: в `skipped` — рассмотренные файлы, которые
повторить нельзя, с кодом `FILE_NOT_RETRYABLE` или `MAX_ATTEMPTS_REACHED`; пустой пакет — 202 с пустыми списками.
Статус версии и периметр перепроверяются под блокировкой.
Аудит — `audit_event(FILE_REPROCESS)` на каждый файл, отправленный на повтор (раздел `PRIMARY_DOCUMENTS`,
`object_type = stored_file`, `object_id` — файл, `user_id` — инициатор): `before_value` — прежнее задание (`job_id`,
`attempt`, `status`, `error_code`), `after_value` — новое (`job_id`, `attempt`); при обработке пакета — ещё
`consolidation_id` в `after_value`. Файлы пакета, не подошедшие под условие, события не пишут. `FILE_UPLOAD` не
пишется: файл не загружается заново, событие загрузки означало бы вторую загрузку, которой не было. Смены статусов
нового задания и файла в обмене аудит не пишут, как и при загрузке (п. 6, OQ-55).
---
## §10. Роли и матрица прав (ТЗ 8.1, DOC-036960) — seed для `role_permission`
| Роль (`role.code`) | DIRECTORY | DOCUMENT | CONSOLIDATION | KFO | EX | EXPORT | USERS | AUDIT |
|---|---|---|---|---|---|---|---|---|
| ADMIN | VIEW, EDIT, DELETE, ADMIN (импорт) | — | — | — | VIEW | — | ADMIN | VIEW |
| CURATOR_GO (Куратор КП ГО) | VIEW, EDIT (ГК: создание, перезакрепление) | — | VIEW (по закреплённым ГК) | VIEW | VIEW | — | — | — |
| KA_RF | VIEW (свой РФ), EDIT (состав ГСК своей ГК) | VIEW, EDIT (загрузка, правка, подтверждение) | VIEW, EDIT (INITIATED…CONSOLIDATION_RF) | VIEW, EXPORT | VIEW | EXPORT | — | — |
| RKA (РКА РФ) | VIEW, EDIT (состав ГСК в рамках корректировки) | VIEW | VIEW, EDIT (VERIFICATION_RF) | VIEW, APPROVE | VIEW | EXPORT | — | — |
| KA_GO | VIEW, EDIT (состав ГСК, без ограничения по филиалу) | VIEW, EDIT | VIEW, EDIT (всё, что KA_RF + CONSOLIDATION_GO) | VIEW, VERIFY (№/дата УОБ), EXPORT | VIEW | EXPORT | — | — |
| EXPERT | VIEW | VIEW | VIEW | VIEW | VIEW | — | — | — |
| CLIENT_MANAGER | VIEW (свои клиенты) | VIEW, EDIT (только загрузка по своей ГК) | VIEW (статус) | VIEW | — | — | — | — |
Policy-слой (не права): KA_RF/RKA/CLIENT_MANAGER видят только ГК своего `regional_office`; редактирование версии — только в
допустимом статусе и при отсутствии чужой активной `consolidation_lock`; ЗО (`business_segment`) — фильтр видимости.
**Срез 4 (`domain/policy.py:ensure_version_editable`, решения владельца 06.10.2026, OQ-19):** правка версии (замена
периметра, ручной статус, ручное сопоставление контрагента) разрешена, когда версия «в работе» (INITIATED,
DOCUMENTS_PROCESSING, DATA_CORRECTION; иначе 409 `VERSION_NOT_EDITABLE`) **и** у пользователя своя активная
`consolidation_lock` (нет — 409 `LOCK_REQUIRED`, чужая — 409 `LOCKED_BY_OTHER`). Блокировка закрывает все правки версии, но не
загрузку и подтверждение документов. Перехват чужой блокировки — любому с `CONSOLIDATION_EDIT`, с обязательной причиной;
блокировка не истекает по времени (OQ-119). Чтение (свод, чек-лист, экспорт, кандидаты) требует `CONSOLIDATION_VIEW`,
блокировки не нужно, версия любого статуса читается. Видимость версии — видимость её ГК; чужая версия — 404.
Порядок проверок: право → видимость → статус/блокировка → операция.
Матрица в ТЗ помечена «будет разработана» (сноска 29) — seed считать черновиком, редактируется администратором в UI.
### 10.1 Видимость справочников (Срез 1, без проверки кода роли)
Правило формулируется через данные, а не через роль (инвариант «права — данные»):
- Пользователь **головного офиса** (`app_user.regional_office → regional_office.is_head_office = true`) видит все ГК и все ЮЛ.
- Пользователь **филиала** видит ГК с `company_group.base_regional_office_id = app_user.regional_office_id`, их ЮЛ, ГСК/ГСЗ,
а также монозаёмщиков своего филиала (`legal_entity.company_group_id IS NULL AND legal_entity.regional_office_id = …`).
- Это совпадает с §10: KA_RF / RKA / CLIENT_MANAGER — пользователи филиалов, KA_GO / CURATOR_GO / EXPERT / ADMIN — ГО.
- ЗО (`business_segment`) в Срезе 1 **не фильтрует**: у ГК и ЮЛ нет поля сегмента (DBML v5.14), сегмент есть только у пользователя
**[открыто, OQ-86]**.
- Право (`DIRECTORY_VIEW`, `DIRECTORY_EDIT`, …) отвечает «может ли вообще», policy — «какие именно записи»; обе проверки
обязательны на каждом маршруте справочников. Запись вне видимости → 404 (не 403), чтобы не раскрывать существование.
---
## §13. Импорт справочников из выгрузки ЕЦП CRM (Срез 1; формат — `docs/source/biq/ecp-export-lagal-entity.xlsx`)
Источник: реальная выгрузка ЕЦП CRM (лист «Выписки_01.04.26», 8400 строк, 345 ГК, 53 значения «Базовый РФ»), ТЗ 6.1 (ГК из
ЕЦП CRM, создание вручную Куратором), макет [38]. Импорт запускает пользователь с правом `DIRECTORY_ADMIN`
(seed: `ADMIN`); результат — отчёт + `audit_event(DIRECTORY_IMPORT)` + файл в S3.
### 13.1 Формат файла
- xlsx (первый лист, первая непустая строка — заголовки) или csv (UTF-8, разделитель `;` или `,` — определяется по строке
заголовков). Заголовки сопоставляются по нормализованному тексту (нижний регистр, без пробелов, `ё` = `е`):
`ИНН`, `КПП`, `iD ГК`, `Базовый РФ ГК в CRM`, `Группа компаний`, `Организация`, `ГСК`, `ГСЗ`, `Тип`. Порядок колонок не важен,
лишние колонки игнорируются. Нет любой из `ИНН`, `iD ГК`, `Группа компаний`, `Организация`, `Тип` → файл отвергается целиком
(`MISSING_COLUMNS`). `Базовый РФ ГК в CRM`, `ГСК`, `ГСЗ`, `КПП` — необязательные (v5.17: отсутствие колонки `КПП` не ошибка; значение — trim, все пробельные символы убираются, регистр букв приводится к верхнему; число в xlsx читается как целое без дробной части, ведущие нули не восстанавливаются — как у ИНН).
- Значения: trim; `ИНН` — убрать все пробельные символы; `Тип` — без регистра. Пустые строки пропускаются. Число в ячейке
xlsx читается как целое без дробной части (`3528052718.0` → `3528052718`); **ведущий ноль, потерянный ещё в Excel
(`0123456789` → `123456789`), не восстанавливается** — строка получит `INVALID_INN` (ИНН нужно задавать текстом или
использовать csv) **[открыто, OQ-93]**.
- Технические пределы файла (не правила предметной области; нарушение — ошибка файла целиком, §13.3): размер ≤ 20 МиБ
(`IMPORT_MAX_BYTES`, ответ 413), не более 100 000 строк листа/записей csv **включая пустые** (`MAX_IMPORT_ROWS`), суммарный
распакованный размер частей xlsx ≤ 200 МиБ (`MAX_UNCOMPRESSED_BYTES`); допустимы только расширения `xlsx` и `csv`.
### 13.2 Строка → решение
Строки обрабатываются в порядке файла; **порядок проверок внутри строки**: тип → ИНН → КПП → дубль ИНН в файле → ГК (пустой
`iD ГК`, конфликт имени) → РФ → ГК (создание/обновление) → ЮЛ → флаги ГСК/ГСЗ. У каждой строки ровно один **итоговый** код
(шесть кодов пропуска — `UNSUPPORTED_TYPE`, `INVALID_INN`, `INVALID_KPP`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT`, —
и `LE_CREATED` / `LE_UPDATED` / `LE_EXISTS`) и ноль или более **сопутствующих** событий той же строки (`RF_*`, `GK_*`,
`GSK_*`/`GSZ_*`, `MEMBER_ADDED`, `UNKNOWN_FLAG`). Счётчик `errors` = число строк с кодом пропуска.
| Шаг | Правило | Код в отчёте |
|---|---|---|
| Тип | `ЮЛ` / `ИП` → `entity_type`; иное (`нерез.`, `ФЛ`, `нет инн`, пусто) → строка пропускается **[открыто, OQ-87]** | `UNSUPPORTED_TYPE` |
| ИНН | ЮЛ — ровно 10 цифр, ИП — 12; иначе пропуск (контрольные разряды в Срезе 1 не проверяются **[открыто, OQ-88]**) | `INVALID_INN` |
| КПП | v5.17, OQ-31: пустой КПП — допустим (у ЮЛ необязателен). Непустой: у ИП — запрещён, у ЮЛ — по формату §13.5 (9 символов: 4 цифры, 2 цифры/латинские A–Z, 3 цифры); нарушение — строка пропускается с сообщением | `INVALID_KPP` |
| Дубль в файле | второй и далее ИНН в файле пропускается. ИНН считается «встречавшимся» только после успешных проверок типа и формата, поэтому «ФЛ», идущая после ИП с тем же ИНН, получает `UNSUPPORTED_TYPE`, а не `DUPLICATE_IN_FILE` | `DUPLICATE_IN_FILE` |
| РФ | по `regional_office.name` (точное после trim); нет → **создаётся** `regional_office(name, is_head_office=false)` с предупреждением (один раз на имя в файле); пусто → `base_regional_office_id = NULL`. РФ разрешается один раз на ГК — по первой строке ГК и только после проверок пустого `iD ГК` и конфликта имени (отвергнутая строка филиал не создаёт); у **существующей** ГК при `skip` филиал из файла всё равно создаётся, но ГК не меняется. Значения «ЦКБ» и «Головная организация» в выгрузке — **[открыто, OQ-89]**: до решения создаются как обычные филиалы | `RF_CREATED` |
| ГК | **пустой `iD ГК`** → строка пропускается (`GK_NAME_CONFLICT`, сообщение «пустой iD ГК»). По `company_group.crm_id = iD ГК`: нет → создать (`code` генерируется `GK-NNNNNN`, `name`, `base_regional_office_id`, `valid_from = now`); есть → при стратегии `update` обновить `name`/`base_regional_office_id` c снимком в `company_group_history` и `audit_event(GROUP_CHANGE / ASSIGNMENT_CHANGE)`, при `skip` — оставить; **`GK_EXISTS` ставится и для неизменной ГК при `update`** (нет различий в имени и РФ — нечего обновлять; это обеспечивает идемпотентность). Разные имена одной ГК в файле (имя первой строки против последующих) → ошибка ГК, строка пропускается. Архивные ГК (`is_archived`) входят в сопоставление и при `update` обновляются **без разархивации** **[открыто, OQ-94]**. `update` пишет снимок `company_group_history` и событие `audit_event` теми же функциями, что ручная правка (PATCH); смена только имени — `GROUP_CHANGE`, смена РФ (в т.ч. вместе с именем) — одно `ASSIGNMENT_CHANGE` | `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, `GK_NAME_CONFLICT` |
| ЮЛ | по `legal_entity.inn`: нет → создать (`entity_type`, `name`, `company_group_id`, `regional_office_id = base RF ГК`, `valid_from = now`); есть в этой же ГК → `update`: обновить `name`/`entity_type` со снимком `legal_entity_history(UPDATE)`, `skip`: оставить; есть **в другой ГК или вне ГК (монозаёмщик)** → строка пропускается, перевод между ГК только явной операцией (§13.4) **[открыто, OQ-90]**; сообщение называет имя ЮЛ, независимо от филиала инициатора — импорт доступен только `DIRECTORY_ADMIN` **[открыто, OQ-95]**. `regional_office_id` нового ЮЛ = действующий РФ ГК. `LE_UPDATED` пишет снимок `legal_entity_history(UPDATE)` и `audit_event(LEGAL_ENTITY_CHANGE)` как ручная правка; `LE_EXISTS` — и при `skip`, и при `update` без различий | `LE_CREATED`, `LE_UPDATED`, `LE_EXISTS`, `LE_IN_OTHER_GROUP` |
| ГСК / ГСЗ | флаг = нормализованное `входит в гск` / `входит в гсз`; любое другое непустое значение → предупреждение и «не входит». Если у ГК есть хотя бы один член с флагом — создаётся (если нет) **одна** группа вида `gsk`/`gsz` с `name` = имя ГК и `code` `GSK-NNNNNN`/`GSZ-NNNNNN` **[открыто, OQ-91]**; член добавляется записью `related_group_member(kind, valid_from = now, is_current)`, если текущего членства **этого вида** (в этой или другой группе) нет — существующее членство импорт не меняет и не закрывает. Флаги обрабатываются **и при `skip`** (недостающее членство добавляется у существующих ЮЛ). Группа создаётся по самому факту флага, даже если у ЮЛ уже есть членство того же вида в другой группе (возможна пустая группа). Значение, не равное ожидаемому для колонки (в т.ч. «входит в ГСЗ» в колонке `ГСК`), — `UNKNOWN_FLAG` (предупреждение, не ошибка: «не входит»). Отсутствие флага членство **не закрывает** (импорт не удаляет) | `GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG` |
**Перечень кодов строки (17; совпадает с `ImportCode` в `arm-core/api/src/domain/directory_import.py`):**
| Код | Роль в строке | Счётчик отчёта |
|---|---|---|
| `UNSUPPORTED_TYPE`, `INVALID_INN`, `INVALID_KPP`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` | итоговый, строка пропущена | `errors` |
| `LE_CREATED`, `LE_UPDATED`, `LE_EXISTS` | итоговый | `entities_created`, `entities_updated`, `entities_skipped` |
| `RF_CREATED` | сопутствующий | `offices_created` |
| `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS` | сопутствующий | `groups_created`, `groups_updated`, `groups_skipped` |
| `GSK_CREATED`, `GSZ_CREATED` | сопутствующий (создана группа) | — |
| `MEMBER_ADDED` | сопутствующий | `memberships_created` |
| `UNKNOWN_FLAG` | сопутствующий (предупреждение) | — |
Допущения по неоднозначностям выгрузки — OQ-87…OQ-91 (в коде — `ASSUMPTION(OQ-nn)`); вопросы, появившиеся при реализации, —
OQ-92 (пустые «Организация»/«Группа компаний» при непустом `iD ГК` импортируются как есть, с пустым именем), OQ-93, OQ-94, OQ-95.
### 13.3 Транзакция, отчёт, след
- Один импорт — одна транзакция; ошибки строк не прерывают импорт (строка пропускается), прерывают только ошибки файла.
`dry_run = true` — тот же прогон с откатом, отчёт идентичен; **в API `dry_run` по умолчанию `true`** (запись — явным
`dry_run=false`). Номера `GK-`/`GSK-`/`GSZ-` из последовательностей PostgreSQL при откате расходуются (дыры в нумерации
допустимы). Ошибки файла (прерывают импорт до любых записей, ответ 422): `MISSING_COLUMNS` (с перечнем недостающих
колонок) и `UNSUPPORTED_FILE` (расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8, превышены лимиты строк и
распакованного размера §13.1); размер файла больше 20 МиБ — 413.
- Стратегия дублей — параметр `on_existing ∈ {skip, update}` (макет: «Пропустить существующие» — `skip` по умолчанию); `update` обновляет у ГК имя и РФ (РФ — только непустым значением из файла), у ЮЛ — имя и тип.
- Отчёт: счётчики (`rows`, `groups_created/updated/skipped`, `entities_created/updated/skipped`, `memberships_created`,
`offices_created`, `errors`) и построчный список `{row, inn, code, message}`. Отчёт возвращается в ответе и пишется в
`audit_event(action = DIRECTORY_IMPORT, section = DIRECTORIES, object_type = 'directory_import', after_value = отчёт,
description = имя файла, user_id = инициатор)`. Файл сохраняется в S3 как `uploads/imports/{audit_event_id}/{original_name}`
и регистрируется в `s3_object(bucket_role = uploads, created_by = user:{id})` (`size_bytes`, `sha256`, `content_type` по расширению); имя файла приводится к basename (путь отбрасывается, управляющие и форматирующие символы убираются, длина ≤ 200 символов с сохранением расширения; пустое → `import`). Запись `s3_object` создаётся **до** `put_object` (сбой S3 откатывает импорт); сам объект кладётся до commit транзакции, поэтому при сбое commit в бакете может остаться объект без записи (зачистка — follow-up arm-core). При `dry_run` файл не сохраняется и `DIRECTORY_IMPORT` не пишется. Без подключённого S3 (dev) файл не сохраняется, в лог пишется предупреждение.
- Помимо сводного `DIRECTORY_IMPORT` импорт при `update` пишет **собственные** события изменения объектов — `GROUP_CHANGE`/`ASSIGNMENT_CHANGE` (ГК), `LEGAL_ENTITY_CHANGE` (ЮЛ) с `description = «импорт ЕЦП»`; создания объектов отдельных событий не порождают (только сводное, снимок в `*_history` при создании не пишется — он появляется при первом изменении).
- Повторный импорт того же файла идемпотентен при `skip`: ранее принятые строки получают `GK_EXISTS`/`LE_EXISTS`, новых `MEMBER_ADDED` нет; ранее пропущенные (ошибочные) строки остаются ошибками.
- Лимит тела запроса (20 МиБ на пути импорта) обязан ставить ingress: multipart разбирается до аутентификации (требование к платформе, `arm-core/README.md`, M-1).
### 13.4 Ручные операции справочника (вне импорта)
- **Перевод ЮЛ в другую ГК** (макет [79-01]): `legal_entity.company_group_id := новая`, снимок в `legal_entity_history(event_type =
MOVE_TO_GROUP, effective_date = дата перевода ≥ сегодня, OQ-21)`, все текущие членства ЮЛ в ГСК/ГСЗ старой ГК закрываются
(`valid_to`, `is_current = false`), `audit_event(MEMBERSHIP_CHANGE)`.
- **Вывод из состава ГК** (макет [80]): событие `CLOSE` (`close_date`, ЮЛ остаётся с `company_group_id` для истории? — нет:
`company_group_id := NULL`, `close_date := дата`) или `REMOVE_FROM_GROUP` (`company_group_id := NULL`, без `close_date`);
членства закрываются; `legal_entity_history(event_type, effective_date)`.
- **ГСК/ГСЗ:** ЮЛ может входить одновременно в одну ГСК и одну ГСЗ (v5.14), но только в группы **своей** ГК; добавление в
группу другой ГК → ошибка. Удаление группы — только пустой (без текущих членов) **[OQ-34]**.
- **Алиасы** (§3): `legal_entity_alias(alias, normalized_alias)`; `normalized_alias` уникален в пределах ЮЛ; конфликт
с алиасом другого ЮЛ (OQ-28/ANL-028) — ошибка с указанием ЮЛ-владельца.
- **ГК:** перезакрепление за другим филиалом — `audit_event(ASSIGNMENT_CHANGE)` + снимок `company_group_history`; архив —
`is_archived` (ГК с активными консолидациями архивировать нельзя — проверка появится в Срезе 4).
### 13.5 КПП (v5.17, OQ-31, решение владельца 07.10.2026)
- Поле `legal_entity.kpp` (и снимок `legal_entity_history.kpp`), nullable. Формат — ровно 9 символов: `^[0-9]{4}[0-9A-Z]{2}[0-9]{3}$` (4 цифры, 2 символа — цифра или латинская A–Z, 3 цифры); иначе `INVALID_KPP` (422 в API). Контрольных разрядов у КПП нет.
- **ИП:** КПП запрещён — непустой `kpp` при `entity_type = ИП` → `INVALID_KPP` (создание и PATCH; при PATCH проверяется итоговая пара «тип, КПП»: смена типа на `ИП` у ЮЛ с КПП без очистки КПП — `INVALID_KPP`, а смена типа без смены ИНН невозможна и по длине ИНН — `INVALID_INN`). Для ЮЛ КПП необязателен; очистить его (`null`) можно.
- **Уникальность:** только `inn` (КПП — атрибут). Сопоставление контрагентов ОСВ по ИНН (§3) КПП не использует.
- **Изменение КПП** — обычный PATCH ЮЛ: снимок `legal_entity_history(event_type = UPDATE)` с прежними значениями и `audit_event(LEGAL_ENTITY_CHANGE)` с `before/after`, содержащими `kpp`.
- **Импорт:** колонка `КПП` необязательна; для существующего ЮЛ при стратегии `update` непустой КПП из файла обновляет `kpp` (со снимком, как `name`/`entity_type`), пустая ячейка существующий КПП не стирает.
- **Поиск ЮЛ:** подстрочный поиск `q` по справочнику ЮЛ (имя, ИНН) ищет и по КПП; точный фильтр `inn` не меняется.
Все мутации пишут `audit_event` (`GROUP_CHANGE`, `ASSIGNMENT_CHANGE`, `MEMBERSHIP_CHANGE`, `LEGAL_ENTITY_CHANGE` — v5.14, `DIRECTORY_IMPORT`) с `before/after`.
---
---
## §11. Нормализация форм ББ/ОФР — общая цепочка (scan-gateway и tabular-parser)
**Статус: целевое правило** change `unified-statement-normalization` (RSHB/arm-ka-docs#116; дизайн —
`openspec/changes/archive/2026-10-19-unified-statement-normalization/design.md`, решения владельца Р-1…Р-19 от 17.10.2026), контракт
v2.0. Порядок выкладки — «сначала ядро» (ядро принимает 1.x и 2.0); пока сервис пишет 1.x, он работает по прежним
правилам — прежний текст §11, §11.1 и §12 (Срезы 2b, 3a, 3b) — в истории git репозитория docs, коммит `43b4b80`.
§11 объединяет прежние §11 (маппинг json ИС), §11.1 (очистка OCR-текста) и §12 (ББ и ОФР из Excel); §12 оставлен
заголовком-ссылкой.
Источник требований: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР»), DOC-037734, DOC-221281, FNR01/FNR02; формат —
Приказ 66н / ФСБУ 4/2023, на практике — скан бланка (ИС Сканирование), выгрузка 1С («Регламентированный отчёт»), шаблон
банка и вручную набитая таблица.
**Принципы.**
1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист
Excel → кандидат»). Шаги 2–8 — общий код с общими словарями. Единственное место, где общий код смотрит на источник, —
разбор запятой и точки в сумме (шаг 4).
2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со
статусом, а не превращается в счётчик; положение в источнике (`ref`), порядок строк (`row_index`) и сырой текст
ячейки (`text`) доезжают до базы и экрана. Подтвердить документ, не разобрав строки с суммами без кода, нельзя (Р-13).
3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа),
статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, `is_recognized` и §7
сервис сводит в один признак `needs_review` у значения, строки и поля шапки и из них выводит статус документа. Ядро
при загрузке ничего из этого не пересчитывает.
4. **Один словарь — одно место:** проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, подписи колонок,
упорядоченные перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде
сервисов; соответствие «код → наименование» — только в ядре (Р-3).
5. **Очистка OCR — не отдельный шаг**, а первая стадия типовых разборов текста там, где уже известен смысл ячейки (дата,
подпись колонки, код, разлепление, сумма).
**Где код.** Шаги 2–8, общие разборы и словари — пакет `arm_statements/` репозитория `arm-statements` (Р-1); в
tabular-parser, scan-gateway и arm-core он лежит копией (раскладывает и проверяет `sync-statements` в docs, прямые правки
копии запрещены, маркер версии — `service.statements_version`). Golden-тесты пакета — на синтетических и обезличенных
данных внутри пакета; тесты на `fixtures/` остаются в сервисах. Изменения правил ниже — только через этот параграф и
словари пакета.
### §11.1 Источник OCR: ИС Сканирование (адаптер шага 1 в scan-gateway)
**Формат ответа** — по реальным файлам `fixtures/ocr/*.json` (проверено 04.10.2026; OQ-13, OQ-14, OQ-77):
- Каталог ответа `<job_id>/` содержит **несколько json** — по одному на распознанный комплект отчётности. Шлюз читает
все json каталога и склеивает их в `raw/{job_id}.json` (`{job_id, files[{name, response, parse_error}]}`) **до**
нормализации; ошибка одного json не отменяет остальные. Допущение владельца (17.10.2026): несколько периодов в одном
файле ИС отдаёт разными json; образца нет — проверить на первом таком файле (OQ-167).
- Корень: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`**; ошибка — непустой `code`
(`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`).
- `data_ocr` — объект: поля шапки `label`, `organization`, `inn`, `units`, `date` — каждое `{form_name, confidence}` (сырой
OCR-текст; `date.form_name` ненадёжна — у aep_6 и «БО 1 кв 2025» там название организации), **одна шапка на json**;
`table_structure[]` — таблицы `{label {form_name, confidence}, rows[{row_index, cells[{column_index, value,
confidence}]}]}`; `column_index` не обязательно подряд; `confidence` — проценты 0–100 (NaN → 0). В одном json
встречаются ББ и ОФР одного периода под общей шапкой. ИС отдаёт ячейку на каждую позицию строки: пустая — пустой
текст с уверенностью 100, непрочитанная — пустой текст с уверенностью 0.
**Адаптер.** Каждая таблица успешного json — кандидат (`FormCandidate`): `source = ocr`; `locator = file:<имя
json>;table:<i>` (как в golden-эталонах); шапка json копируется в каждую свою таблицу (`Cell.text = form_name`,
`confidence` ИС); `label.form_name` таблицы — заголовок в `evidence` (тип — правилом §11.2 «тип-подсказка» по словарю
заголовков; прежний `statement_labels.yaml` уходит в словарь пакета); сетка строится по `row_index` и `column_index`
плотно, пустая позиция — `null`; `Cell.ref = row:<row_index>`, `value = null`.
**Ошибки набора.** Ошибочный json (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном
соседе — предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в тексте код и сообщение ИС; решение
№ 28 среза 3a: потерянный документ, например ОФР, не должен остаться незамеченным); все json ошибочны — конверт `error`
с кодом первого. Исключение при разборе таблицы — предупреждение **файла** `TABLE_PARSE_ERROR` (`error`, `locator =
file:<имя json>;table:<i>`; документа у такой таблицы нет), остальные таблицы разбираются; как и `JSON_FAILED`, оно
блокирует `RECOGNIZED` всех документов файла (§9.3); ни одной таблицы не разобрано — `error RECOGNITION_FAILED`.
**Перенос из старого ядра.** Источник очистки и разборов — модуль `DKB_ARMKA` `api/src/services/ofr_result_conversion/`
(`utils.py`, `date_utils.py`, `consts.py`; локально — `reference-old-arm/api/src/core/PDFWorker/`); решение владельца
06.10.2026: проверен на стенде ИС Сканирование, перенос разрешён — **единственное исключение** из правила «доменный код из
`reference-old-arm` не переносить». Перенесённые функции теперь — общие разборы пакета (§11.3–§11.6), отличия от
источника перечислены там.
### §11.2 Источник Excel: tabular-parser (адаптер шага 1)
**Остаётся в сервисе:** лимиты и чтение книги (`PARSE_MAX_BYTES` 50 МБ, `PARSE_TIMEOUT_SECONDS` 120 с, формат по
сигнатуре байтов — OLE2 → XLS, zip с `xl/workbook.bin` → XLSB, иной zip → XLSX, иначе `UNSUPPORTED_FORMAT`; несовпадение
с `file_type` — `FILE_TYPE_MISMATCH` `info`; лимиты распакованного zip и площади объединённых ячеек; библиотека упала —
`UNREADABLE`), классификация листов и путь ОСВ (§1).
1. **Классификация листов**: лист ОСВ (признак ведомости в первых 30 строках, §1) → форма → продолжение → прочее. Форма —
заголовок («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД в любой строке до
таблицы; титул «Бухгалтерская отчетность» — не тип. Продолжение — лист без заголовка, но с маркером «Форма … с.2», или
с подписью «Код» и периодами правее, или с колонкой кодов, или с одним-двумя кодами сразу после листа формы. Прочее —
`SHEET_OTHER` (`info`), если у листа нет ни типа, ни кодов.
2. **Блоки форм на листе**: строки с заголовком или ОКУД — кандидаты; без кодов между ними блоки сливаются, заголовок
после кодов — новая форма. Шапка **своя у каждого блока** в его границах: организация (после метки «Организация»,
иначе текст с признаком ОПФ строкой выше метки), ИНН 10/12 цифр у метки «ИНН» (ИНН аудитора не берётся: строки после
метки «аудитор» исключены), ОКПО, ОКОПФ, ОКФС, ОКЕИ, адрес, единицы, дата (подзаголовок формы или тройка «число | месяц
| год» у метки «Дата»; для ОФР — конец периода). Шапка другого листа не используется. Найденное поле — `Cell` с
`confidence = 100`, не найденное — `null`. ОКЕИ — поле `okei` шапки контракта.
3. **Ячейки**: `text` — как показывает числовой формат ячейки, `value` — число по формату (tp#42, OQ-138 пересмотрен
15.10.2026): масштабирующие запятые (`#,##0,` → тысячи), число знаков после точки, секции и условия (`[=0]"-"`,
`[=-646532]"(647)"`; литерал условной секции разбирается как текст), округление half-up, знак — от сырого значения,
условия сравниваются с точностью 15 значащих цифр; `General` / пустой формат — значение как в ячейке; формат, который не
удалось разобрать (`%`, экспонента, дробь, даты, `@`, неизвестные знаки вне кавычек, больше четырёх секций, условие не
по шаблону, результат вне `numeric(18,2)`), — значение как в ячейке и одно `NUMBER_FORMAT_UNPARSED` (`info`) на документ
с перечнем форматов; xlsb — стили не читаются, `XLSB_NO_FORMATS` (`info`). `ref = <лист>!<A1>` по правилу Excel: имя
листа берётся в одинарные кавычки, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф
внутри имени удваивается (`Баланс!C27`, `'Бухгалтерский баланс. Лист 1'!C14`; решение владельца 18.10.2026, Р-22 /
S-15); `confidence = 100`.
4. **Склейка листов-продолжений — до раскладки.** Строки продолжения дописываются в конец сетки открытой формы по
маркеру «Форма … с.2» или по позиции листа; граница листа — в `segment_breaks` (всегда граница сегмента раскладки).
Тип маркера сверяется с типом формы: расхождение — отдельный кандидат и `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без
маркера и шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок
сумм совпадает с формой (проверки прежнего `join_blocker`); иначе — отдельный кандидат и `SHEET_NOT_JOINED` (`error`).
`locator = sheet:<A>[,<B>…]`. **Страницы в разных файлах не склеиваются** (1С печатает форму постранично): страница 2
без первой — документ без шапки, `needs_check` (OQ-140). Две половины формы одного типа и периода в одном файле
склеиваются только листом-продолжением; иначе это две формы (Р-15, вопрос — OQ-165).
5. **Пятизначные подстроки** (11101, 12301…) — обычные строки. **Шаблоны банка** (коды есть, сумм и шапки нет) —
`needs_check` (OQ-142).
**Тип-подсказка (`type_hint`) — одно правило у обоих источников:** ОКУД по словарю (0710001 → `BALANCE`, 0710002 →
`INCOME`, прочие 0710xxx — 0710003 капитал, 0710004 ДДС и др. → `DISCLOSURE`) → иначе заголовок по словарю заголовков
(у OCR — `label` таблицы ИС; «Пояснения», «Расшифровка» → `DISCLOSURE`) → иначе `null`. Окончательный тип — по кодам строк
(§11.7).
**Шапка — одно правило разбора** по `Cell.text`: ИНН — только цифры, **без замены двойников** (иначе «3|1 21 2|» из aep_3
даёт лишние единицы и `MISMATCH` с ЮЛ), длина 10 или 12, иначе `null`; дата отчёта — словарь периодов §11.3 п. 3;
единицы — словарь единиц пакета `statement_units.yaml` (написания «тыс. руб.», «в тыс. рублей», «руб.», «млн руб.» → `тыс.
руб.` / `руб.` / `млн руб.`); единицы не найдены по тексту, а ОКЕИ есть — по коду ОКЕИ тем же словарём (384 → `тыс. руб.`,
383 → `руб.`, 385 → `млн руб.`); ОКЕИ — только цифры. Сырой текст и уверенность каждого поля — в контракт
(`<поле>_text`, `<поле>_confidence`); ядро хранит их в `financial_statement_meta.header_source`.
### §11.3 Шаг 2 — раскладка таблицы
**Очистка OCR-текста** (общий разбор, применяется к любой ячейке без `value`; у Excel — к текстовым ячейкам, почти
всегда без изменений). Буквы-двойники рядом с цифрами `О/о/O/o → 0`, `З/з → 3`, `б → 6`, `S/s → 5`, `I/l/|/і → 1`,
`Ч → 4` заменяются **только вплотную к цифре**, в несколько проходов («2ОО5» → «2005»), слова не портятся; если первая
строка значения состоит только из цифр, пробелов, `|`, точек и двоеточий — `|` считается разделителем клеток («2|0|2|4|» →
«2 0 2 4»). **Применяется к датам, подписям колонок, кодам и суммам** (Р-11; отменяет прежнее «только к датам» и решение
№ 22 среза 3a): сумма с заменой — `is_recognized = false`, код с заменой — неуверенный (уверенность не выше 99); в суммах
крайние `|` (след границы таблицы: «| : 122477») удаляются, а не заменяются. ИНН двойники не трогают. Текст длиннее 200
символов датой не разбирается (решение № 31 среза 3a: backtracking регулярок).
**Диапазоны кодов** (словарь пакета): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала 3100–3699, отчёт о
движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании средств 6100–6499. «Кодовые
диапазоны» для поиска столбца — объединение отрезков (годы 2000–2099 и четырёхзначные суммы вне отрезков кодами не
считаются). «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца; им проверяются
разлепление строк, слабые продолжения и наследование столбца в сегментах.
**Разбор кода:** убрать пробелы и переносы → двойники (`|` → 1, только если результат в кодовом диапазоне: «| 140» →
1140) → краевая пунктуация («1220 ,» → 1220) → 4–5 цифр (допускается `.0`: `1110.0` из Excel), первые четыре — в кодовом
диапазоне. Многострочная ячейка — код, только если каждая её часть код.
1. **Столбец кодов.** (а) Столбец с подписью из словаря («код», «коды», «код строки», «код показателя» — после
нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью доля кодов среди непустых ≥ 0,6 или
кодов ≥ 3; несколько кандидатов — с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом.
Защита обязательна: в выгрузках 1С под «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267, 12300 — «коды
баланса» по первым цифрам), ОКЕИ — один код из девяти, доля 0,11. Документ из двух строк остаётся документом (два из
двух, доля 1,0). (б) Подписи нет — столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6. Нет —
`CODE_COLUMN_NOT_FOUND`; сопоставление по наименованиям строк не делается (OQ-139).
2. **Сегменты.** Новый сегмент открывает строка-шапка (в ней есть подпись кода из словаря, или не менее двух её непустых
ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница листа из `segment_breaks`. У
сегмента своя раскладка: по его шапке; без шапки — столбец кодов по п. 1, а если сегмент не первый и кодов меньше трёх —
ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (лист «_2» НПЗ
с единственной строкой 2400, у которого код в другой колонке). Колонки документа задаёт первый сегмент; колонки
следующих сегментов новых колонок не создают, а сопоставляются с колонками первого: у сегмента **со своей шапкой**
(повторная шапка OCR, лист-продолжение Excel со строкой заголовка — «Бухгалтерский баланс. Лист 4» НПЗ, колонки 8–10
при 16 / 21 / 24 у первого листа) — по `period_date`, если у каждой колонки сегмента дата есть и совпадает ровно с
одной колонкой первого, иначе по порядку; у сегмента без шапки колонки значений — столбцы правее кода, где в строках с
кодами числа, по порядку. Значения несут `column_index` колонки первого сегмента. При сопоставлении по порядку другое
число колонок — `SEGMENT_COLUMNS_MISMATCH` (`error`): значения по позиции, лишние столбцы сегмента в колонки не
попадают, их номера — в тексте предупреждения (S-56). Столбец кодов не найден ни так, ни так —
`SEGMENT_WITHOUT_CODES` (`error`); строки сегмента не теряются (роль `data`, статусы по §11.4).
3. **Дата колонки — один словарь периодов** `statement_periods.yaml`, объединяющий прежний словарь парсера и шаблоны
`dates.py` шлюза (порядок приоритета текстовых форм: диапазон «с 01.01.2025 по 30.09.2025» — конец; «30 сентября 2025
г./r./года»; «I квартал 2026» / «1-й кв.» / «первый квартал»; «1 полугодие» / «1 п/г»; «9 месяцев 2025»; «январь –
сентябрь 2025» — последний месяц; «за 2025 г.» / «2025 год» — 31.12; затем числовые: три группы цифр «30.09.2025»,
«2025 03 31», «30/09/25»; иначе склеенные цифры — 8 → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный), 4 → год (31.12,
1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается; двузначный год → 20YY; квартал / полугодие / N месяцев → последний
день последнего месяца; не дата → `null`). Строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты
словаря. Результат — `period_date` (конец периода колонки; во внутренних типах пакета может называться иначе, в
контракте и БД — `period_date`) и `period_kind`:
- абсолютные подписи → `absolute`;
- относительные подписи бланка 66н без впечатанных дат (Р-6): «На отчётную дату», «За отчётный период» →
`relative:current` (= эффективная дата); «На 31 декабря предыдущего года» → `relative:prev_year_end` (31.12 года
эффективной даты − 1); «На 31 декабря года, предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года
− 2); «За аналогичный период предыдущего года» → `relative:prev_period` (эффективная дата − 1 год; 29.02 → 28.02);
- подписи нет → `none`.
**Эффективная дата документа** — дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок (та же формула,
что период формы в ядре, §9.5 п. 2); нет ни той, ни другой → у относительных колонок `period_date = null`, вид
остаётся относительным, ядро досчитает после правки даты отчёта. Подпись OCR с уверенностью ниже 100, разобранная в
дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два года, 31 декабря
предыдущего и предшествующего ему года; иначе `COLUMN_DATE_UNCERTAIN` (`error`, колонка). Для этого подтверждения
эффективная дата строится **только из уверенных источников** — дата отчёта с уверенностью 100, иначе самая поздняя
абсолютная дата колонок с подписью уверенности 100; нет таких — подпись не подтверждена (неуверенное не подтверждает
само себя, S-64).
4. **Строка заголовка** — последняя над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе последняя
над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); двухстрочный
заголовок («На» + «20__ г.», перенос внутри ячейки) склеивается. Роль `title` — только строки выше найденной строки
заголовка (титульный блок «Дата 30 | 9 | 2025»); в сегменте без заголовка роли `title` нет, строки выше первого кода с
содержимым идут в данные. Заголовка нет ни у одного сегмента → колонки значений первого сегмента — столбцы правее кода,
где в строках с кодами числа, `label` пустая, `period_date = null`, `period_kind = none`, одно `HEADER_ROW_NOT_FOUND`
(`error`; прежний синтез шапки шлюза и «рулинг 06.10.2026» заменены этим правилом). Заголовок есть хотя бы у одного
сегмента — остальные берут его колонки без предупреждения (листы 2–5 ББ НПЗ без шапки остаются `done`).
5. **Строка нумерации** «1 2 3 4 5» — роль `numbering`, в данные не попадает (AURORA-1517). Строка — нумерация, если в
ней не меньше трёх непустых ячеек, каждая после замены букв-двойников (§11.3, «1 2 З 4» → 1 2 3 4) — целое число,
числа слева направо возрастают на 1, начинаются с 1 или 2 и допускают **один пропуск** («1 2 4 5», «2 3 4 5» без
первой ячейки; в Excel Межениновской в столбце кодов строк нумерации стоит «2»). Решение владельца 18.10.2026 (Р-22,
S-13): иначе сбой OCR в нумерации превращает её в строку `unreadable` с «суммами» и блокирует подтверждение.
Запись нумерации в столбце кодов («2», «3») кодом не считается — вне кодовых диапазонов.
**Строка сразу под строкой заголовка** сегмента (первой или повторной) проверяется мягче: ячейка со сбоем OCR даёт
своё единственное целое («• ■ 1» → 1, «3гола» → 3, «гола\n4» → 4), ячейка без цифр («/» вместо 1, «V» вместо 4) —
пропуск, ячейка с двумя целыми — не нумерация; дальше те же условия (три числа, шаг +1, начало 1 или 2, один
пропуск), так что строка с кодом строки формы нумерацией не станет. В остальных строках — только строгая проверка.
Повторная шапка, у которой под каждой подписью склеен номер столбца («Коды\n2», «На 30 июня 2026 года\n3»),
распознаётся по подписям без этого хвоста. Решение владельца 22.10.2026 (ретест AURORA-1517: «Светлый путь»,
«Будамшинское»; arm-statements#8).
6. **Подпись без периода** (Р-6): период по словарю (абсолютный или относительный) → колонка с датой; подпись из
словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск, `COLUMN_SKIPPED` (`info`);
иначе, если в столбце есть числа в строках с кодами (OCR-мусор «| | г» с уверенностью 17, обрезанная или самодельная
подпись), — колонка остаётся с подписью как есть, `period_date = null`, `period_kind = none`, `COLUMN_WITHOUT_DATE`
(`error`, колонка); аналитик ставит дату через PATCH (§9.5 п. 9); иначе пропуск. Колонка без даты не мешает периоду
формы (эффективная дата), но межформенная проверка и гудвилл «на отчётную дату» по ней не считаются, пока дата не
проставлена. Уверенность подписи — `label_confidence` (Excel — 100, подписи нет — `null`).
**Числа правее таблицы формы** (решение владельца 18.10.2026, Р-44; ответ на вопрос Р-31 плана 4; пример — Межениновская 2025-Q3, ББ, формулы
аналитика `=M45*5/100` в столбцах U–W над пустой шапкой): если среди колонок значений сегмента есть хотя бы одна с
подписью периода (дата или относительная подпись бланка), то столбец **без текста подписи**, лежащий правее последней
такой колонки, колонкой сумм не считается: его числа в значения строк не попадают, `COLUMN_WITHOUT_DATE` нет,
выдаётся одно предупреждение `NUMBERS_OUTSIDE_TABLE` (`info`, документ) с адресами ячеек («числа правее таблицы формы
не учитываются: V45, W57, …»: в тексте первые 10 адресов, дальше «и ещё N»; Excel — `<лист>!<A1>`, OCR — `row:<n>`).
Адреса собираются по всем сегментам формы в одно предупреждение; у сегмента без подписанных колонок (лист-продолжение
без шапки) столбцы без текста подписи сверх числа колонок документа тоже считаются числами правее таблицы (иначе
`SEGMENT_COLUMNS_MISMATCH`). Подписанный столбец без чисел в строках с кодами (потерянная подпись объединённой ячейки)
границей таблицы не служит. На статус документа предупреждение не влияет. Не
меняется: подписанных колонок нет вовсе; столбец без подписи **между** подписанными — `COLUMN_WITHOUT_DATE`; столбец
правее с **любым текстом подписи**, не разобранным ни как период, ни как исключение (OCR-мусор «| | г», «Проверка»), — `COLUMN_WITHOUT_DATE`, как раньше. Правило общее для Excel и
OCR и живёт в пакете `arm_statements` (шаг 2), не в адаптере хоста.
7. **Столбец наименования** — подпись, начинающаяся с «наименование», в строке заголовка; иначе столбец левее кода с
наибольшим числом текстов, кроме «Пояснения» (пустые или ≤ 3 символов); наименование строки — ячейка этого столбца,
пуста — ближайшая текстовая ячейка левее кода, кроме «Пояснения».
8. **Номер колонки** (`columns[].column_index`, `values[].column_index`, `column_index` предупреждений) — номер столбца
в сетке кандидата шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд: 2, 4, 5), у Excel — номер
столбца листа с 0 (столбец D — 3). Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками
первого сегмента по порядку и несут их номера. Предупреждение о столбце, которого нет среди `columns[]`
(`COLUMN_SKIPPED`), несёт номер этого столбца в сетке; ядро хранит его в `document_issue.column_index` (колонки формы
нет — `column_id = null`).
### §11.4 Шаг 3 — отбор строк данных и статусы
Строка данных — строка сетки с ролью `data`, у которой есть **содержимое**: в ячейке кода есть хотя бы одна цифра
(разобранный код или мусор вроде «44 30\nI |» aep_6), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`),
или непуста с уверенностью ниже 100 (признак потерянной суммы: «[190 :», «" :» в aep_3), или её текст — «цифра под видом
буквы» (после замены двойников без условия соседства есть цифра и нет других букв: `'I'` с уверенностью 100 в строке 38
aep_6). Строка только с текстом наименования — не строка данных (заголовок раздела); текст из настоящих букв в колонке
значений при уверенности 100 (подписи под формой Excel: «Руководитель … Косарев») — тоже нет, осознанное исключение
(S-72). Строка `unreadable` без значений («44 30\nI |» при пустых суммах)
— строка данных: документ `needs_check` (не все строки `ok` / `detail`), но подтверждение она не блокирует
(`LINES_UNRESOLVED` — только строки со значениями, §9.5 п. 4). У строки: `row_index` (номер в документе с
0, по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:<row_index ИС>`, разлепленные
`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>`, без столбца кодов — первой непустой
ячейки), статус, ячейки кода и наименования, ячейки значений по колонкам сегмента, `parent_ref`. **Статусы:**
- `ok` — код разобран;
- `unreadable` — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «[190 :»); ячейка без
единой цифры («}», «:», «изо :» — двойники вне соседства с цифрой цифрами не считаются, S-61) считается пустой;
- `detail` (Р-8) — строка без кода с содержимым, над которой есть строка `ok` (в том же или в предыдущих сегментах —
родитель продолжается через разрыв страницы): расшифровка («в том числе: …», «из них …», «Оборудование к установке»,
«Товар (41.01)»); родитель — ближайшая строка `ok` выше (`unreadable` / `missing` между ними пропускаются). Сервисы её
отдают, ядро хранит; в проверки §7, агрегаты, `needs_review` и `done` она не входит (у её значений `needs_review =
false`, Р-23); экран по умолчанию не показывает; после правок ядро пересчитывает родителей всех строк `detail`;
- `missing` — строка без кода с содержимым, над которой во всём документе нет строки `ok` («Выручка» aep_3, где 2110
уехал в ячейку значения); строки роли `title` сюда не попадают.
Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. **Счётчиков
отброшенных строк нет — есть статусы** (отменяет «строк с нечисловым кодом пропущено», «строк без кода с суммами
пропущено», OQ-36 / OQ-141; OQ-149 — восстановление строк по наименованию — остаётся открытым). Строка формы с пустой
ячейкой кода под другой строкой с кодом попадёт в `detail` и будет скрыта; её значения не войдут в сумму раздела,
нарушение §7 подсветит родителя (пустая цель при непустых членах — нарушение), аналитик раскроет расшифровки и назначит
код (Р-13) — осознанная цена Р-8.
**Повтор кода** (Р-4) — законный случай (один код, разные наименования; договорённость ДБД в AURORA-1431, закрывает
AURORA-1406): все строки `ok`, порядок как в файле, ни предупреждения, ни `duplicate_line_codes` (отменяет «оставлена
первая строка», решение № 15 среза 3a и OQ-152). Для §7 и всего после — сумма по коду (агрегат формы по коду, §9.5 п. 2).
Неверно прочитанный код, совпавший с существующим, ловит структурная проверка (§11.9).
**Разлепление склеенных строк** (OQ-156; Р-17 заменяет решение владельца 08.10.2026 о безусловном `needs_check`) —
**до** разбора кода: ячейка кода содержит N ≥ 2 частей через перевод строки, каждая непустая ячейка значений — ровно N
частей, и хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в
диапазоне ББ). Получается N строк (`ref` `row:<i>#<k>`): k-й код (нечитаемая часть — строка `unreadable`), k-я часть
каждой ячейки значений, наименование — k-я из непустых частей, если их тоже N, иначе целиком; `line_code_text` и `text` —
k-е части; уверенность — исходной ячейки. Значения разлепленных строк — `is_recognized = false`, нуждаются в покрытии
§7 (при сошедшихся соотношениях не подсвечиваются); само разлепление — `ROW_SPLIT` (`info`). Число частей не совпало или
ни одна часть не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст,
`is_recognized = false`. Пример: ББ РБ и Счастливочной за 1 кв. 2025 — код `1500\n1700`, суммы `917 775\n2 371 333`.
У Excel не срабатывает.
### §11.5 Шаг 4 — значения ячеек
Выход: `{column_index, amount, string_value, text, confidence, is_recognized}` (+ `needs_review`, §11.9). `text` — всегда
сырой текст источника; `string_value` — только когда `amount = null` и текст непуст и не прочерк.
1. **`value` есть (Excel):** число → `amount` с квантованием до 2 знаков half-up, не больше 16 целых цифр (иначе `amount
= null`, `string_value` = текст, `is_recognized = false`, `VALUE_OUT_OF_RANGE` `info`); дата или логическое → `amount
= null`, `string_value` = текст, `is_recognized = false`.
2. **`value` нет — `parse_amount(text, source)`**, один для обоих, по стадиям (S-57): (а) прочерк (`-`, `–`, `—`, `−`
без цифр) и пусто → `amount = null`, `is_recognized = true` (OQ-148); (б) крайние `|` удаляются (до замены
двойников), затем двойники вплотную к цифре (§11.3); (в) **ядро числа** — от первого из «цифра, `(`, `-`, `−`, `+`»
до последнего из «цифра, `)`, `}`, `]`»; всё вне ядра — краевой мусор (`:`, `=`, `■`, `•`, кавычки, `{`, `[` …),
выбрасывается с `is_recognized = false` («: : 2856983 = ■» → 2856983, «| : 122477» → 122477); (г) в ядре допустимы
только цифры, пробелы (NBSP, тонкие), `,`, `.`, знак в начале и одна пара скобок вокруг всего числа (`(` первым, `)`,
`}` или `]` последним — `}` / `]` исправление, OQ-147: «(7744536}» → −7744536, `is_recognized = false`; `{` и `[`
открывающей скобкой не считаются); иначе — буква в ядре, скобка без пары или не на краю, знак не в начале, другой
символ между цифрами — `amount = null`, `string_value` = текст, `is_recognized = false` («211(1 |», «(Ю4)», «{38378)»);
(д) цифр нет — `amount = null`, `string_value` = текст. Разделители:
- `ocr`: запятая — тысячи, если весь текст (после `strip` и удаления пробелов, с учётом знака и пары скобок на всю
строку) — `[+-]?[1-9]\d{0,2}(,\d{3})+` (AURORA-1405, sg#35: `4,200` → 4200, `1,234,567` → 1234567); точка — тысячи
при `[+-]?[1-9]\d*(\.\d{3})+` (sg#37: `837.994` → 837994, `1.234.567` → 1234567, `1 234.567` → 1234567); удаление
таких разделителей исправлением не считается. Иначе запятая и точка — десятичный разделитель (`4,2` → 4.2, `1 234,56`
→ 1234.56); смешанные записи (`1.234,56`, `1,234.56`) — цифры подряд, `is_recognized = false`. **Три и более знаков
после десятичного разделителя** (`0,125`, `12.3456`) — `amount = null`, `string_value` = текст (решение № 30 среза
3a сохраняется для OCR-текста: такое значение не укладывается в `numeric(18,2)`);
- `excel`: запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5; как прежний `to_money`).
3. **Пробел внутри числа** допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — «лучшая
догадка» (цифры подряд, не длиннее 16 цифр, иначе `null`), `is_recognized = false`, сырой текст — в `text` («42831 1» →
428311; AURORA-1511, 1519). Многострочное значение в строке с однострочным кодом → `amount = null`, `string_value` =
текст, `is_recognized = false`. Excel-ячейки «число текстом» идут тем же путём.
4. **`is_recognized`** — один смысл у обоих: текст разобран как число без выброшенных и исправленных символов (замена
двойника, `}` / `]` вместо скобки — исправление). `confidence` — отдельно, от источника (у Excel — 100); прежнее «`is_recognized`
= `confidence == 100` и ничего не выброшено» шлюза отменено.
5. **Пустая ячейка** (Р-2): позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`,
`is_recognized = true`, `confidence` ячейки (позиции нет — 100); собственной подсветки нет. Непрочитанная ячейка ИС
(пустой текст, уверенность 0) нуждается в покрытии по §11.9.
6. **Единицы:** «тыс. руб.» и ≥ 10 % дробных значений → `FRACTIONAL_IN_THOUSANDS` (`info`, документ остаётся `done`;
OQ-138 — единицам шапки верим); то же в книге xlsb без числовых форматов — `XLSB_SCALE_SUSPECT` (`error`: вероятен
масштаб ×1000, а §7 сходится при любом масштабе).
Разбор сумм ОСВ (`to_money` в `classify.py`, §1) этими правилами не затрагивается.
### §11.6 Шаг 5 — код, наименование, редакция
Строка: `{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` — разобранный код или `null` у `unreadable` / `missing` / `detail`;
`line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника (у кода с заменой двойника — не выше 99),
**без срезания до 99 по справочнику**; `line_name` — распознанный текст наименования как есть, с уверенностью источника
(Р-3: имя из справочника шлюза `api/rules/statement_lines.yaml` отменено, файл удаляется; соответствие «код →
наименование» — только в ядре, §7 «Полный состав формы»). **Редакция** (`66n` / `fsbu4`) в сервисах нужна только для
упорядоченного перечня кодов (§11.9): проверяются **обе** редакции, берётся та, где подпоследовательность длиннее, а при
равенстве — по эффективной дате (`fsbu4` — после 2024-12-31, как `edition` ядра, OQ-145): в 2025 году формы сдают и на
бланке 66н (Межениновская, 3 кв. 2025), и выбор только по дате не подтвердил бы её код 1120 (в перечне `fsbu4` его нет). Прежний выбор редакции шлюза для имён (по дате, по кодам, «редакция не
определена» / «не заполнена» → `needs_check`) отменён.
### §11.7 Шаг 6 — тип формы, вариант, документ
1. **Тип** — большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений →
`DISCLOSURE`; совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и `TYPE_CONFLICT`
(`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2 п. 1) выполняется **всегда**, в том числе при
`type_hint = DISCLOSURE`: подпись «Пояснения» или имя листа «Расшифровка» над таблицей с кодами 1110–1700 не должны
превратить баланс в пустое пояснение — большинство кодов в отрезке ББ или ОФР даёт тип по кодам и `TYPE_CONFLICT`
(`error`). Без столбца кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с
`CODE_COLUMN_NOT_FOUND` `error`); `type_hint` нет → у OCR таблица ИС с содержимым (хотя бы одна ячейка с цифрой
или «цифрой под видом буквы») — предупреждение **файла** `TABLE_LOST` (`error`, `locator = file:<имя json>;table:<i>`),
таблица только из текста — `TABLE_SKIPPED` (`info`, файл, S-71); у Excel — лист «прочее», предупреждение **файла**
`SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа во всех случаях нет. `DISCLOSURE` (по `type_hint` без
кодов форм или по кодам прочих форм) — шаги 3–7 не выполняются, `columns` и `lines` — пустые массивы (ядро строки
пояснений не хранит), `checks` нет, документ `done`.
2. **Вариант формы** `form_variant` — только по структуре кодов всех строк `ok` (ядро определяет его заново при каждом
пересчёте после правки; ОКУД 0710096 — код КНД комплекта, на бланках
0710001 / 0710002, признаком не служит): ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; ОФР — есть 2400 или
2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` / `OTHER` — `null`. Шаг 7 и перечень
кодов берут набор варианта. Образец упрощённой формы — в fixtures при первой возможности (OQ-166).
3. **`locator`** — пары `ключ:значение` через `;`: `file:<имя json>;table:<i>` / `sheet:<A>[,<B>]`. Шапка документа —
поля кандидата (`text` → `<поле>_text`, разобранное → поле, `confidence` → `<поле>_confidence`).
### §11.8 Шаг 7 — самопроверки §7
Правила счёта — §7 («Самопроверка в сервисах»): один словарь у обоих сервисов, значение кода — сумма строк `ok`,
«посчитанное» соотношение, допуск и округление, `abs_terms` подтверждают величину, но не знак, полный или упрощённый
набор по `form_variant`, родитель подстроки. `checks` есть у каждого `BALANCE` / `INCOME` со строками и отсутствует у
документа без строк. Проверки по словарю имён и перечню кодов («код не в словаре», «код не из редакции») — в ядре,
`info` к строке.
### §11.9 Шаг 8 — `needs_review`, статус, предупреждения
**Подтверждение проверкой** (Р-5, Р-7, Р-11, Р-17, Р-21). Проверка §7 — арбитр, уверенность — подсказка там, где
проверки нет. Правила ниже — для значений строк `ok`, `unreadable`, `missing`; у значений строк `detail` `needs_review =
false` всегда (Р-23, решение владельца 18.10.2026): кода у них нет, покрыть их нельзя, в §7, агрегаты и статус они не
входят, сомнение видно в подсказке ячейки (`text`, уверенность).
- Значение **нуждается в покрытии**, если `confidence < 100` (Р-5: у ИС уверенность дискретная — 100, ~80, 71, 67, 60,
50, 25, 0; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80) или `is_recognized = false`
(грязный текст, замена двойников, разлепленная строка). Чистые значения в покрытии не нуждаются.
- Значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое
посчитано и сошлось: **жёсткое** — в пределах допуска ±1 или округления 0,001 %; **мягкое** — только точно, в пределах
допуска ±1, без «округления» (Р-21, решение владельца 18.10.2026). Мягким покрываются строки, у которых жёстких
соотношений нет: 2400, 2410, 2411, 2412, 2460 (ОФР полного набора; `2400` упрощённого — тоже мягкое, S-5), 2510, 2520,
2530, 2500 (`2500 = 2400 + 2510 + 2520 + 2530`), пятизначные подстроки («сумма подстрок = родитель»).
- **Знак** члена `abs_terms`, нуждающегося в покрытии: подтверждён, если форма знака совпадает с формой строгого
большинства **остальных** членов `abs_terms` той же колонки с непустым `amount`, `is_recognized = true` и без правки
аналитика (сама ячейка не голосует); форма знака — по результату разбора `parse_amount`: «скобки» (пара, в том числе с
`}` / `]`), «минус», «без знака»; голосующих нет или голоса поровну — не подтверждён (S-66; то же правило — при
покрытии мягким соотношением, например 2410 в `2400 = 2300 − 2410 + 2420`). «(7744536}» подтверждается, если 2210 и
2220 той же колонки в скобках; сомнительное «38378» без знака при остальных в скобках — подсвечивается; «{38378)»
разбором даёт `null` (§11.5) и подсвечивается как непокрытое.
- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех
ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности. Иначе `false`: сырой
текст и уверенность остаются в подсказке ячейки. Мягкое соотношение, не сошедшееся точно, подсветки не даёт.
Не покрыто проверками и остаётся на уверенности: строки 29xx (прибыль на акцию), 2420 и 2530 вне сошедшихся мягких
соотношений, детализация без строки-родителя, `DISCLOSURE`, колонки, где код не участвует ни в одном посчитанном
соотношении. Риск двух взаимно компенсирующих ошибок в одном соотношении принимается. Закрывает AURORA-1524 без ущерба
для 1519.
**Код строки** (Р-5, Р-12, Р-16, Р-20). Код неуверенный — `line_code_confidence < 100` или заменён двойник; структурная
проверка — **только для неуверенных кодов** (у Excel и чистых OCR-кодов расхождение с перечнем — `info` ядра
`CODE_NOT_IN_EDITION`, не `needs_check`: пятизначные подстроки 1С и нестандартные коды клиента не блокируют
автоподтверждение). В пакете — **упорядоченный перечень кодов** каждой формы по редакциям (66н, ФСБУ 4) и вариантам, без
наименований, в порядке бланка (итоги после слагаемых: 1110 … 1190, 1100; 2110, 2120, 2100). **Данные перечня —
`contracts/statement-code-orders.yaml`** (источник; в пакете — его байтовая копия, сверяет `sync-statements --check`):
объединение кодов шаблонов банка (словарь ядра) и кодов печатных форм 1С и сканов из `fixtures/` (1105, 1130, 1140, 1215,
1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; код без образца в перечень не входит (кандидаты — OQ-168;
временное правило утверждено владельцем 18.10.2026, Р-26: неуверенный такой код на скане — `needs_review` строки),
упрощённые варианты — по бланку (OQ-166). **Законные повторы кода** (Р-20, решение владельца 18.10.2026) — часть перечня
(`repeats`): у `fsbu4` — 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощённой
системе налогообложения» (печатная форма 1С, AURORA-1431), у `66n` — раздел III некоммерческой организации после раздела
III (aep_3). Алгоритм: четырёхзначные коды строк `ok` документа — последовательность (коды, которых нет в перечне
редакции, в её последовательность не входят); для **каждой** редакции перечня (66н и ФСБУ 4 — всегда обе: Межениновская
за 3 кв. 2025 сдана на бланке 66н) — **лучшая** подпоследовательность, согласованная с порядком перечня варианта формы
документа (повтор допустим столько раз, сколько код стоит в перечне): сначала наибольшее число уверенных кодов (якоря;
противоречащие по порядку якоря все войти не могут — входит наибольшее согласованное множество), затем наибольшая
длина; редакция — с лучшей (при равенстве — по эффективной дате: `fsbu4` после 2024-12-31, OQ-145); неуверенный код
подтверждён, только если входит во все лучшие подпоследовательности выбранной редакции (S-65). Вариант формы — по всем
строкам `ok` (§11.7); ядро пересчитывает вариант и проверку после правки кода. Пятизначный код
подтверждён, если подтверждён его родитель (§7) и подстрока стоит после него. Повтор кода, которого **нет в перечне**
(или копий больше, чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной копии (Р-16);
повтор из перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `needs_review` строки,
документ `needs_check`. Подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120) структура не
ловит — это закрывает ядро: для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение распознанного
наименования со справочным (§9.3).
**Совпадение наименований** (ядро; S-4 с оговорками ревью, решение владельца 18.10.2026): обе строки — нижний регистр,
`ё` → `е`, всё кроме букв и цифр → пробел, пробелы схлопываются, крайние обрезаются (`strip`); совпало, если строки
равны, или более короткая (≥ 5 символов) входит в более длинную, или `difflib.SequenceMatcher` ≥ 0,8 — с **любым**
справочным именем кода в любой редакции словаря ядра (у кода может быть несколько имён: синонимы и законные повторы —
Р-20). Исключение: если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера
раздела** — начальные буквы `i` / `v` / `x` первого слова после фразы (кириллические «і», «х» приводятся к латинским,
хвост слова отбрасывается: «Итого по разделу V1» Счастливочной и «Итого по разделу V /» РБ → V); совпало ⇔ номера равны;
номер не выделен — только равенство строк (S-60). Без исключения «итого по разделу i» входило бы в «итого по разделу ii»
— подмена 1100 ↔ 1200 ↔ 1300 не ловилась бы именем. В словарь имён ядра `fsbu4` в PR перехода добавляются вторые имена
законных повторов 1220, 1520, 2410 и коды 1105 «Гудвил», 1130, 1140, 1530, 2460 (спека `arm-core`).
Кода нет в словаре или наименование пусто — не совпало; несовпадение — `NAME_MISMATCH` (`info`) у строки. По
`fixtures/ocr` коды часто приходят с уверенностью 50 и 25 — требовать 100 нельзя.
**Поля шапки.** Дата отчёта с уверенностью ниже 100 подтверждена, если совпала с самой поздней абсолютной датой колонок
с подписью уверенности 100 (неуверенная подпись дату отчёта не подтверждает, S-64);
иначе `report_date` попадает в `needs_review_fields` и `REPORT_DATE_UNCERTAIN` (`error`) — неверно прочитанный год давал
бы неверный период и автоподтверждение. **У бланка 66н только с относительными подписями** («На отчётную дату», …)
абсолютных дат колонок нет, и неуверенная дата отчёта структурой не подтверждается никогда — такой документ всегда
`needs_check` с `REPORT_DATE_UNCERTAIN`; аналитик сверяет дату с бланком и подтверждает её (PATCH `header.report_date`
тем же значением или исправленным, §9.5 п. 9). ИНН и организация с уверенностью ниже 100 — `info` (их сверяет ядро: ИНН
— `MISMATCH` с ЮЛ, организация — по алиасам), в `needs_review_fields` не попадают (`needs_review_fields` — поля, которые
держат документ в `needs_check`; подсветка ИНН и организации на экране — по предупреждениям с `field`; S-10, решение
владельца 18.10.2026); единицы и прочие поля на статус не влияют.
**Предупреждение** — структура `{code, severity, message, line_refs?, column_index?, field?}`; уровень файла
(`metrics.warnings`) — ещё `locator?`. Место — `locator` документа (у предупреждения файла — его `locator`), не текст.
`line_refs` — только `ref` строк из `lines[]` (о строках ролей `title` / `header` / `numbering` — в `message`);
`column_index` — номер столбца сетки (§11.3 п. 8). Принцип: `error` — только то, что меняет данные или их полноту;
`error` → `needs_check`, `info` на статус не влияет. Предупреждение уровня файла с `error` блокирует `RECOGNIZED` всех
документов файла (§9.3).
| Код | Уровень | Когда | Чем снимается в ядре |
|---|---|---|---|
| `JSON_FAILED` | error, файл | json набора ИС ошибочен при успешном соседе | перезагрузка |
| `TABLE_PARSE_ERROR` | error, файл | исключение при разборе таблицы ИС (`locator` таблицы; документа нет) | перезагрузка |
| `TABLE_LOST` | error, файл | таблица ИС с содержимым (ячейка с цифрой или «цифрой под видом буквы») без столбца кодов и без типа | перезагрузка |
| `TABLE_SKIPPED` | info, файл | таблица ИС только из текста без столбца кодов и без типа (документа нет) | — |
| `CODE_COLUMN_NOT_FOUND` | error | форма `BALANCE` / `INCOME` без столбца кодов | перезагрузка |
| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в т. ч. лист-продолжение) | перезагрузка |
| `SHEET_NOT_JOINED` | error | лист с ≥ 3 кодами и другим числом колонок не присоединился | перезагрузка |
| `CONTINUATION_TYPE_CONFLICT` | error | тип маркера продолжения противоречит форме | перезагрузка |
| `SEGMENT_COLUMNS_MISMATCH` | error | при сопоставлении по порядку число колонок сегмента не совпало с первым | перезагрузка |
| `COLUMN_WITHOUT_DATE` | error | числовая колонка без разобранной подписи | PATCH `columns[].period_date` |
| `COLUMN_DATE_UNCERTAIN` | error | неуверенная подпись не попала в ожидаемые даты | PATCH `columns[].period_date` (в том числе той же датой — подтверждение) |
| `HEADER_ROW_NOT_FOUND` | error | заголовка нет ни у одного сегмента | перезагрузка |
| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` | перезагрузка |
| `NO_ORGANIZATION_NO_INN` | error | нет ни организации, ни ИНН | PATCH `header.inn` / `organization_name` |
| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок | PATCH `header.report_date` |
| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок (у бланка только с относительными подписями — всегда) | PATCH `header.report_date` (в том числе тем же значением — подтверждение) |
| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов, «тыс. руб.», ≥ 10 % дробных | перезагрузка |
| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден / неуверен при найденной организации | — |
| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена / неуверенна при найденном ИНН | — |
| `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | PATCH `header.report_date` |
| `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 «прочее» без типа и кодов (`locator = sheet:<имя>`; документа нет) | — |
| `ROW_SPLIT` | info | разлеплена склеенная строка | — |
| `NUMBERS_OUTSIDE_TABLE` | info | числа правее последней подписанной колонки значений не учтены (шаг 2, п. 6); адреса ячеек — в тексте | — |
| `OSV_*` | info | предупреждения листа ОСВ (§1) | — |
| `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH` | info, ядро | проверки ядра (§9.3, §9.5) | пересчёт |
| `RESULT_STATUS_INCONSISTENT` | info, ядро | `document.status = done` из результата противоречит строкам, `needs_review`, нарушениям или предупреждениям документа — ядро считает его `needs_check` (§9.3) | пересчёт после правки |
| `MAPPING_WARNING` | info, ядро | предупреждение маппера 1.x по документу (повтор кода, значение вне колонок) | — |
| `LEGACY_WARNING` | info, файл | строка `metrics.warnings` результата 1.x (`recognition_job.metrics`) | — |
| `RELATION_FAILED` / `RELATION_SOFT` | error / info | код записи `document_issue` вида `relation` / `soft` — нарушение §7 из `checks` (или пересчёта ядра) с `codes`, `relation`, `expected`, `actual` | пересчёт после правки; `relation` — ещё акцепт |
Назначение кода и отметка `detail` снимают нарушения, пересчитываемые §7. Новый код предупреждения вводится словарём
пакета и строкой этой таблицы; в контракте `code` — строка, не перечисление.
**Статус документа** — одна функция общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧ период определён
(дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей
шапки с `needs_review` (значения строк `detail` не учитываются, Р-23) ∧ нет жёстких нарушений §7 вне допуска и
округления ∧ нет предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о
раскладке, строках и проверках. Квартал периода,
сверка ИНН и наименований — знание ядра (§9.3), в `done` не входят. У ОСВ `document.status = done`, когда контрольные
суммы §1.4 листа сошлись (§1.6, S-22), её предупреждения — `info`.
**Интерфейс функции статуса** (K-14) — один для сервиса и ядра, на входе только данные, без типов источника:
`document_type`; `layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть хотя бы
одна строка из источника, `row_index` не `null`); `has_organization_or_inn`; `period_known` (дата отчёта или абсолютная
дата хотя бы одной колонки — у ядра `document.reporting_date` не `null`); строки `{line_status, needs_review}`; значения
`{line_status строки, needs_review}` (значения строк `detail` функция не учитывает); число жёстких нарушений вне допуска и округления; `accepted` (акцепт — жёсткие нарушения не
учитываются); уровни предупреждений документа (у ядра — записи `document_issue` вида `warning` с `severity = error`).
`needs_review` строк и значений на входе уже учитывает подтверждения: у ядра правленое (`is_modified`,
`line_code_modified`) или подтверждённое (`is_confirmed`) — `false`. Сервис собирает вход из `FormCandidate` и
результата шагов 2–7, ядро — из строк БД (после правки — точкой входа пакета для пересчёта по сохранённым данным,
спека `statement-normalization`; при приёме результата — для проверки согласованности `done`, §9.3); так один код даёт
один статус.
**Ожидание на реальных сканах** (`fixtures/ocr`, оценки ревью спеки 18.10.2026, K-4 и L-1): после Р-20 (повторы 1220 /
1520 / 2410 в перечне `fsbu4` и синонимы в словаре имён), Р-21 (мягкие соотношения ОФР) и Р-23 (значения `detail` не
держат статус) законные повторы, строки 24xx / 25xx и ячейки расшифровок документ не держат. По прогону ревьюера ББ и
ОФР РБ («Бухгалтерская отчетность 1 кв.25») и Счастливочной («БО 1 кв 2025») могут стать `done`: все их неуверенные
значения строк с кодом покрыты сошедшимися соотношениями, неуверенные коды подтверждаются перечнем `fsbu4`, а держали
их только ячейки расшифровок. `needs_check` остаётся там, где есть что смотреть: строки `unreadable` / `missing` с
суммами (aep_3), несошедшиеся соотношения и мусор в значениях (aep_6: «(Ю4)» в 2350), неуверенная дата отчёта.
Golden-эталоны `fixtures/ocr/expected_normalize/` пересчитываются в PR scan-gateway (шаг 5 выкладки), фактическая доля
`done` — в его описании.
**Статус конверта:** `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`;
документов нет — `error` (`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser — коды описывают
разные причины). Порядок документов tabular-parser — сначала ОСВ в порядке листов, затем формы. Статус конверта в
условие `RECOGNIZED` ядра не входит (Р-19).
### §11.10 Что отменено и заменено этим параграфом
| Прежнее правило | Теперь |
|---|---|
| Строки без кода не отдаются и не хранятся (OQ-36, OQ-141; §12 п. 5) | статусы `detail` / `missing` / `unreadable`, хранятся (Р-8); OQ-36 и OQ-141 закрыты |
| Двойники — только в датах (§11.1 п. 1, решение № 22 среза 3a) | двойники в датах, подписях, кодах и суммах (Р-11); ИНН — без двойников |
| Три и более знаков после разделителя → `amount = null` (решение № 30 среза 3a) | сохраняется для OCR-текста; для числа Excel — квантование до 2 знаков |
| Разлепленная строка → документ `needs_check` (решение владельца 08.10.2026 по OQ-156) | значения нуждаются в покрытии §7, `ROW_SPLIT` — `info` (Р-17) |
| Повтор кода: остаётся первая строка (решение № 15 среза 3a, §12 п. 5, OQ-152) | все строки `ok`, сумма по коду (Р-4) |
| Наименование — из справочника шлюза, уверенность кода ≤ 99 вне справочника (§11.1 п. 6, п. 10) | наименование из источника, уверенность от источника (Р-3) |
| Без шапки — синтез шапки и `needs_check` (рулинг 06.10.2026) | правило строки заголовка §11.3 п. 4 |
| Подпись без периода не читается (§12 п. 4) | колонка без даты и `error` (Р-6) |
| Закрывающая `}` / `]` не даёт минус (решение № 12, OQ-147) | `}` / `]` — закрывающая скобка, `is_recognized = false` |
| Длинное тире → `is_recognized = false` (решение № 20, OQ-148) | прочерк любого вида — `null`, `is_recognized = true` (OQ-148 закрыт решением владельца 18.10.2026, Р-22 / S-7) |
| `is_recognized` OCR = уверенность 100 ∧ ничего не выброшено; любое предупреждение → `needs_check` | `is_recognized` — только разбор; статус — §11.9 |
| `checks` ядро игнорирует и пересчитывает §7 при загрузке | хранит как пришли, пересчитывает только после правок |
### §11.11 Отложено
Восстановление строк по наименованию (OQ-149); сопоставление формы без столбца кодов по именам строк (OQ-139); склейка
страниц из разных файлов (OQ-140) и половин формы из одного файла (OQ-165, Р-15); приведение единиц сверх числового
формата (OQ-138); формулы §7 по редакциям и знаки 2410/2420 (OQ-12, OQ-146); образец упрощённой формы (OQ-166); коды
перечня без образца в fixtures (OQ-168);
несколько периодов в одном файле ИС (OQ-167, допущение владельца); переключатель «как в файле» на экране (Р-14).
Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_* + document_issue` для PDF и Excel;
экран «Проверка распознавания» показывает статусы строк и предупреждения у места.
---
## §12. ББ и ОФР из Excel — см. §11
Правила разбора форм из Excel (tabular-parser) с контракта v2.0 — часть общей цепочки нормализации ББ/ОФР: источник
Excel — §11.2, раскладка — §11.3, строки — §11.4, значения и числовой формат ячейки — §11.2 п. 3 и §11.5, тип и вариант —
§11.7, самопроверка — §7 и §11.8, статус — §11.9. Прежний текст §12 (Срез 2b, контракт v1.2) — в истории git, коммит
`43b4b80`. Ссылки «§12 п. N» в старых документах: п. 1 → §11.2 п. 1; п. 2 → §11.3 п. 1; п. 3 → §11.3 п. 7; п. 4 → §11.3
п. 3, п. 6; п. 5 → §11.2 п. 3, §11.4, §11.5; п. 6 → §11.2 п. 2; п. 7 → §7, §11.8; п. 8 → §11.7 п. 1; п. 9 → §11.2 п. 4;
п. 10 → §11.9; п. 11 → §11.11.