arm-ka-docs/context/05-domain-rules.md
Andrey Epifancev 2b3ead461a docs: исправить вставку в §11 и сигнатуры контракта 3b (#81)
M-7: вставка про достройку строк разорвала перечисление в 05-domain-rules.md §11, вынесена отдельным предложением.
M-8: в api-contract-slice-3b.md у LineOut/ValueOut добавлены id | null и origin, у StatementPatch.values - line_id | line_code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:31:35 +04:00

1075 lines
153 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.17)
Каждый раздел: источник в ТЗ → правило → где в модели. Где ТЗ молчит или противоречит — пометка **[открыто]** и
ссылка на `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
Выводимые данные: сумма
Субконто | Сальдо на начало периода | Оборот за период | Сальдо на конец периода
| Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
```
Нормализация значений шапки в типы:
| Текст в шапке | `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` (текст первой колонки), шесть сумм, ИНН (**только из колонки ИНН**; в ячейке оставляются цифры, результат
только при 10 или 12 цифрах — «ИНН 352819549855» → `352819549855`, иначе NULL; число из 9/11 цифр — ИНН региона 01–09
с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке
приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем строка без сумм **прямо над** названием на том же уровне
оформления (в `rows` не попадает, предупреждение «ИНН … над строкой … отнесён к ней»), затем конец текста названия
(«Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»).
Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст; только для строк
`COUNTERPARTY`, договоры и прочее не трогаются; правила — `osv_headers.yaml` (`counterparty_inn_suffix`, `inn_only_row`,
`inn_kpp_cell`); **реализовано**, OQ-100, OQ-130 (tabular-parser #9, PR #15; синтетика `s17`–`s19`),
`data_type` (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки).
Строки «Вал.», «Кол.» и трёхбуквенный код валюты («USD») — не уровень группировки, а вид сумм родителя:
`group_type = OTHER`, `data_type = CURRENCY | QUANTITY`, позицию в `group_schema` не занимают, **не наследуют**
`counterparty_name`/`counterparty_inn` (контрагента потребитель находит по родителю), `account_code_raw` наследуют от
ветки; их суммы остаются в шести полях, но в проверки родителя не входят (§1.4).
Классификация строки (п. 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`) пишутся как в результате.
Нормализация кода счёта (ядро, `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): у 91 и 99 строки аналитики несут
только обороты, сальдо закрывается на субсчёт закрытия (91.09), поэтому тождество строк на них не проверяется; список
счетов — данные в `rules/*.yaml` tabular-parser (начальный состав 91, 99; 90 не входит — внутри года у его субсчетов
сальдо есть). Проверки «сумма детей = родитель» и «Итого» на этих счетах сохраняются в таком виде (R-3 PR
arm-tabular-parser#26, подтверждено владельцем 08.10.2026): **обороты** родителя сверяются с суммой детей всегда;
**сальдо** — только у родителя с дочерними субсчетами (91 → 91.01/91.02/91.09); у субсчёта, под которым одна аналитика,
сальдо с детьми не сверяется (у аналитики оно пусто, у субсчёта — равно оборотам). Счёт строки сопоставляется со
списком по ближайшему счёту ветки: запись `91` покрывает `91`, `91.01`, `91.09`, но не `910` и не `90.01`; строка
«Итого» проверяется как обычно.
- По каждому родителю: суммы родителя = Σ детей того же `data_type`. Строки CURRENCY/QUANTITY в суммирование не входят
и сами родителями не проверяются. **Обороты** сверяются по колонкам (дебет с дебетом, кредит с кредитом). **Сальдо**
(начало, конец) — по колонкам либо, **если у родителя сальдо свёрнуто** (не больше одной ненулевой стороны), по нетто
`дебет − кредит` (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).
- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0); если корней уровня 0 несколько (лист без
строки корневого счёта: верхний уровень — субсчета 58.01, 58.03, OQ-133, PR #13) — «Итого» == **сумме строк уровня 0**
(показатели без суммы и сама «Итого» не входят; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя
выше, R-21); при одном корне сверка прежняя. Вторая итоговая строка сразу за «Итого» («Итого развернутое») — тоже `TOTAL`,
в сверку не входит; строка сумм без подписи в конце таблицы принимается за «Итого» с предупреждением.
Если корня нет (**пустая ОСВ**: заголовок и
сразу «Итого» — в фикстурах две, 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).
`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: нет признаков ОСВ или формы`; листы форм теперь разбираются, §12). Другие предупреждения:
`sheet=<имя> hierarchy=<стратегия>` (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное
поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение
«xlsb: outline level недоступен»).
Лимиты: `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), удаление ЮЛ
из периметра. Версии `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, а у
«Русского бисквита» ОСВ без колонки ИНН — контрагент «Пекарня Счастливочная ООО» только текстом → резолв по алиасу.
---
## §4. Правила элиминации — пары счетов (Прил. 3 ТЗ, DOC-223044, FNR03)
Хранятся как данные: `elimination_rule` (+ `elimination_rule_account`), версионируются; версия фиксируется в
`consolidation_version.elimination_rules_version`. 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)
Границы: только `closing_debit` / `closing_credit`; только компании периметра версии; пары счетов — из §4; **1:1**;
строка участвует в одном сопоставлении целиком.
```
для каждой строки a компании A с counterparty_id = B ∈ периметр, group_type = COUNTERPARTY, data_type ∈ (BU, SUM):
сторона a = closing_debit, если > 0, иначе closing_credit
кандидаты = строки b компании B с counterparty_id = A, противоположная сторона,
(счёт a, счёт b) входит в допустимую пару правила r (для каждого r — отдельный набор)
1. точное 1:1 (сумма a == сумма b, одна пара счетов) и единственный вариант → матч, обе строки выбывают
2. несколько точных по РАЗНЫМ правилам → не матчим, вниз таблицы на ручное
3. нет точного: по каждому правилу — минимальное |a − b|; выбираем правило с минимальным расхождением;
равное минимальное по разным правилам → не матчим (ручное)
результат пары: rule, A, B, сумма A, сумма B, deviation_amount = |A−B|, deviation_percent = dev / min(A,B) * 100,
исходные osv_entry.id обеих сторон
```
Результат **не хранится** (DECISION-5) — возвращается как структура для шахматки, корректировок и КФО. Ручное решение по
паре — `correction` с естественным ключом (`rule_id`, `legal_entity_id`, `counterparty_id`).
Открыто: агрегировать ли несколько строк одной компании по одному контрагенту и счёту перед матчингом (FNR03 говорит
«несколько строк суммируются», DOC-223044 — «одна позиция = одна строка»). Рекомендация: агрегировать по
(компания, контрагент, счёт, сторона), сохраняя список `osv_entry.id` как детализацию.
**Алгоритм — по ТЗ** (решение владельца 08.10.2026, OQ-155): пары, `min`, порог 5 %, а не полная элиминация каждой
стороны, как в эталонном Excel. Агрегация перед матчингом (OQ-07) ещё не решена; на фикстурах несколько строк одной
компании по одному контрагенту и счёту возникают из группировки «Подразделение» (ОСВ 62 Счастливочной, Q3 2024: 62.01
«БП Производство» 106 311 116,46 и «БП Аренда» 1 401 330 на «Русский бисквит» при одной строке 60.01 у РБ). Варианты —
строго 1:1 по строкам (DOC-223044) или гибрид: сначала точные пары 1:1, затем агрегация остатка.
---
## §6. Шахматка и результирующая таблица (DOC-221277)
Вход: версия + набор дебетовых счетов (`kind ∈ A, AP`) и кредитовых (`kind ∈ P, AP`); предустановки: «58.03 vs
67.03 + 66.03», «60 + 62 + 76 vs 60 + 62 + 76», «все счета».
Строки/столбцы — компании периметра; диагональ пуста.
```
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; > 5 % — подсветка
ячейка: amount_a / amount_b / (variance)
```
Фильтры строк — как в §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_*`).
**Парсер tabular-parser (§12 п. 7)** считает **жёсткими** (нарушение → `needs_check`) соотношения ББ — `1100`, `1200`
(+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`, `1600 = 1700` — и ОФР — `2100`, `2200`,
`2300`; **мягкими** (предупреждение) — `2400 = 2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`,
`2410 = 2411 + 2412`), `2500 = 2400 + 2510 + 2520 + 2530` и «сумма пятизначных подстрок = родитель»
(`ASSUMPTION(OQ-12)`). Допуск ±1 в единицах формы (расхождение до 0,001 % наибольшего числа соотношения — округление,
мягкое, OQ-138), расходы — двойной расчёт знаков (`abs_terms`). Межформенная проверка
`1370 − 1370(начало года) = 2400` парсером не выполняется. Это сигнал о структуре и данных файла, а не замена проверок
ядра: ядро (Срез 3) пересчитывает §7 и ведёт акцепт (`financial_statement_meta.checks_accepted_*`).
**Ядро (Срез 3b, реализовано).** Ядро считает §7 само, для обоих источников (Excel и OCR), по **текущим** суммам формы
(`financial_statement_value.amount` после правок аналитика); результат не хранится, считается на лету (`GET
/documents/{id}/statement`, подтверждение). Правила — словарь `arm-core/api/rules/statement_checks.yaml`: **копия**
словаря tabular-parser (те же жёсткие и мягкие соотношения, допуск ±1 в единицах формы, округление 0,001 %, `abs_terms`,
мягкая «сумма пятизначных подстрок = родитель»); тест идентичности копий между репозиториями не ставится, расхождение —
известный риск (`03-architecture.md`). Соотношение считается по каждой колонке и только если строка-цель (левая часть) есть в форме;
отсутствующий код и пустая сумма — 0 (ненулевые слагаемые при пустой цели дают жёсткое нарушение). Результат в ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в
том числе «в пределах округления»), `cross_form`, `accepted`.
Межформенная `1370(отчётная дата) − 1370(начало года) = 2400` — **мягкая** и не блокирует подтверждение: пара — другой
документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый предпочтительнее, далее
самый поздний `created_at`); считается, только если найдены пара и обе колонки (ОФР часто приходит отдельным файлом позже).
**Акцепт** — по жёстким нарушениям, с комментарием (1–2000 символов), `POST /documents/{id}/statement/checks/accept`;
без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы** (значения или шапки): данные
изменились, проверку нужно смотреть заново. Аудит акцепта — `RECOGNIZED_DATA_EDIT` (отдельного `audit_action` нет, новое
значение enum потребовало бы DBML v5.17). Формулы одни для редакций `66n` и `fsbu4` (`ASSUMPTION(OQ-12, OQ-146)`); редакцию
ядро определяет по дате периода документа и отдаёт в ответе (`edition`: `66n` при дате ≤ 2024-12-31, `fsbu4` — позже, `null`
без даты; решение владельца 07.10.2026 по OQ-145 — редакция в контракт не передаётся, досрочный ФСБУ 4/2023 — новый OQ при
первом случае); раздельные словари и формулы по редакциям — вместе с составом `fsbu4` (OQ-146, OQ-150).
**Полный состав формы (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`) и в БД не пишет; первая непустая правка создаёт строку
(`line_code_original_value = NULL` — признак «не из источника») и ячейку как правку аналитика (аудит
`RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная
правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не
достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом.
Когда: после распознавания; после «Сохранить» правок; при «Подтвердить данные». Непройдено → `document.status =
NEEDS_CHECK`; пройдено/акцептовано + нажато «Подтвердить» → `CONFIRMED` + `audit_event(DOCUMENT_CONFIRM)`.
Отрицательные значения в формах — в скобках `(2100)` → `-2100`. Значения с `confidence < 100` подсвечиваются.
---
## §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` (по §4–5, пересчёт в тыс. руб.) → `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`) |
|---|---|---|
| загрузка файла с `consolidation_version_id`, принят ≥ 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`.
### 9.4 Задание (`recognition_status`): queued → published → processing → done | needs_check | error | timeout → dlq;
replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `reply_deadline` OCR 5 мин.
### 9.5 Первичные документы: загрузка, маппинг, замена, удаление, подтверждение (Срез 2c)
1. **Загрузка** (`POST /legal-entities/{id}/files`, право `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.
3. **Автозамена.** После успешного маппинга другие файлы того же ЮЛ (не `SUPERSEDED`), содержащие документ с теми же
`document_type`, `osv_account_code`, `period_year`, `period_quarter`, переводятся в `SUPERSEDED`
(`superseded_by_file_id` = новый файл); `version_no` нового = максимум заменённых + 1. Заменяется и файл с `CONFIRMED`
документом (решение владельца 05.10.2026); документ без года или квартала в замене не участвует. Документы и строки
заменённых файлов сохраняются, но по умолчанию не показываются. Если новый файл содержит лишь часть документов
заменяемого, заменяемый уходит в `SUPERSEDED` целиком (OQ-06: замена файлом целиком; частный случай — OQ-115). Для форм
поведение то же (решение владельца 07.10.2026, OQ-154): исправленный отдельный ОФР заменяет всю книгу «ББ + ОФР» того же
периода. Чтобы это не происходило молча, документы заменённого файла, ключа которых нет в новом, перечисляются в
предупреждении `metrics.mapping_warnings` нового файла: тип, счёт, период, был ли `CONFIRMED`, из каких версий сняты
привязки (реализация — RSHB/arm-core#58).
Аудит: системное `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`.
При подтверждении пишется `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`, `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. **Загрузка в контексте версии** (`?consolidation_version_id=`, Срез 4): до приёма — версия видима (404), «в работе»
(409 `VERSION_NOT_EDITABLE`), ЮЛ — член периметра (422 `ENTITY_NOT_IN_PERIMETER`); после приёма хотя бы одного файла
INITIATED → DOCUMENTS_PROCESSING (§9.1); сбой этого перехода не отменяет принятые файлы. Блокировка версии для загрузки не
нужна.
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 символов). Код и название строки, добавление и
удаление строк и колонок не правятся. **Только до подтверждения:** документ `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 без аудита. Автозамена при правке не запускается (только при маппинге): правка даты, давшая период
другого файла ЮЛ, привязывает документ к версии лишь при подтверждении. Порядок блокировок — как у подтверждения (файл →
документ).
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 нет — удаляет сервис).
---
## §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. Маппинг JSON ИС Сканирование (FNR01/FNR02) → формы
Формат ответа — **по реальным файлам** `fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json` (проверено 04.10.2026; ТЗ
и первая редакция этого параграфа расходятся с ними — см. OQ-13, OQ-14, OQ-77):
- Каталог ответа `<job_id>/` содержит **несколько json** — по одному на распознанный документ файла (пример: `aep_3.json` —
успех, `aep_3_ofr.json` — ошибка по второму документу того же PDF). Шлюз читает все json каталога и склеивает в один ответ
(`raw/{job_id}.json`: `{job_id, files[{name, response, parse_error}]}`); ошибка одного файла не отменяет остальные.
- Корень файла: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`** (поле `error` не `null`, а
`{"code": "", "message": ""}`); ошибка — непустой `code` (`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`), `data_ocr` с пустыми полями и `table_structure: []`.
- `data_ocr` — **объект** (не список): `label`, `organization`, `inn`, `units`, `date` — каждый `{form_name, confidence}`
(в `form_name` шумный OCR-текст; `date.form_name` ненадёжна, ИНН может быть мусором), `table_structure` — **список таблиц**
`{label {form_name, confidence}, rows[]}`: `label.form_name` «Бухгалтерский баланс» → BALANCE, «Отчет о финансовых
результатах» → INCOME. **Наблюдается:** в одном json может быть **несколько таблиц разных форм** (в `aep_3.json` и
`aep_6.json` — баланс и ОФР), каждая со своим `label`; тип документа задаёт `label`. Разбиение одной формы на несколько
таблиц (сегменты) в образцах **не встречалось** и не предполагается; если одна форма вдруг придёт двумя таблицами или
`label` повторится — это нештатный случай: документ не создаётся молча, а уходит в `NEEDS_CHECK` с причиной (решение —
Срез 3, OQ-77). Неизвестный `label` → `OTHER`.
- `rows[]`: `row_index`, `cells[] {column_index, value, confidence}`; `column_index` не обязательно подряд (бывают 0, 1, 2, 4, 5),
`value` — сырой OCR-текст (мусор в ячейках, переносы строк, скобки для отрицательных, пробелы-разделители тысяч).
В `aep_6.json` `rows[0]` — строка-шапка («Наименование показателя», «Код», «На 30 июня 2025 г», …); в `aep_3.json` шапки
**нет** — первая строка данных. Бывают строки без кода (расшифровки «в том числе…», OQ-36) и код-строка внутри ячейки
наименования («АКТИВ / I. Внеоборотные активы / Гудвил» с кодом 1105).
- `confidence` — **числа 0–100** (в ТЗ — доли 0..1; в реальных файлах проценты) → `numeric(5,2)` как есть.
Маппинг (Срез 3): таблица `table_structure[i]` с `label` «Бухгалтерский баланс» / «Отчет о финансовых результатах» →
`document` (BALANCE/INCOME; `locator = file:<имя json>;table:<i>` — так его формирует scan-gateway для текстового
PDF-пути, решение №1 спеки 3a; `pages:…` — только если ИС отдаст номера страниц) + `financial_statement_meta`
(ИНН, имя, units, report_date, okud — из `organization/inn/units/date.form_name`, с низкой уверенностью → `NEEDS_CHECK`) +
`financial_statement_column` (из шапки `rows[0]`, если она есть; `period_date` распарсить из «На 30 июня 2025 г», `is_current` =
первая; без шапки — колонки без дат, документ `NEEDS_CHECK`) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя;
только строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`).
Недостающие строки формы ядро достраивает на экране по справочнику редакции (§7, arm-core#80).
Сверки: ИНН/имя vs `legal_entity` → `document.counterparty_check`; дата vs период версии → `period_check`.
### §11.1 Очистка OCR-текста (перенос из старого ядра, Срез 3)
Источник — модуль старого ядра `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: модуль **проверен на реальном стенде ИС Сканирование, его код можно переносить** в нормализатор `scan-gateway`.
Это **единственное исключение** из правила «доменный код из `reference-old-arm` не переносить» (CLAUDE.md workspace).
Переносятся чистые функции очистки и разбора; обвязка старого ядра (`json_to_db_rows`, dataclass'ы `Meta/DbRow/DbValue`,
прямая запись в `financial_statement_*`) не переносится — результат ложится в `statementDocument` контракта v1.2
(`string_value`, `confidence`, `is_recognized`, `period_date`, `period_text`, `line_code_confidence`). Перенос — с тестами
на `fixtures/ocr/aep_*.json`; изменения правил ниже — только через этот параграф.
1. **Буквы-двойники рядом с цифрами** (`date_utils._normalize`, `_LOOKALIKES`): `О/о/O/o → 0`, `З/з → 3`,
`l/I/|/і → 1`, `б → 6`, `S/s → 5`, `Ч → 4` — заменяются **только если символ стоит вплотную к цифре**, в несколько
проходов («2ОО5» → «2005»); слова («Общество») не портятся. Если первая строка значения состоит только из цифр,
пробелов, `|`, точек и двоеточий — `|` считается разделителем клеток формы («2|0|2|4|» → «2 0 2 4»), а не единицей.
**Применяется только к датам** — дате отчёта (шапка) и подписям колонок значений; коды строк очищаются п. 6
(`clean_value`), суммы — п. 7 (`parse_amount`), ИНН — только цифры (п. 8). Замена двойников внутри кодов и сумм на
стенде не проверялась, поэтому не делается (решение №22 спеки 3a): код с латинской `O` вместо нуля не распознаётся
как целое и остаётся только в `raw/`.
2. **Даты из OCR-текста** (`date_utils._parse_date`): сначала текстовые формы по всей строке в порядке приоритета —
диапазон «с 01.01.2025 по 30.09.2025» (берётся конец), «30 сентября 2025 г./r./года» (латинская `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 месяцев → последний день последнего месяца. Не дата (название организации) → `None`. Разборщик общий с §12 п. 4
(Excel), там на вход приходят чистые строки. **Отличие от источника (решение №31 спеки 3a):** текст длиннее 200
символов не разбирается (`None`, двойники к нему не применяются) — регулярки квартала и полугодия дают
экспоненциальное/кубическое backtracking на длинной строке; настоящая дата занимает десятки символов.
3. **Раскладка колонок** (`_build_layout`, `_infer_layout`): шапка — строка с ячейкой, начинающейся на «код» (после
нормализации: нижний регистр, всё не-буквенно-цифровое → пробел); наименование — ячейка с «наименование», иначе ближайшая
заполненная слева от кода; колонки значений — правее кода, где в тексте есть четыре цифры года или текст начинается на
«на » / «за ». Повторная шапка внутри таблицы **перезаписывает** раскладку. **Без шапки** раскладка выводится по данным:
код — столбец с наибольшим числом значений из справочника кодов строк; наименование — столбец левее кода с наибольшим
числом текстовых ячеек; значения — столбцы правее кода, где суммы не реже мусора (полоса скана) или столбец пуст
(период без данных); шапка синтезируется (`Код` / `Наименование показателя`, тексты периодов пустые). Строки до первой
настоящей шапки разбираются по выведенной раскладке. Ни шапки, ни столбца кода — таблица пропускается с warning.
**Рулинг 06.10.2026 (вместо прежнего «без шапки → колонки без дат, `NEEDS_CHECK`»):** раскладку выводить, как в старом
ядре, но документ без шапки всё равно отдавать `needs_check` с причиной в `metrics.warnings` — колонки без `period_date`
аналитик подтверждает руками.
4. **Классы строк** (`_classify_row`): пустой код → строка пропускается; нечисловой код → заголовок (не данные);
наименование «Баланс» → `BALANCE`, начинается с «итого» → `TOTAL`, иначе обычная строка. Берутся только строки с
целым кодом (как в §11 выше); расшифровки без кода — OQ-36. Строки, отброшенные классификацией, **не восстанавливаются**
(поведение источника), но о них выдаётся предупреждение уровня таблицы (решение №29 спеки 3a): `строк с нечисловым
кодом пропущено: N` — код непустой и не целое число (`1150 :`); `строк без кода с суммами пропущено: N` — код пуст, но в
колонках значений есть разобранная сумма (в `aep_3.json` «БАЛАНС» и «Выручка» имеют пустой код, мусор `211(1 |` стоит в
колонке значения; как в Excel-пути, OQ-141). Строки с пустым кодом без сумм (заголовки групп, «в том числе:»)
предупреждения не дают. Любая такая таблица — `needs_check`; восстановление строк по наименованию — OQ-149.
**Склеенные строки** (OQ-156, решение владельца 08.10.2026): ИС может вернуть две соседние строки формы одной строкой —
в ячейке кода несколько кодов через перевод строки, суммы тоже через перевод строки (ББ РБ и Счастливочной за 1 кв.
2025: код `1500\n1700`, наименование `Итого по разделу V\n\nБАЛАНС`, суммы `917 775\n2 371 333`). До классификации
такая строка **разлепляется**: если в ячейке кода N ≥ 2 непустых частей, каждая — целое число, и в каждой ячейке
колонок значений либо пусто, либо ровно N непустых частей, получается N строк: i-й код, i-я часть каждой ячейки
значений (пустая ячейка — пусто во всех), наименование — i-я из непустых частей, если их тоже N, иначе всё
наименование у каждой; уверенность — исходной ячейки. Предупреждение уровня таблицы `строк с несколькими кодами
разлеплено: N` (документ `needs_check`: разбивку подтверждает аналитик). Число частей не сходится хотя бы в одной
ячейке — строка не разлепляется и отбрасывается как нечисловой код (предупреждение выше).
**Повтор кода** в таблице: остаётся первая строка, остальные не попадают в документ, выдаётся предупреждение «код N
повторяется: оставлена первая строка», документ `needs_check` (как §12 п. 5: в БД `financial_statement_line`
уникален по `(meta_id, line_code)`; старое ядро дубли не убирало — решение №15 спеки 3a). Известный недостаток: в
`aep_6` (ОФР) код 2410 повторён, первая строка пуста, а отброшенная содержит `(649)` — OQ-152 (сумма отброшенной
строки остаётся в `raw/`).
5. **Тип таблицы** (`_resolve_table_type`): по первой цифре кодов строк данных — больше `1xxx` → BALANCE, больше `2xxx` →
INCOME; это запасной путь, когда `label.form_name` зашумлён; при расхождении с `label` — `needs_check`.
6. **Код и наименование строки**: код очищается до букв, цифр, пробела и точки (`_clear_value`: переносы строк и
табуляции → пробел, остальное выбрасывается); наименование берётся **из справочника по коду** (`consts.codes_mapping`;
у нас — `api/rules/statement_lines.yaml` **scan-gateway** с редакциями форм `66n` / `fsbu4`, см. п. 10, а не
`rules/statement_lines.yaml` из §12 — в tabular-parser такого файла нет), а из OCR — только если кода нет в справочнике
выбранной редакции (`line_code_confidence < 100`, не выше 99). Код считается распознанным (`line_code_recognized`), если
`confidence == 100`, очищенный текст равен сырому и код есть в справочнике.
7. **Суммы** (`_parse_amount`): запятая → точка, пробелы убираются; содержимое скобок — отрицательное; из строки
удаляется всё, кроме цифр, `+`, `-`, `.`; если точек больше одной, **удаляются все** (запятая уже стала точкой:
`1.234,56` → `123456`, дробная часть не сохраняется, `is_recognized = false`); `Decimal`, иначе `None`. Прочерк, «–», «—», пусто — пустое значение (NULL, не 0). `string_value` — текст ячейки как распознан
(`\n`/`\t` → пробел), **только если сумма не разобрана** (`amount = null`) и ячейка не пуста и не прочерк (DBML:
«как распознано, если не число»); у разобранных сумм — `null` (решение №16 спеки 3a).
**Перенесено дословно (расхождения с желаемым — OQ-147, OQ-148):** закрывающая скобка, распознанная как `}` или `]`
(`(7744536}`), минус не ставит — сумма положительная, `is_recognized = false` (решение №12); длинное/среднее тире
«—»/«–» при `confidence = 100` даёт `is_recognized = false` (тире вырезается как «лишний» символ), дефис «-» — `true`
(решение №20). Скобки сами `is_recognized` не портят: признак считается до постановки минуса (решение №13; в источнике
любая сумма в скобках получала `false`).
**Отличия от источника под контракт** (решение №30 спеки 3a): сумма с **более чем двумя** знаками после запятой
(`1,234`, `0,125`) не укладывается в `Money` контракта (`numeric(18,2)`) — `amount = null`, `string_value` — текст
ячейки, `is_recognized = false` (иначе один артефакт OCR, например пробел тысяч как запятая, валил всю таблицу);
`confidence` ячейки и полей шапки приводится к диапазону 0–100, NaN → 0.
**`is_recognized`** значения = `confidence == 100` **и** разбор суммы не потребовал выбрасывать символы (сырой текст без
скобок равен очищенному). Для ячеек шапки: `is_recognized` = `confidence == 100` и в тексте не было мусора; если дата
разобралась — `period_text` нормализуется в «На ДД.ММ.ГГГГ», `period_date` — результат п. 2. Ячейки под колонкой
заголовка нет — пустое значение с `is_recognized = false`, чтобы не сдвигать столбцы.
8. **Шапка документа**: ИНН — только цифры из `inn.form_name`, «распознан», если `confidence == 100` и в сыром тексте не
было ничего, кроме цифр (предупреждение уровня файла: `ИНН не распознан` — в поле нет ни одной цифры; `ИНН распознан
неуверенно` — цифры извлечены, но `confidence` ≠ 100 или убран мусор); единицы — `_clear_value`, тот же критерий; дата отчёта — п. 2, «распознана», если
`confidence == 100` и дата разобралась. Нераспознанное → `needs_check` (как в §11).
9. **Ошибка разбора одной таблицы** (исключение в разборе `table_structure[i]`) не роняет файл: выдаётся предупреждение
уровня таблицы `BACKEND_PARSING_ERROR: <класс исключения>: <сообщение>`, остальные таблицы файла разбираются;
хотя бы один документ есть — конверт `needs_check` (статус не бывает `done`, пока есть предупреждение уровня таблицы),
ни одной таблицы не разобрано — `error` с кодом `RECOGNITION_FAILED` («ни одной таблицы не разобрано», причины — в
`metrics.warnings`). Ошибочный файл набора ответа при успешном соседе (например, `<job_id>_ofr.json` с
`INVALID_DOCUMENT`) документы успешных файлов не отменяет: ошибка попадает в `metrics.warnings` строкой
`<имя json>: <код ИС>: <сообщение>`, такой файл **понижает статус до `needs_check`** (`done` невозможен, решение №28 спеки 3a: потерянный документ, например ОФР,
не должен остаться незамеченным).
10. **Редакции форм** (`api/rules/statement_lines.yaml`): `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно)
и `fsbu4` (ФСБУ 4/2023, с 2025 года; **пока пуста** — состав кодов даёт владелец, OQ-150). Редакция документа выбирается
**по дате отчёта** шапки, если её нет — по **самой поздней `period_date`** колонок значений, если нет и её — по
**числу совпадений кодов** строк со словарями редакций (с предупреждением «редакция определена по кодам»); если и кодов
не хватает — редакция **не определена**: имена строк из OCR, предупреждение «редакция не определена», `needs_check`.
Имя строки — из словаря выбранной редакции по коду, из OCR — только если кода в нём нет. **Пустая** выбранная
редакция → имена строк из OCR, одно предупреждение «редакция `<id>` не заполнена», `needs_check`. Предупреждение «код N из редакции X»
(код есть в другой редакции, но не в выбранной) выдаётся только для **непустой** выбранной редакции — иначе каждая
строка формы 2025 года давала бы шум (решение №23 спеки 3a). Редакция формы в конверт не передаётся (OQ-145).
---
## §12. ББ и ОФР из Excel (tabular-parser, второй тип документа)
Источник: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР (эксель файлов)»), DOC-037734, DOC-221281; формат —
Приказ 66н / ФСБУ 4/2023, но на практике это и выгрузка из 1С («Регламентированный отчёт»), и шаблон банка
(`Копия Рекомендуемые формы расшифровок…`, лист `Отчетность` в `Анализ *.xlsx`), и вручную набитая таблица.
Принцип тот же, что в §1.0: **структура ищется по содержимому**, результат — тот же `statementDocument` контракта,
что и у `scan-gateway`, чтобы ядро маппило ББ/ОФР одинаково независимо от источника (`confidence = 100`,
`is_recognized = true` для табличного пути).
**Статус: реализовано (Срез 2b, tabular-parser feat/14, контракт v1.2; решения владельца 06.10.2026).** Модули —
`api/src/parser/statements/`, словари — `api/rules/statement_headers.yaml`, `statement_periods.yaml`,
`statement_checks.yaml`; ожидания по реальным файлам — `api/tests/statements_manifest.yaml` (06-test-data §4б).
1. **Классификация листов**: лист ОСВ → форма → продолжение → прочее. Форма: заголовок («Бухгалтерский баланс»,
«Отчет о финансовых результатах», «Отчет о прибылях и убытках») или ОКУД в **любой** строке до таблицы (у НПЗ он стоит
под титулом); титул «Бухгалтерская отчетность» — не тип, тип берёт ближайший следующий заголовок. Тип по ОКУД:
0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx (0710003 капитал, 0710004 ДДС) и пояснения («Пояснения к
бухгалтерскому балансу», «Расшифровка») → `DISCLOSURE`; при противоречии ОКУД и заголовка — тип по ОКУД и
предупреждение. Две формы на одном листе — по второму заголовку, в книге несколько листов — несколько `document`.
Продолжение: лист без заголовка формы, но с маркером «Форма … с.2», либо с подписью «Код» и периодами правее, либо с
колонкой кодов. ОСВ и формы в одной книге допустимы.
2. **Колонка кодов**: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx,
пятизначные — по первым четырём цифрам), кодов не меньше `min_codes` = 3; заголовок «Код/Коды» необязателен, числа вида
`1110.0` читаются как текст «1110». Колонки нет — документ без строк, `needs_check`, предупреждение «колонка кодов не
найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-139).
3. **Колонка наименований** — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не
наименование.
4. **Колонки-периоды**: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024»,
«За 9 месяцев 2025 г.», «За январь - сентябрь 2025 г.», «2024»), по порядку слева направо (`column_index` 0, 1, 2…);
`period_date` — дата или конец периода; `is_current` в контракте нет — ядро берёт самую позднюю. Заголовок в две строки
(«На» + «20__ г.», перенос внутри ячейки) склеивается; строка нумерации «1 2 3 4 5» пропускается. Колонки без периода
(«Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет.
5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`,
`is_recognized = false`. **Единицы не нормализуются**: `amount` как в ячейке, `header.units` — текст шапки; при «тыс.» и
≥ 10 % значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» (OQ-138,
решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются
(`ASSUMPTION(OQ-36)`, OQ-141); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`.
Пятизначные подстроки (11101, 12301…) отдаются как обычные строки.
6. **Шапка**: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12
цифр у метки «ИНН» (**ИНН аудитора не берётся**: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес,
единицы, `report_date_text`/`report_date` — из подзаголовка формы или тройки «число | месяц | год» у метки «Дата» (для
ОФР — конец периода). Шапка другого листа не используется: у ОФР НПЗ нет метки «ИНН» → `inn = null`. Найденное поле —
`*_confidence = 100`, не найденное — `null`.
7. **Самопроверка**: соотношения §7 по каждой колонке — данные `statement_checks.yaml`. **Жёсткие** (нарушение →
`needs_check`): ББ `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`,
`1600 = 1700`; ОФР `2100`, `2200`, `2300`. **Мягкие** (только предупреждение, `ASSUMPTION(OQ-12)`): `2400 = 2300 −
2410 + 2420` и варианты ФСБУ 4/2023, `2500`, «сумма пятизначных подстрок = родитель». Допуск ±1 **в единицах формы**,
отсутствующий код = 0, соотношение проверяется, только если присутствует его цель; правая часть считается дважды — как
записано и с членами `abs_terms` по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Результат —
`documents[].checks` (`relations_failed`, `soft_relations_failed`, `duplicate_line_codes`) и предупреждения.
Расхождение жёсткого соотношения больше ±1, но не больше 0,001 % от наибольшего по модулю числа соотношения — **округление итогов** (OQ-138, решение владельца 06.10.2026: единицам шапки верим): уходит в `soft_relations_failed` с предупреждением «сошлось в пределах округления», статус не меняется (`rounding_tolerance_percent` в словаре). Пример: книга НПЗ (`npz/2025-Q3`) — `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 при итогах ~17 млрд,
ОФР Унагранде Компани (стр. 1) — итог 2100 округлён до тысяч, ОФР Унагранде Групп 3 кв. 2025 — 2200 = −1 365 000 как
малая разность слагаемых ~393 млн; все `done`. Пятизначная подстрока относится к ближайшей четырёхзначной строке выше с
теми же тремя первыми цифрами (12310 под 1230). ББ Унагранде (стр. 1) — только актив, `1600 = 1700` не сходится,
`needs_check`.
8. **Расшифровки** (0710003/0710004, пояснения, лист «Примечания» шаблона) — `DISCLOSURE` с `locator` и шапкой, если
нашлась, без `columns`/`lines`; на статус не влияет (OQ-23). Книга только из таких листов — `done`.
9. **Склейка**: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок
сумм совпадает; строки добавляются в конец, `locator` перечисляет листы через запятую. **Страницы в разных файлах не
склеиваются** (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой
и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-140).
Продолжение без колонки кодов при непустом результате не теряется молча — `needs_check`.
10. **Статус**: документ `ok` ⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов
∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга — `done`, если `ok`
у всех документов, иначе `needs_check`; книга без листов ОСВ и форм — `INVALID_DOCUMENT`. Порядок документов: сначала
ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`.
11. **Отложено**: запасной путь по именам строк (OQ-139), нормализация единиц (OQ-138), признак продолжения в контракте и
склейка страниц из разных файлов в ядре (OQ-140, Срез 3), строки без кода (OQ-141), образцы ФСБУ (OQ-142), лист
продолжения с единственной кодовой строкой (OQ-143 реализовано: после листа формы хватает одного кода,
`min_codes_continuation` = 1; первая страница по-прежнему требует `min_codes` = 3).
Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel (маппинг форм —
Срез 3); экран «Проверка распознавания» для Excel-источника показывает не страницы, а таблицу — UI-решение вне парсера.