arm-ka-docs/context/05-domain-rules.md
Andrey Epifancev 2b094ffe0b docs(#116): §7, §9.3, §9.5, §11 по ревью спеки; OQ-148 закрыт, OQ-168; гайд фронта и checks.md
- §7: мягкое соотношение, сошедшееся точно, подтверждает неуверенные значения (Р-21); ОФР упрощённого набора
  2400 — мягкое (Р-22 / S-5); form_variant = null — полный набор; 1.x и мигрированные формы — §7 копией
  arm_statements один раз в document_issue, экран и подтверждение читают только его (K-2)
- §9.3: страховка согласованности document.status = done (K-24), «того же периода» — год и квартал (K-20),
  1.x после правки — общим кодом (K-21)
- §9.5: признак строки правки — row_index IS NULL (K-1); header_source, codes/column_index document_issue (K-3, K-13);
  LINES_UNRESOLVED — сумма или текст (K-16); PATCH: organization_name (K-12), confirmed: false, line_status только
  detail, обратного detail нет, дата колонки обязательна, акцепт сбрасывает только правка данных, подтверждение даты
  отчёта тем же значением (K-17, K-23); 409 LINE_EXISTS → LINE_CODE_TAKEN (K-18)
- §11: TABLE_PARSE_ERROR и SHEET_OTHER — уровня файла (K-7); столбец кодов ищется всегда, DISCLOSURE с кодами ББ/ОФР —
  TYPE_CONFLICT (K-10); номер колонки (K-6); содержимое строки (K-14); нумерация с двойниками и одним пропуском (S-13);
  ref Excel с кавычками (S-15); единицы по словарю и ОКЕИ (K-22); перечень кодов — данные, законные повторы (K-5, Р-20),
  обе редакции всегда; синонимы и «итого по разделу» в сверке имён (S-4); интерфейс функции статуса (K-14); ожидание на
  реальных сканах (K-4); бланк 66н с относительными подписями (K-17)
- 07-open-questions: OQ-148 закрыт (Р-22 / S-7), OQ-166 уточнён, OQ-168 — коды перечня без образца
- dev/checks.md: копия arm_statements, sync-statements (коды 0 / 10 / 20 / 2), statements-check в check-full,
  проверки docs (K-9); frontend-api-guide §5.6.1 по новым правилам

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 02:09:04 +04:00

224 KiB
Raw Blame History

05. Правила предметной области (выжимка из ТЗ, имена — v5.19)

Каждый раздел: источник в ТЗ → правило → где в модели. Где ТЗ молчит или противоречит — пометка [открыто] и ссылка на 07-open-questions.md.


§1. Парсинг ОСВ (tabular-parser + маппинг в ядре)

Источник: «Парсинг ОСВ» (DOC-260010), FNR01 (DOC-037740), реальные файлы fixtures/osv/. Реализация — tabular-parser, Срез 2a (change slice-2a-osv-parser); что именно сделано и как выглядит результат — §1.6.

1.0 Главный принцип: структура определяется динамически, не по координатам

ОСВ приходят из разных конфигураций 1С (8.2/8.3, УПП, ERP, БП 3.0), из других учётных систем и после ручной правки бухгалтером. Ни одна буква колонки, ни один номер строки, ни одно имя листа не являются контрактом. Фикстуры в fixtures/osv/ — это примеры двух организаций, а не спецификация формата. Парсер обязан:

  1. Найти лист(ы) с ОСВ по содержимому: ячейка в первых 30 строках листа (SHEET_MARKER_ROWS: шапка ведомости всегда над таблицей), матчащая оборотно[- ]сальдовая ведомость (без учёта регистра, «ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколько document. Листы без признаков ОСВ/ББ/ОФР — пропускать с warning, не падать.
  2. Найти заголовок таблицы по содержимому: строка(и), где встречаются одновременно «Сальдо на начало…», «Оборот(ы) за период», «Сальдо на конец…» (с вариантами: «Нач. сальдо», «Обороты», «Кон. сальдо», «Дебет/Кредит», «Дт/Кт», «Д-т/К-т»). Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на начало» (иначе заголовок не найден → INVALID_FORMAT, §1.6). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет» в первой колонке, так и строкой «Субконто», так и перечислением через запятую (вариант 2).
  3. Определить колонки сумм по тексту, а не по позиции: для каждой из трёх групп («начало», «оборот», «конец») найти подзаголовки «Дебет»/«Кредит» в следующей строке под ней (с учётом merged-диапазонов). Допустимы ОСВ без «начала» (только обороты и конец), с одной свёрнутой колонкой сальдо (знак = сторона), с дополнительными колонками («Количество», «Валюта», «ИНН», «КПП», «Код»). Неизвестные колонки — сохранять в extra строки, не терять.
  4. Определить колонку текста строки — первая непустая слева от сумм (обычно A, но не обязательно); ИНН — колонка с заголовком ИНН или колонка, где ≥ 80 % непустых значений — ровно 10/12 цифр (число из 9/11 цифр при этом выборе нулём не дополняется, иначе числовой КПП сошёл бы за ИНН — R-24).
  5. Определить иерархию строк по outline level Excel; если уровней нет (файл пересохранён, csv, другая система) — по отступу (ведущие пробелы / alignment.indent), по жирности счёта, по шаблону кода счёта и, в крайнем случае, по факту «сумма детей = родитель». Стратегии применяются каскадом, выбранная стратегия пишется в metrics.warnings как sheet=<имя листа> hierarchy=<outline|indent|account_pattern|sums|trivial>. Строка «Итого» в каскаде не участвует (всегда уровень 0); при меньше чем двух строках данных (пустая ОСВ) — trivial.
  6. Шапку документа (организация, счёт, период, «выводимые данные») искать регулярками по всем ячейкам выше заголовка таблицы; отсутствие любого поля — не ошибка, а null + warning (ядро попросит аналитика уточнить при проверке).
  7. Самопроверка результата (контрольные суммы §1.4) — единственный способ понять, что структура угадана верно. Если тождества не сходятся — не «подгонять», а вернуть needs_check с диагностикой, какой шаг не уверен.
  8. Правила парсера — данные, а не код: словари синонимов заголовков, группировок, периодов лежат в tabular-parser/api/rules/*.yaml (osv_headers, osv_groups, periods, data_types) и расширяются без релиза. Новый формат ОСВ = новая фикстура + при необходимости новый синоним, а не новая ветка if.

Тесты парсера строятся по принципу «свойств»: для любого входного файла результат удовлетворяет инвариантам §1.4, число строк-контрагентов ≥ 0, Итого = корень. Фикстуры — регрессия, не оракул формата.

1.1 Шапка — два варианта 1С (как примеры, не исчерпывающий список)

Вариант 1 (все наши фикстуры):

r1  <Наименование организации>                       ← владелец ОСВ (сверить с legal_entity → document.counterparty_check)
r2  Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г.   ← счёт после «по счету», период после «за»
r4  Выводимые данные: БУ (данные бухгалтерского учета)              ← data_type шапки
r7  Параметры: | Валюта БУ, НУ: RUB   (или «руб.»)
r9  Счет          | Сальдо на начало периода | Обороты за период | Сальдо на конец периода
r10 Контрагенты   | [ИНН] | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r11 Договоры                                                        ← может отсутствовать

Группировки — отдельные строки первой колонки блока заголовка (Счет, Подразделение, Контрагенты, Договоры), порядок строк = порядок вложенности. Ячейка ИНН в той же строке, что Контрагенты → has_inn = true.

Вариант 2 (встречается у других клиентов):

Оборотно-сальдовая ведомость по счету 60
Период: 1 квартал 2026 г.
Детализация по субсчетам, субконто: Контрагенты, Договоры     ← группировки через запятую, первая неявно ACCOUNT
Выводимые данные: сумма
Субконто | Сальдо на начало периода | Оборот за период | Сальдо на конец периода
         | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит

Реквизит субконто в списке группировок (OQ-162, решение владельца 13.10.2026). Элемент вида <группировка>.ИНН (вариант 2 — «Детализация по субсчетам, субконто: Контрагенты, Контрагенты.ИНН»; вариант 1 — строка Контрагенты.ИНН в блоке заголовка под строкой Контрагенты; суффикс после последней точки — по словарю service_columns.inn, префикс — имя группировки COUNTERPARTY этой же шапки; реквизит может стоять до или после владельца) — не уровень аналитики, а реквизит этой группировки. В таблице 1С выводит его отдельной строкой под контрагентом (уровень глубже, те же суммы, ИНН часто числом); элемент может стоять и перед владельцем («субконто: Контрагенты.ИНН, Контрагенты» / строка Контрагенты.ИНН выше строки Контрагенты в варианте 1): тогда 1С выводит ИНН над наименованием. В group_schema такой элемент не попадает, у группировки-владельца has_inn = true; элемент — подсказка для строк ИНН (§1.3, OQ-163): задаёт направление и снимает проверку контрольной суммы ИНН. Строки ИНН склеиваются с наименованием по §1.3.

Нормализация значений шапки в типы:

Текст в шапке group_type
Счет, Счета, Субконто (первая) ACCOUNT
Подразделение, Подразделения DEPARTMENT
Контрагент, Контрагенты COUNTERPARTY
Договор, Договоры, Договоры контрагентов CONTRACT
прочее (Валюта, Номенклатура, Статьи…) OTHER

Результат шапки → recognition_job.group_schema (по листу): {"locator":"sheet:Лист_1","account":"60", "period_text":"1 квартал 2025 г.","data_type":"BU","groups":[{"type":"ACCOUNT"},{"type":"COUNTERPARTY","has_inn":true}, {"type":"CONTRACT"}]} и → document(document_type=OSV, osv_account_code, period_year, period_quarter, is_cumulative, locator).

Шапка без меток и без строки организации (реализовано, PR tabular-parser #13, OQ-134; ОСВ 63 Новоросметалл). Период может стоять отдельной ячейкой («Бухгалтерский | 9 месяцев 2025 г.»: periods.yaml — standalone, data_types.yaml — standalone_header), организация — хвостом названия ведомости, а колонки счёта — подписями «Счет: Код / Наименование» (osv_headers.yaml — column_captions: «Код» и «Наименование» под группой «Счет» не группировки). Код счёта берётся из первой строки-счёта таблицы, если «по счету …» в шапке нет, — с предупреждением «код счёта … из строки таблицы». Текст строки может стоять правее колонки текста: уровень задаёт колонка, в которой он найден.

1.2 Колонки сумм

Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту (§1.0 п. 3). Всё правее последней колонки «Кредит» — не часть таблицы (в фикстурах там встречаются посторонние числа): игнорируется, в суммы и в extra не попадает. В rows[].extra (контракт v1.1) идут только колонки внутри таблицы, не сопоставленные с шестью суммами и ИНН (ключ — заголовок колонки: «КПП», «Количество»…). Числа могут быть numeric или текстом («1 234 567,89», «(1 234)», «-», «—», неразрывные пробелы) — нормализовать в Decimal; пустая ячейка/прочерк = NULL (не 0). Отрицательные возможны.

1.3 Строки

Для каждой строки табличной части: row_number (исходный номер), level (outline level Excel, в xls — rowinfo_map; без группировок — каскад §1.0 п. 5), row_value (текст первой колонки; целое число из ячейки — без дробной части: xls хранит числа как float, и 2370012036.0 — артефакт чтения, а не значение файла), шесть сумм, ИНН (только из колонки ИНН; в ячейке оставляются цифры, результат только при 10 или 12 цифрах — «ИНН 352819549855» → 352819549855, иначе NULL; число из 9/11 цифр — ИНН региона 01–09 с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем строка ИНН рядом с наименованием (ниже, §1.3 «Строки ИНН», OQ-163), затем конец текста названия («Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Строки ИНН (OQ-163, решение владельца 13.10.2026; заменяет правила OQ-130 и OQ-162). Строка, подпись которой целиком — ИНН (inn_only_row: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП»; число из 9/11 цифр дополняется нулём слева — R-24). Без подсказки <группировка>.ИНН в шапке ИНН строки должен пройти контрольную сумму ИНН (алгоритм ФНС); строка из 10/12 цифр с неверной суммой рядом с наименованием остаётся как есть (обычная строка), предупреждение «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», документ — needs_check (решение H-1: синтетика s28). С подсказкой сумма не проверяется, пустая строка-ребёнок наименования — «ИНН не указан» (рулинг R-1 #32), непустая не-ИНН на этом месте — «ИНН не распознан» (такой ребёнок бывает только у наименования, которое по схеме контрагент; у подразделения единственный ребёнок — наименование). Восемь раскладок — ИНН над / под наименованием × в дереве / не в дереве × с суммами / без:

Положение строки ИНН Пара
у неё есть дети (над, в дереве) единственный ребёнок-наименование, он поднимается на её место; несколько детей — без пары. Если подсказки в шапке нет, в group_schema больше одной группировки и в листе нет строк глубже схемы (R-3), строка — наименование по схеме (контрагент, названный ИНН)
ребёнок наименования (под, в дереве) родитель; её дети — к нему; несколько строк ИНН у одного наименования — без пары. Родитель — пара только при подсказке «под» или если он по схеме контрагент (R-2); строка ИНН с детьми под контрагентом остаётся у него и при подсказке «над» (с предупреждением о противоречии)
сосед того же родителя (не в дереве) с суммами — сосед с теми же суммами; без сумм (или совпали оба) — сосед по направлению листа

Суммы строки ИНН, если есть, должны совпасть с суммами пары по всем шести колонкам (пусто = 0). Направление листа — подсказка шапки (порядок элемента <группировка>.ИНН), а без неё — сведения пар соседей по суммам и блоков детей одного родителя вместе (начинается строкой ИНН — над, кончается — под, начинается и кончается — блок сломан); противоречие сведений или их отсутствие — направление неизвестно, строки без сумм без пары. Две строки ИНН на одно наименование — обе без пары. ИНН колонки, отличный от ИНН строки, остаётся, документ — needs_check. Склеенная строка в rows не попадает, предупреждение «строка N: ИНН … отнесён к контрагенту в строке M»; без пары — строка в rows с group_type = OTHER (в свод не попадает), предупреждение с причиной, документ — needs_check. Противоречие подсказке шапки (решение H-2). Если пара найдена, но фактическая раскладка противоречит направлению подсказки <группировка>.ИНН (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него; подсказка «под», а строка ИНН — родитель наименования или сосед выше него), строка всё равно склеивается, но добавляется отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка («…») указывает <над/под> — проверьте», документ — needs_check. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт; для соседей без сумм направление берётся из подсказки, конфликта нет. Реализовано (tabular-parser #35, модуль inn_rows.py; синтетика s18, s20–s28).

Из текста ИНН отрезается всегда: counterparty_name без ИНН и разделителя, row_value — исходный текст; только для строк COUNTERPARTY, договоры и прочее не трогаются; правила — osv_headers.yaml (counterparty_inn_suffix, inn_only_row, inn_kpp_cell); реализовано, OQ-100, OQ-163 (строки ИНН; ранее OQ-130, tabular-parser #9, PR #15; синтетика s17–s19), data_type (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки).

Строки «Вал.», «Кол.» и трёхбуквенный код валюты («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: нет признаков ОСВ или формы; листы форм теперь разбираются, §11.2). Другие предупреждения: sheet=<имя> hierarchy=<стратегия> (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение «xlsb: outline level недоступен»). С контракта v2.0 (change unified-statement-normalization, §11.9) у каждого osvDocument есть status (done, когда контрольные суммы §1.4 листа сошлись, иначе needs_check — те же условия, что выше, но по листу) и warnings[] — перечисленные предупреждения листа структурами {code: "OSV_…", severity: "info", message}; строки sheet=<имя> … в metrics.warnings больше не пишутся (предупреждения уровня книги — metrics.warnings структурами, например FILE_TYPE_MISMATCH). Разбор ОСВ не меняется.

Лимиты: PARSE_MAX_BYTES (по умолчанию 50 МиБ) и PARSE_TIMEOUT_SECONDS (120 с; кооперативный — проверка между листами, в циклах по листу и между стратегиями; чтение книги библиотекой он не прерывает). Поэтому до библиотеки чтения действуют лимиты чтения (R-23, R-25) → UNSUPPORTED_FORMAT: распакованный zip (xlsx/xlsb) ≤ 10 × PARSE_MAX_BYTES, суммарная площадь объединённых ячеек книги ≤ 200 000 (реальный максимум 10 090; xlsx — потоковый expat-скан всех XML-частей до openpyxl, после чтения — только диапазоны, которые индекс листа раскладывает по ячейкам). Объём самих ячеек, sharedStrings/стили и XML-бомбы (defusedxml) не ограничены — [открыто] OQ-108. Любой реальный файл разбирается быстрее 5 с (максимум ≈ 0,6 с).

Словари: osv_headers.yaml (признаки листов, группы сумм, «Дебет»/«Кредит», колонка ИНН, «Итого», метки шапки), osv_groups.yaml (группировка → group_type), periods.yaml (период → квартал и нарастающий итог), data_types.yaml (БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют). Каждая запись словаря обязана срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия).


§2. Приведение периодов (DOC-259006; Срез 4, решения владельца 06.10.2026: OQ-03, OQ-19)

Комплект (ББ + ОФР + ОСВ) за один период нарастающим итогом. Если ОСВ квартальные (is_cumulative = false), при построении сводной ОСВ (§3) строится расчётное представление (не хранится, DECISION-5). Реализация — domain/consolidation_periods.py:select_sources (выбор документов) и domain/consolidated_osv.py:build (склейка).

Выбор источников на пару (ЮЛ, счёт документа) среди документов, привязанных к версии (§3 п. 1); n — квартал версии:

  1. есть привязанный документ с is_cumulative = true и period_quarter = n — он один, строки берутся как есть;
  2. иначе нужны привязанные квартальные документы за каждый квартал Q1..Qn; любого нет — пробел (Gap(missing_quarters)), строк пары в своде нет, пробел показывается в сводке (gaps[]) и в листе «Пробелы» экспорта;
  3. нарастающий и квартальные одновременно — берётся нарастающий (он полнее по определению);
  4. Q1-эквивалентность ОСВ (R-14, design №81; OQ-124): документ ОСВ за Q1 равнозначен и нарастающему, и квартальному (парсер помечает ОСВ за Q1 как нарастающую: период с начала года = Q1) — в версиях Q2+ он служит квартальным источником Q1. ББ/ОФР за Q1 версиям Q2–Q4 не подходят (R-14a: баланс на конец Q1).

Склейка квартальных строк — ключ (account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn)); договоры (group_type = CONTRACT) в свод не входят — строка COUNTERPARTY уже содержит их сумму:

opening_* = opening_* строки Q1
turnover_* = Σ turnover_* по кварталам Q1..Qn
closing_* = closing_* строки Qn

Отсутствующая в квартале строка ключа = нули; None в ячейке = 0; две строки с одним ключом внутри квартала суммируются (design №1); entry_ids строки свода перечисляет все источники. Нарастающий документ отдаётся строка в строку (OQ-125: различие с квартальным Q1 версии Q1 — только на дублях ключа и None). Строки свода сортируются: имя ЮЛ, счёт (натуральный порядок: 9 < 60 < 60.2 < 60.10), номер строки.


§3. Сводная ОСВ и сопоставление контрагентов (DOC-221278, FNR01 п. 9)

Вход: consolidation_version_id. Периметр — consolidation_member версии (не текущий related_group_member!).

  1. Для каждой компании периметра: document типа OSV, входящие в consolidation_document версии (явная привязка, а не «последние подтверждённые»). Документ подходит версии, если ЮЛ — член периметра, period_year = год версии, и: нарастающий (ББ/ОФР — всегда) — period_quarter = квартал версии; квартальный — period_quarter ≤ квартал версии; ОСВ за Q1 подходит версии любого квартала года (§2 п. 4, OQ-124). Привязываются только CONFIRMED документы незаменённых файлов. Точки привязки: подтверждение документа (ко всем версиям «в работе»), создание версии и добавление ЮЛ в периметр (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним confirmed_at); при привязке документ с тем же ключом у того же ЮЛ заменяется. Точки отвязки: автозамена файла (§9.5 п. 3), удаление ЮЛ из периметра. Версии 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_*).

Самопроверка в сервисах (общий код, §11.8; change unified-statement-normalization, контракт v2.0). Оба сервиса (tabular-parser и scan-gateway) считают §7 одним кодом arm_statements по одному словарю statement_checks.yaml и кладут результат в documents[].checks (relations_failed, soft_relations_failed; у нарушения — column_index, текст соотношения, codes — коды цели и членов, expected, actual). Ядро хранит checks как пришли (document_issue) и пересчитывает тем же кодом только после правок аналитика; своей копии словаря у ядра нет. Правила счёта:

  • Жёсткие (нарушение → needs_check): ББ — 1100, 1200 (+1215), 1300, 1400, 1500, 1600 = 1100 + 1200, 1700 = 1300 + 1400 + 1500, 1600 = 1700; ОФР — 2100, 2200, 2300. Мягкие (info, ASSUMPTION(OQ-12)): 2400 = 2300 − 2410 + 2420 и варианты ФСБУ 4/2023 (2400 = 2300 + 2410 + 2460, 2410 = 2411 + 2412), 2500 = 2400 + 2510 + 2520 + 2530, «сумма пятизначных подстрок = родитель» (по колонке — только если хотя бы одна подстрока родителя в ней непуста: amount не null, ноль — значение; AURORA-1527). Мягкое соотношение на статус и подсветку не влияет, но сошедшееся точно (расхождение в пределах допуска ±1, без «округления») подтверждает неуверенные значения своих кодов (Р-21, §11.9): так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки.
  • Значение кода в колонке — сумма по всем строкам ok с этим кодом (повтор кода — законный случай, Р-4); строки detail, unreadable, missing в соотношения не входят; нарушение относится ко всем строкам его кодов. duplicate_line_codes не выдаётся.
  • Посчитанное соотношение. Соотношение считается, если непуста цель или хотя бы один член (есть строка ok с кодом и непустой суммой); пусто всё — пропускается. Пустая цель при непустых членах — нарушение («БАЛАНС» без кода ушёл в detail — 1600 пуст, 1600 = 1100 + 1200 подсвечивает родителя); непустая цель при пустых членах — тоже нарушение (лист только с активом: 1600 = 1700 при отсутствующем 1700 — needs_check). До v2.0 соотношение с отсутствующей целью пропускалось у обоих сервисов и в ядре — поведение изменено; посчитанное соотношение из одних null ничего не подтверждает.
  • Допуск и округление: жёсткое соотношение не сошлось, если расхождение больше ±1 в единицах формы и больше 0,001 % наибольшего по модулю числа соотношения — только тогда оно в relations_failed; расхождение в пределах округления (OQ-138) и мягкие соотношения — в soft_relations_failed, это info, на статус и подсветку не влияют. Соотношения считаются по отображаемым значениям (§11.2, числовой формат Excel).
  • Члены по модулю (abs_terms: 2120, 2210, 2220, 2330, 2350, 2410). Правая часть считается дважды — как записано и с этими членами по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Такое соотношение сходится при любом знаке члена и потому подтверждает величину, но не знак: знак сомнительной ячейки подтверждается конвенцией колонки (§11.9).
  • Упрощённый набор. Набор выбирается по form_variant документа (§11.7): у упрощённой формы нет подытогов 1100 / 1200 (ББ) и 2100 / 2200 / 2300 (ОФР), свои равенства по бланку упрощённой формы (приказ 66н, прил. 5; ASSUMPTION до первого образца в fixtures): ББ — 1600 = 1150 + 1170 + 1210 + 1230 + 1250 (жёсткое), 1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550 (жёсткое), 1600 = 1700 (жёсткое); ОФР — 2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410 (мягкое, как 2400 полного набора: знак 2410 не определён, OQ-12; abs_terms те же; решение владельца 18.10.2026, Р-22 / S-5). Оба набора — в одном словаре statement_checks.yaml. Без упрощённого набора полный дал бы нарушения «пустой цели» на каждой упрощённой форме. form_variant = null (результат 1.x, документ до v5.19) — полный набор.
  • Родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из первых четырёх цифр (12310 под 1230); то же правило ядро отдаёт экрану как parent_code.
  • Межформенная 1370(отчётная дата) − 1370(начало года) = 2400 сервисами не считается — только ядром (ниже).

Ядро. При загрузке результата v2.0 ядро §7 не пересчитывает: checks, needs_review и статус документа берутся из результата (§9.3, §9.5 п. 2). После правки аналитика (PATCH …/statement) ядро пересчитывает §7 копией arm_statements по текущим суммам (financial_statement_value.amount после правок) — с теми же правилами, что в сервисах. Результат 1.x (окно выкладки) и формы, существовавшие до миграции v5.19, checks не несут: ядро один раз считает §7 копией arm_statements (form_variant = null — полный набор) — при приёме 1.x и в миграции — и пишет нарушения в document_issue (source = core, relation / soft); условие D-6 «проверки сошлись» для 1.x берётся из этого расчёта. Экран (GET …/statement) и подтверждение (CHECKS_NOT_PASSED) читают нарушения только из document_issue — у любой формы, какой бы версией контракта она ни пришла. Межформенная 1370(отчётная дата) − 1370(начало года) = 2400 — мягкая, считается ядром по агрегату формы по коду (§9.5 п. 2) и не блокирует подтверждение: пара — другой документ формы того же ЮЛ с тем же годом и кварталом периода (файл не SUPERSEDED; подтверждённый предпочтительнее, далее самый поздний created_at); считается, только если найдены пара и обе колонки. Результат в ответе API: failed (жёсткие), soft_failed (мягкие, в том числе «в пределах округления»), cross_form, accepted; у нарушения — codes и line_ids. Показ пятизначных подстрок (AURORA-1527, решение владельца 16.10.2026): ядро отдаёт у каждой строки формы вычисляемое parent_code (правило выше); экран показывает подстроки под родителем, пустые подстроки свёрнуты за переключателем. Акцепт — по жёстким нарушениям, с комментарием (1–2000 символов), POST /documents/{id}/statement/checks/accept; без нарушений — 409 NOTHING_TO_ACCEPT. Акцепт сбрасывается любой правкой формы (с v2.0 — правкой данных: подтверждение ячейки или кода без изменения его не сбрасывает, §9.5 п. 9). Аудит акцепта — RECOGNIZED_DATA_EDIT. Формулы одни для редакций 66n и fsbu4 (ASSUMPTION(OQ-12, OQ-146)); редакцию ядро определяет по дате периода документа и отдаёт в ответе (edition: 66n при дате ≤ 2024-12-31, fsbu4 — позже, null без даты; OQ-145); тем же правилом сервисы разрешают ничью между редакциями упорядоченного перечня кодов (§11.6, §11.9).

Полный состав формы (arm-core#80, решение владельца 09.10.2026). Экран проверки показывает все строки формы редакции документа (edition): справочник arm-core/api/rules/statement_lines.yaml (66н и ФСБУ 4/2023, ББ и ОФР, из шаблонов банка fixtures/statements-xlsx/templates/). Строки и ячейки, которых нет в источнике, ядро достраивает в ответе (id = null, origin = template) и в БД не пишет; первая непустая правка создаёт строку (признак «не из источника» — до v5.19 line_code_original_value = NULL, с v5.19 — row_index = NULL) и ячейку как правку аналитика (аудит RECOGNIZED_DATA_EDIT). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом. С v2.0 (change unified-statement-normalization): справочник ядра — только словарь имён (код → наименование по редакциям; у кода может быть несколько имён — синонимы и законные повторы кода в бланке, Р-20: 1220 «Налог на добавленную стоимость по приобретенным ценностям» и «НДС, начисленный по отгрузке», 1520 «Кредиторская задолженность» и «Обязательство по аренде»; загрузчик повтор кода не запрещает, сверка наименования — с любым именем кода, шаблонная строка берёт первое); порядок и состав редакций ядро берёт из копии упорядоченного перечня кодов arm_statements (данные — contracts/statement-code-orders.yaml; sync-statements --check сверяет, что все коды словаря имён есть в перечне); код, у которого есть хотя бы одна строка ok из файла, шаблоном не достраивается; строки unreadable / missing шаблонной строкой молча не заменяются — показываются отдельно со статусом. Наименование строки из источника хранится как распознано; экран показывает справочное имя по коду, файловое — рядом (Р-3, AURORA-1406 / 1522).

Когда: в сервисе при распознавании; в ядре после «Сохранить» правок. Непройдено → document.status = NEEDS_CHECK; пройдено/акцептовано + нажато «Подтвердить» → CONFIRMED + audit_event(DOCUMENT_CONFIRM). Отрицательные значения в формах — в скобках (2100) → -2100. Подсветка ячеек — только по needs_review (§11.9): уверенность, is_recognized и нарушения §7 сведены в него сервисом; «подтверждение проверкой» — сошедшееся посчитанное жёсткое соотношение снимает подсветку с сомнительной ячейки (Р-7).


§8. Корректировки, гудвилл, КФО, пороги

Корректировка (DOC-227278, ТЗ 6.8): запись на пару (правило, компания 1, компания 2) и форму (ББ/ОФР); debit_line_code / debit_adjustment, credit_line_code / credit_adjustment; распределение суммы одной стороны по нескольким строкам — дочерние записи (parent_correction_id, глубина 1). Контроль: Σ debit_adjustment ≈ Σ credit_adjustment (±5 %), иначе ВГО распределено не полностью. Комментарий обязателен. История — audit_event(CORRECTION_*), version_no++.

Гудвилл (DOC-259025, ТЗ 6.7): по каждой паре ГО→ДО периметра: FV = Σ closing_debit (58.01, контрагент ДО); UK = 1310 ББ ДО на отчётную дату (FNR03: Кт 80 + Кт 83, при отсутствии — 1310); share — ручной ввод; participation = (1310 [+ 1350?]) × share / 100; goodwill = FV − participation (может быть < 0); include_in_kfo + комментарий обязателен. Положительный итог — строка GOODWILL в разделе I КФО.

КФО (ТЗ 6.4, Прил. 10): по каждому коду строки ББ/ОФР: sum_amount = Σ по компаниям (значения отчётной колонки ББ / ОФР за период, в тыс. руб.) → eliminated_auto (по §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)
загрузка файла в консолидацию (POST /consolidations/{id}/legal-entities/{le}/files), принят ≥ 1 файл INITIATED → DOCUMENTS_PROCESSING у каждой версии консолидации «в работе», где ЮЛ в периметре загрузивший
маппинг создал документ NEEDS_CHECK, подходящий версии DOCUMENTS_PROCESSING → DATA_CORRECTION stored_file.uploaded_by (нет — переход пропускается, лог)
подтверждение документа, привязанного к версии INITIATED / DOCUMENTS_PROCESSING → DATA_CORRECTION подтвердивший
POST …/status {CANCELLED, comment} (комментарий обязателен, 422 COMMENT_REQUIRED) любой «в работе» → CANCELLED пользователь с блокировкой
POST …/status {INITIATED} DOCUMENTS_PROCESSING / DATA_CORRECTION → INITIATED (привязки сохраняются) пользователь с блокировкой

«В работе» = INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION. CANCELLED снимает активную блокировку и is_in_work. Загрузка и подтверждение блокировкой не ограничиваются; запрет загрузки/подтверждения после DATA_CORRECTION (ТЗ 03_9 A1) — Срез 7 (OQ-118). Условие «все обязательные документы CONFIRMED» для DATA_CORRECTION → CONSOLIDATION_* — Срез 7.

9.2 Файл (file_status): UPLOADED → PROCESSING → PROCESSED | ERROR; любой → SUPERSEDED (замена файлом целиком,

superseded_by_file_id, version_no + 1). Статус меняют только по текущему заданию файла (current_recognition_job_id): PROCESSING — ack сервиса (или результат done/needs_check, если ack потерян); ERROR — задание error/timeout/dlq либо ошибка маппинга; PROCESSED — маппинг завершён; SUPERSEDED — автозамена. PROCESSED и SUPERSEDED событиями обмена не перезаписываются. Физическое удаление: ANL-020 запрещает его после INITIATED; в Срезе 2c консолидаций ещё нет, поэтому удаление разрешено, кроме: (а) файла с документом, подтверждённым пользователем; (б) файла с автоподтверждённым документом, привязанным к версии вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION; (в) файла, заменившего другие (§9.5); поведение после появления консолидаций — OQ-110.

9.3 Документ (document_status): создаётся ядром при маппинге результата, по одному на лист ОСВ:

RECOGNIZED — задание done, checks парсера без нарушений, counterparty_check ≠ MISMATCH и нет предупреждений маппинга этого листа (повтор счёта в файле, строки без родителя, нарастающая по данным Q1); иначе NEEDS_CHECK (OQ-05 закрыт: RECOGNIZED нужен). Автоподтверждение (OQ-157, решение владельца 08.10.2026; ТЗ 03_9 п. 4, 10_10): документ, получивший при маппинге RECOGNIZED, в той же транзакции становится CONFIRMED от имени системы (confirmed_by NULL, audit_event(DOCUMENT_CONFIRM) с user_id NULL и описанием «автоподтверждение: без замечаний»), привязывается к версиям и двигает их, как при ручном подтверждении (§3 п. 1); переход версии — от stored_file.uploaded_by с комментарием «автоподтверждение документа …». Форма ББ/ОФР — при тех же условиях, что ручное подтверждение (п. 4: период определён, нет непройденных жёстких проверок §7; мягкие не учитываются); RECOGNIZED формы после правки или акцепта — только вручную. NEEDS_CHECK — только вручную. DISCLOSURE/OTHER (без данных) подтверждаются автоматически тоже (в версии не привязываются; удаление — по §9.5 п. 5). «Подтвердить данные» → CONFIRMED; ERROR — зарезервирован (в Срезе 2c ядро документ в ERROR не создаёт: файл без разбираемой ОСВ получает ERROR на уровне файла). counterparty_check: ИНН шапки равен ИНН владельца файла → MATCH; ИНН нет — нормализованное имя шапки совпало с именем или алиасом владельца → MATCH; иначе MISMATCH; пустая шапка → UNKNOWN. period_check — UNKNOWN: подходящесть документа версии — функция (документ, версия), а один документ входит в несколько версий (§3 п. 1, Срез 4; OQ-120).

Документ формы ББ/ОФР (Срез 3b). RECOGNIZED — только если одновременно: задание done; нет непройденных неакцептованных жёстких проверок §7; counterparty_check ≠ MISMATCH; определены period_year и period_quarter; у каждого значения либо is_modified, либо is_recognized не false и confidence равна 100 или пуста; нет предупреждений маппинга по документу (повтор кода, значение вне колонок, пропущенная вторая форма); есть хотя бы одна строка. Иначе NEEDS_CHECK. Статус пересчитывается при маппинге, после каждой правки формы и после акцепта (кроме CONFIRMED); при пересчёте после правки/акцепта предупреждения маппинга не учитываются — на уровне документа они не хранятся (metrics.mapping_warnings — на уровне файла). Расшифровки (DISCLOSURE) и прочие (OTHER) — один документ на тип на файл без данных, статус RECOGNIZED при маппинге, затем автоподтверждается → CONFIRMED (OQ-23, OQ-157). Документ ББ/ОФР, созданный до Среза 3b и не имеющий данных формы (financial_statement_meta нет), подтверждается без условий п. 9.5.4, а GET/PATCH …/statement по нему — 409 DOCUMENT_NOT_STATEMENT.

Документ формы при приёме контракта v2.0 (change unified-statement-normalization, Р-19; правило выше остаётся для результатов 1.x на окне выкладки). RECOGNIZED — только если одновременно: document.status = done из результата и он согласован с содержимым документа (нет строк unreadable / missing с суммой или текстом в значениях, нет строк, значений и полей шапки с needs_review, нет checks.relations_failed и предупреждений документа уровня error; иначе ядро считает документ needs_check и пишет RESULT_STATUS_INCONSISTENT info — страховка от ошибки сервиса); в metrics.warnings файла нет предупреждений уровня error (потерянный json, таблица ИС или исключение разбора таблицы — решение № 28 среза 3a; needs_check соседнего документа и статус конверта не учитываются); counterparty_check ≠ MISMATCH; определены period_year и period_quarter (дата — конец квартала); у каждой строки ok с неуверенным кодом (line_code_confidence < 100) распознанное наименование совпало со справочным по коду (Р-16, правило совпадения — §11.9); в файле нет второй формы того же типа и периода (Р-18; «того же периода» — та же пара period_year, period_quarter, формы без квартала не считаются). Иначе NEEDS_CHECK. Проверки §7, needs_review и предупреждения сервиса при загрузке не пересчитываются. После правки или акцепта статус пересчитывается общим кодом (§11.9) с подтверждениями аналитика и теми же условиями ядра; RECOGNIZED после правки — только вручную, как и прежде. Документ, принятый по 1.x, после правки пересчитывается так же (общим кодом), его LEGACY_WARNING и MAPPING_WARNING — info и статус не держат.

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 20 мин (решение владельца 09.10.2026; note DBML «5 мин» — OQ-159), парсер 5 мин.

9.5 Первичные документы: загрузка, маппинг, замена, удаление, подтверждение (Срез 2c)

  1. Загрузка (POST /legal-entities/{id}/files — без контекста, stored_file.consolidation_id = NULL; в консолидацию — п. 8; право DOCUMENT_EDIT, ЮЛ в видимости): тип файла по расширению и сигнатуре (xls — BIFF8, xlsx/xlsb — zip, pdf, png, jpg); лимит UPLOAD_MAX_BYTES (50 МиБ); дедуп по sha256 в пределах ЮЛ среди файлов не SUPERSEDED. Принятый файл: объект S3 uploads/{file_id}/{job_id}/{имя}, stored_file(UPLOADED, version_no = 1), recognition_job(queued), audit_event(FILE_UPLOAD). Тип документа и период при загрузке не запрашиваются: их определяет парсер.
  2. Маппинг (фоновый цикл ядра, отдельный от inbox): задание done/needs_check + файл PROCESSING → проверка ключа результата (бакет и префикс results/{file_id}/{job_id}/, OQ-70) → чтение S3 вне транзакции → одна транзакция: прежние строки файла удаляются, на каждый лист ОСВ создаётся document (osv_account_code — нормализованный счёт шапки, period_year/quarter, is_cumulative по §1.5, locator), вставляются osv_entry (§1.3), резолвится counterparty_id (§3 п. 4), пишутся recognition_job.group_schema и metrics.mapping_warnings, файл → PROCESSED, выполняется автозамена. Второй лист того же файла с тем же счётом документа не создаёт (предупреждение, первый документ — NEEDS_CHECK). Повторяемые сбои (БД, S3) — до MAPPING_MAX_ATTEMPTS (3) попыток, затем файл ERROR (metrics.mapping_error); неповторяемые (ключ вне папки, нет объекта, результат другого задания, пустой список documents) — сразу ERROR. Формы (Срез 3b): после листов ОСВ, в той же транзакции, каждый statementDocument BALANCE/INCOME (Excel и OCR) становится document (osv_account_code = NULL, is_cumulative = NULL — отчёт нарастающий, locator из результата) + financial_statement_meta (шапка: ИНН, дата отчёта, единицы, ОКУД/ОКПО/ОКОПФ/ОКФС, адрес; поля *_original_value заполняются как распознано и больше не меняются) + financial_statement_column + financial_statement_line (строки с кодом, (meta_id, line_code) уникален: повтор кода — остаётся первая строка, предупреждение) + financial_statement_value — на каждую пару «строка × колонка» одна запись (ячейки, которых нет в результате, — с пустыми суммой и уверенностью; значение с column_index вне колонок формы пропускается с предупреждением; повтор column_index в колонках результата или повтор пары «строка × колонка» — остаётся первое, пишется предупреждение, документ NEEDS_CHECK). checks результата ядро игнорирует: §7 считает само (§7). Период формы: дата = header.report_date, а если даты нет — самая поздняя period_date колонок (дата отчёта в OCR часто мусор; колонки надёжнее); period_year — год даты, period_quarter — только если дата — последний день марта, июня, сентября или декабря, иначе квартал пуст и предупреждение «дата … не конец квартала»; без даты год и квартал пусты. У колонки с самой поздней датой is_current = true. counterparty_check — как у ОСВ (§9.3). Повтор формы: второй BALANCE/INCOME того же типа в файле не создаётся (предупреждение, первый документ — NEEDS_CHECK, OQ-77); DISCLOSURE/OTHER — один документ на тип на файл без данных (OQ-23). Форма без строк (нет колонки кодов, OQ-139) создаётся с шапкой и колонками, если они есть в результате, статус NEEDS_CHECK. Файл только с формами — не ошибка; ERROR по результату — только при пустом documents. Страницы одной формы из разных файлов не склеиваются (OQ-140), строки без кода не хранятся (OQ-36, OQ-141, OQ-149). Статус документа формы — §9.3. Формы по контракту v2.0 (change unified-statement-normalization; правила выше — для результатов 1.x на окне выкладки, у них поля v2.0 заполняются по умолчанию: line_status = ok, row_index по порядку строк результата с 0, line_code_original_value = line_code, source_ref = null, text = string_value, needs_review значений — по прежнему правилу «правлено или уверенность 100 и is_recognized», form_variant = null (§7 — полный набор), header_source — из полей шапки 1.x; нарушения §7 ядро считает один раз копией arm_statements и пишет в document_issue (source = core, §7 «Ядро»); предупреждения маппинга 1.x по документу (повтор кода, значение вне колонок) — document_issue MAPPING_WARNING info (source = core; статус при приёме — по D-6, после правки они статус не держат); строки metrics.warnings 1.x — в recognition_job.metrics.warnings структурами {code: "LEGACY_WARNING", severity: "info", message} уровня файла; statements_version задания — null, и STATEMENTS_VERSION_MISMATCH у такого документа не пишется). Результат пишется как пришёл, без повторной дедупликации: каждая строка — financial_statement_line с row_index (у строки из результата всегда не null: row_index IS NULL — признак строки, созданной правкой), source_ref (ref, уникален в форме), line_status, line_code (null у unreadable / missing / detail), line_code_original_value (= line_code результата; правками не меняется), line_code_text, parent_row_index (родитель detail), needs_review; повтор кода — законный случай, хранятся все строки (Р-4; строки без кода хранятся — Р-8, отменяет OQ-36 / OQ-141). Значения — с source_text (values[].text) и needs_review; колонки — с period_kind и label_confidence; шапка — с form_variant, needs_review_fields и header_source (<поле>_text и <поле>_confidence всех полей; *_original_value = разобранное значение, inn_confidence = inn_confidence результата, organization_name_original_value = organization_name); checks и предупреждения документа — в document_issue (источник service; нарушение — kind = relation, code = RELATION_FAILED, error или kind = soft, code = RELATION_SOFT, info, с codes, relation, expected, actual, column_id; предупреждение о столбце вне колонок формы — с column_index) вместе с проверками ядра уровня info (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, RESULT_STATUS_INCONSISTENT); версия общего кода — recognition_job.statements_version. Каждый BALANCE / INCOME становится отдельным документом с file_ordinal: вторая форма того же типа в файле хранится (Р-10, Р-15; ни одна из двух форм одного типа и периода не автоподтверждается — Р-18); ОСВ по счёту, DISCLOSURE и OTHER — по-прежнему один документ на тип (и счёт) на файл. Все чтения формы «по коду» (межформенная проверка, суммы формы для консолидации и выгрузок, parent_code, раскладка экрана, правка по line_code, метрики fields_*) идут через одну чистую функцию «агрегат формы по коду»: сумма amount строк ok с кодом по колонке, список line_id, справочное имя (DECISION-5, не хранится). Строки detail, unreadable, missing в агрегат, КФО и снимок ЕХ не попадают — цена Р-8. Записи document_issue удаляются вместе с документом при перезагрузке файла.
  3. Автозамена. После успешного маппинга другие файлы того же ЮЛ (не SUPERSEDED), содержащие документ с теми же document_type, osv_account_code, period_year, period_quarter, переводятся в SUPERSEDED (superseded_by_file_id = новый файл); version_no нового = максимум заменённых + 1. Новый файл без контекста (consolidation_id IS NULL) наследует consolidation_id первого (по id) заменённого файла, у которого он задан; файл, загруженный в консолидацию, сохраняет свою (v5.18). Заменяется и файл с CONFIRMED документом (решение владельца 05.10.2026); документ без года или квартала в замене не участвует. Документы и строки заменённых файлов сохраняются, но по умолчанию не показываются. Если новый файл содержит лишь часть документов заменяемого, заменяемый уходит в SUPERSEDED целиком (OQ-06: замена файлом целиком; частный случай — OQ-115). Для форм поведение то же (решение владельца 07.10.2026, OQ-154): исправленный отдельный ОФР заменяет всю книгу «ББ + ОФР» того же периода. Чтобы это не происходило молча, документы заменённого файла, ключа которых нет в новом, перечисляются в предупреждении metrics.mapping_warnings нового файла: тип, счёт, период, был ли CONFIRMED, из каких версий сняты привязки (реализация — RSHB/arm-core#58). Аудит: системное FILE_REPLACE (user_id = NULL, object_id — заменённый файл, after_value — новый, в description перечень документов и confirmed=true). Срез 4: автозамена снимает привязки (consolidation_document) всех документов заменённого файла из всех версий «в работе» (CANCELLED не трогаются); версии, где привязка снята, — FILE_REPLACE.after_value.unbound_version_ids; новый документ привязывается только после подтверждения (§3 п. 1).
  4. Подтверждение (POST /documents/{id}/confirm, DOCUMENT_EDIT): RECOGNIZED/NEEDS_CHECK → CONFIRMED (confirmed_by/at, audit_event(DOCUMENT_CONFIRM)); повтор — 409 ALREADY_CONFIRMED; документ заменённого файла — 409 FILE_SUPERSEDED; прочие статусы — 409 DOCUMENT_NOT_CONFIRMABLE. Автоподтверждённый документ (confirmed_by NULL) повторно подтвердить нельзя — 409 ALREADY_CONFIRMED. Ручной правки строк ОСВ нет (решение владельца 07.10.2026, OQ-114; возможен пересмотр по качеству парсинга и документов): ошибка в ОСВ исправляется перезагрузкой файла (п. 3), поля is_modified/original_values у osv_entry — резерв; корректировки — §8. Форма ББ/ОФР (Срез 3b) дополнительно: период не определён (period_year или period_quarter пуст) — 409 PERIOD_REQUIRED (поправьте дату отчёта, п. 9); непройденные неакцептованные жёсткие проверки §7 — 409 CHECKS_NOT_PASSED (список нарушений — в detail.failures: column_index, relation, expected, actual); порядок 409: FILE_SUPERSEDED → ALREADY_CONFIRMED → DOCUMENT_NOT_CONFIRMABLE → PERIOD_REQUIRED → CHECKS_NOT_PASSED. С v2.0 (Р-13, Р-18) порядок: FILE_SUPERSEDED → ALREADY_CONFIRMED → DOCUMENT_NOT_CONFIRMABLE → PERIOD_REQUIRED → LINES_UNRESOLVED (есть строки unreadable / missing, у которых хотя бы в одной ячейке непустая сумма или нераспознанный текст — amount или string_value не null; detail.lines[] {line_id, row_index, line_code_text}; строки без значений не мешают) → DUPLICATE_FORM (в файле уже подтверждена другая форма того же типа и периода — та же пара period_year, period_quarter; detail.document_id; между файлами — автозамена п. 3) → CHECKS_NOT_PASSED (нарушения — записи document_issue вида relation без акцепта, у любой формы). Автоподтверждения LINES_UNRESOLVED не касается (done таких строк не допускает). При подтверждении пишется financial_statement_meta.reviewed_by/at. Несовпадение контрагента (MISMATCH) и низкая уверенность значений подтверждение не блокируют (как у ОСВ): защита — статус NEEDS_CHECK и подсветка. Мягкие проверки (в том числе межформенная) не блокируют. DISCLOSURE/OTHER — без условий. Привязка к версиям и переходы статуса версии — как у ОСВ (§3 п. 1, §9.1): ББ/ОФР за квартал версии (Q1 — только версии за Q1, R-14a Среза 4).
  5. Удаление (DELETE /files/{id}, DOCUMENT_EDIT): запрещено, если у файла есть документ, подтверждённый пользователем (confirmed_by не NULL), или автоподтверждённый документ привязан к версии вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION (FILE_HAS_CONFIRMED_DOCUMENTS; OQ-157, уточняет OQ-110); привязки автоподтверждённых документов снимаются, статус версий не меняется, FILE_DELETE.after_value.unbound_version_ids; запрещено также, если файл заменил другие (FILE_SUPERSEDES_OTHERS); SUPERSEDED файл удалить можно. Удаляются строки, документы (вместе с данными форм financial_statement_* и комментариями документов), задания и stored_file; объектам S3 ставится expires_at = now() (физически не стираются — OQ-111); аудит FILE_DELETE с карточкой файла в before_value. OQ-110 закрыт для Среза 4: к версии привязываются только CONFIRMED документы, а файл с таким документом удалить нельзя — удалить файл с документом в версии невозможно; расширять удаление не требуется (уточнено OQ-157: для автоподтверждённых документов — см. выше).
  6. Аудит. FILE_UPLOAD, FILE_REPLACE, FILE_DELETE, DOCUMENT_CONFIRM (раздел PRIMARY_DOCUMENTS); правка формы и акцепт проверок — RECOGNIZED_DATA_EDIT (раздел RECOGNITION, object_type = document, before/after изменённых полей и ячеек); смены статусов задания/файла в обмене и в цикле маппинга audit_event не пишут (OQ-55). Не аудируются (Срез 3b): скачивание исходного файла, CSV чек-листа, комментарии к документу (comment сам хранит автора и время); экспорт сводной ОСВ в xlsx, напротив, пишет audit_event(EXPORT) (Срез 4). Скачивание первички (это же и просмотр PDF) и CSV чек-листа не журналируются — решение владельца 07.10.2026 (OQ-153).
  7. Порядок блокировок (против взаимоблокировок): задание → файл → документы (FOR NO KEY UPDATE) → advisory-lock ГК → версии консолидации (FOR UPDATE, по возрастанию id); автозамена дополнительно берёт advisory-lock по ЮЛ. Подробности — 03-architecture.md.
  8. Загрузка в консолидацию (POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files, DOCUMENT_EDIT; v5.18, решения владельца 09.10.2026, RSHB/arm-ka-docs#90): до приёма — консолидация видима (404 NOT_FOUND), ЮЛ видимо (404), есть версия консолидации «в работе» (409 CONSOLIDATION_NOT_EDITABLE), ЮЛ — член периметра хотя бы одной такой версии (422 ENTITY_NOT_IN_PERIMETER). Принятый файл получает stored_file.consolidation_id; FILE_UPLOAD.after_value.consolidation_id. После приёма хотя бы одного файла, отдельной транзакцией, каждая версия консолидации «в работе» с этим ЮЛ в периметре и статусом INITIATED переходит в DOCUMENTS_PROCESSING (§9.1; версии блокируются FOR UPDATE по возрастанию id, статус перечитывается); сбой перехода не отменяет принятые файлы. Блокировка версии для загрузки не нужна. Query consolidation_version_id у POST /legal-entities/{id}/files (Срез 4) удалён.
  9. Правка формы ББ/ОФР (PATCH /documents/{id}/statement, DOCUMENT_EDIT, Срез 3b; решения владельца 07.10.2026). Правятся суммы ячеек (values[] {line_id, column_id, amount}) и три поля шапки: inn (10 или 12 цифр, иначе 422 INVALID_INN, в том числе null — очистить ИНН нельзя; пересчитывается counterparty_check), report_date (пересчитываются period_year, period_quarter, reporting_date по правилу периода п. 2) и units (непустая строка до 50 символов). Код и название строки, добавление и удаление строк и колонок не правятся (до v2.0; с v2.0 код и статус строки, дата колонки и наименование организации правятся — см. ниже, название строки и удаление строк и колонок — по-прежнему нет). Только до подтверждения: документ CONFIRMED — 409 DOCUMENT_READONLY (исправление подтверждённой формы — новым файлом через автозамену, п. 3); файл SUPERSEDED — 409 FILE_SUPERSEDED; не форма — 409 DOCUMENT_NOT_STATEMENT; ячейка не этого документа — 422 UNKNOWN_CELL. Исходное сохраняется: у значения при первой правке original_amount = прежняя сумма, is_modified = (amount ≠ original_amount) (возврат к исходному снимает признак), modified_by/at; у поля шапки *_modified = true, *_original_value не меняется. После правки: акцепт проверок сбрасывается (§7; прежний акцепт, включая комментарий, остаётся в before_value.checks_accepted записи аудита), проверки и статус пересчитываются (§9.3), пишется одно audit_event(RECOGNIZED_DATA_EDIT); запрос без изменений — 200 без аудита. Автозамена при правке не запускается (только при маппинге): правка даты, давшая период другого файла ЮЛ, привязывает документ к версии лишь при подтверждении. Порядок блокировок — как у подтверждения (файл → документ). С v2.0 (Р-6, Р-13; change unified-statement-normalization) PATCH дополнительно принимает: header.organization_name (непустая строка до 1000 символов; organization_name_modified = true, organization_name_original_value не меняется; пересчитывается counterparty_check; снимает NO_ORGANIZATION_NO_INN; аудит — как у ИНН); header.report_date, равный текущему, у поля из needs_review_fields — подтверждение даты без изменения: поле уходит из needs_review_fields, снимается REPORT_DATE_UNCERTAIN, аудит (у бланка 66н только с относительными подписями неуверенная дата иначе не подтверждается ничем, §11.9); values[] {line_id, column_id, confirmed: true | false} — подтвердить ячейку без изменения (is_confirmed, confirmed_by/at, needs_review = false, is_modified не ставится) или снять подтверждение (is_confirmed = false, needs_review пересчитывается); columns[] {column_id, period_date} — дата колонке без подписи или с неуверенной подписью (обязательная дата, null — 422 ошибка валидации; пересчитывает is_current и период документа); lines[] {line_id, line_code} — назначить или сменить код; lines[] {line_id, line_status: "detail"} — отметить расшифровкой; lines[] {line_id, confirmed: true | false} — подтвердить неуверенный код или снять подтверждение. Переходы: unreadable / missing / detail → ok назначением кода; unreadable / missing / ok → detail отметкой; ok → ok сменой кода. Обратного перехода detail → unreadable / missing нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла. line_status в PATCH принимает только "detail": "ok" без кода — 422 LINE_CODE_REQUIRED (в ok строку переводит только line_code). Назначенный или подтверждённый код — line_code_confidence = 100, line_code_modified, needs_review строки = false; line_code_original_value не меняется (код из источника; у строк без кода — null); у detail родитель пересчитывается как ближайшая строка ok выше по row_index. Код — 4–5 цифр (422 INVALID_LINE_CODE), может совпадать с существующим (Р-4); незнакомый словарю — info. Строка, созданная правкой — row_index IS NULL (шаблонная): если строка с назначаемым кодом — такая и у неё есть суммы, — 409 LINE_CODE_TAKEN; без сумм — она удаляется (её id убирается из document_issue.line_ids), назначенная строка источника занимает её место. Строки источника (row_index не null) с тем же кодом не удаляются и не мешают — повтор кода законен. Элемент без действия или с несколькими — 422 INVALID_LINE_PATCH; чужая строка — 422 UNKNOWN_LINE, чужая колонка — 422 UNKNOWN_COLUMN; правка суммы по line_code при наличии строки ok с этим кодом — 422 UNKNOWN_CELL (передайте line_id). Удаления строк источника нет (первичка не мутируется, detail — способ вывести строку из сумм). Строки, созданные правкой: row_index = null, source_ref = null, line_status = ok, line_code_original_value = null. Правка header.report_date пересчитывает period_date колонок с period_kind = relative:* общим кодом (null — null). После правки ядро пересчитывает checks, needs_review и статус копией arm_statements (§11.9): правленое или подтверждённое значение и строка не имеют needs_review, акцепт снимает жёсткие нарушения; записи document_issue видов relation / soft заменяются целиком, предупреждения сервиса сохраняются, кроме снимаемых правкой (таблица §11.9). Акцепт сбрасывается правкой данных — суммы, кода или статуса строки, даты колонки, поля шапки; подтверждение ячейки или кода (confirmed) суммы не меняет и акцепт не сбрасывает. Расхождение statements_version задания и копии ядра — info STATEMENTS_VERSION_MISMATCH (у заданий 1.x, где версии нет, — не пишется). Один PATCH — один пересчёт и одна запись RECOGNIZED_DATA_EDIT (before/after по кодам, статусам, родителям, подтверждениям).
  10. Исходный файл и комментарии (Срез 3b). GET /files/{id}/content (DOCUMENT_VIEW, policy по ЮЛ) отдаёт исходный объект из S3 потоком через ядро (в контуре MinIO для пользователя может быть недоступен, а через ядро действует видимость); работает и для SUPERSEDED; объекта нет в хранилище — 404 FILE_CONTENT_NOT_FOUND. Комментарии к документу — POST/GET /documents/{id}/comments (таблица comment, object_type = 'document'); добавлять можно к документу в любом статусе; правки и удаления нет; при удалении файла (DELETE /files/{id}, п. 5) комментарии его документов удаляются вместе с ними (связь полиморфная, FK нет — удаляет сервис).
  11. Рабочий стол консоли (GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files, DOCUMENT_VIEW; 404 на невидимые консолидацию или ЮЛ; limit, offset, include_superseded): файлы ЮЛ с consolidation_id этой консолидации плюс файлы ЮЛ, у которых есть документ, привязанный (consolidation_document) к любой версии этой консолидации (подхваченные ОСВ за Q1 и файлы, загруженные без контекста); новые первыми (uploaded_at desc, id); SUPERSEDED — только при include_superseded=true (скрыт по умолчанию). Карточка файла та же, что в GET /legal-entities/{id}/files (FileOut + consolidation_id). Рабочие версии одного периода видят один стол; верифицированная версия заморожена привязками и ex_snapshot (Срез 7), отдельного «замороженного списка» в панели нет. Панель [39] из карточки версии показывает стол консоли; переключатель «Все документы организации» — GET /legal-entities/{id}/files с подписью консолидации у файла.

§10. Роли и матрица прав (ТЗ 8.1, DOC-036960) — seed для role_permission

Роль (role.code) DIRECTORY DOCUMENT CONSOLIDATION KFO EX EXPORT USERS AUDIT
ADMIN VIEW, EDIT, DELETE, ADMIN (импорт) — — — VIEW — ADMIN VIEW
CURATOR_GO (Куратор КП ГО) VIEW, EDIT (ГК: создание, перезакрепление) — VIEW (по закреплённым ГК) VIEW VIEW — — —
KA_RF VIEW (свой РФ), EDIT (состав ГСК своей ГК) VIEW, EDIT (загрузка, правка, подтверждение) VIEW, EDIT (INITIATED…CONSOLIDATION_RF) VIEW, EXPORT VIEW EXPORT — —
RKA (РКА РФ) VIEW, EDIT (состав ГСК в рамках корректировки) VIEW VIEW, EDIT (VERIFICATION_RF) VIEW, APPROVE VIEW EXPORT — —
KA_GO VIEW, EDIT (состав ГСК, без ограничения по филиалу) VIEW, EDIT VIEW, EDIT (всё, что KA_RF + CONSOLIDATION_GO) VIEW, VERIFY (№/дата УОБ), EXPORT VIEW EXPORT — —
EXPERT VIEW VIEW VIEW VIEW VIEW — — —
CLIENT_MANAGER VIEW (свои клиенты) VIEW, EDIT (только загрузка по своей ГК) VIEW (статус) VIEW — — — —

Policy-слой (не права): KA_RF/RKA/CLIENT_MANAGER видят только ГК своего regional_office; редактирование версии — только в допустимом статусе и при отсутствии чужой активной consolidation_lock; ЗО (business_segment) — фильтр видимости.

Срез 4 (domain/policy.py:ensure_version_editable, решения владельца 06.10.2026, OQ-19): правка версии (замена периметра, ручной статус, ручное сопоставление контрагента) разрешена, когда версия «в работе» (INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION; иначе 409 VERSION_NOT_EDITABLE) и у пользователя своя активная consolidation_lock (нет — 409 LOCK_REQUIRED, чужая — 409 LOCKED_BY_OTHER). Блокировка закрывает все правки версии, но не загрузку и подтверждение документов. Перехват чужой блокировки — любому с CONSOLIDATION_EDIT, с обязательной причиной; блокировка не истекает по времени (OQ-119). Чтение (свод, чек-лист, экспорт, кандидаты) требует CONSOLIDATION_VIEW, блокировки не нужно, версия любого статуса читается. Видимость версии — видимость её ГК; чужая версия — 404. Порядок проверок: право → видимость → статус/блокировка → операция. Матрица в ТЗ помечена «будет разработана» (сноска 29) — seed считать черновиком, редактируется администратором в UI.

10.1 Видимость справочников (Срез 1, без проверки кода роли)

Правило формулируется через данные, а не через роль (инвариант «права — данные»):

  • Пользователь головного офиса (app_user.regional_office → regional_office.is_head_office = true) видит все ГК и все ЮЛ.
  • Пользователь филиала видит ГК с company_group.base_regional_office_id = app_user.regional_office_id, их ЮЛ, ГСК/ГСЗ, а также монозаёмщиков своего филиала (legal_entity.company_group_id IS NULL AND legal_entity.regional_office_id = …).
  • Это совпадает с §10: KA_RF / RKA / CLIENT_MANAGER — пользователи филиалов, KA_GO / CURATOR_GO / EXPERT / ADMIN — ГО.
  • ЗО (business_segment) в Срезе 1 не фильтрует: у ГК и ЮЛ нет поля сегмента (DBML v5.14), сегмент есть только у пользователя [открыто, OQ-86].
  • Право (DIRECTORY_VIEW, DIRECTORY_EDIT, …) отвечает «может ли вообще», policy — «какие именно записи»; обе проверки обязательны на каждом маршруте справочников. Запись вне видимости → 404 (не 403), чтобы не раскрывать существование.

§13. Импорт справочников из выгрузки ЕЦП CRM (Срез 1; формат — docs/source/biq/ecp-export-lagal-entity.xlsx)

Источник: реальная выгрузка ЕЦП CRM (лист «Выписки_01.04.26», 8400 строк, 345 ГК, 53 значения «Базовый РФ»), ТЗ 6.1 (ГК из ЕЦП CRM, создание вручную Куратором), макет [38]. Импорт запускает пользователь с правом DIRECTORY_ADMIN (seed: ADMIN); результат — отчёт + audit_event(DIRECTORY_IMPORT) + файл в S3.

13.1 Формат файла

  • xlsx (первый лист, первая непустая строка — заголовки) или csv (UTF-8, разделитель ; или , — определяется по строке заголовков). Заголовки сопоставляются по нормализованному тексту (нижний регистр, без пробелов, ё = е): ИНН, КПП, iD ГК, Базовый РФ ГК в CRM, Группа компаний, Организация, ГСК, ГСЗ, Тип. Порядок колонок не важен, лишние колонки игнорируются. Нет любой из ИНН, iD ГК, Группа компаний, Организация, Тип → файл отвергается целиком (MISSING_COLUMNS). Базовый РФ ГК в CRM, ГСК, ГСЗ, КПП — необязательные (v5.17: отсутствие колонки КПП не ошибка; значение — trim, все пробельные символы убираются, регистр букв приводится к верхнему; число в xlsx читается как целое без дробной части, ведущие нули не восстанавливаются — как у ИНН).
  • Значения: trim; ИНН — убрать все пробельные символы; Тип — без регистра. Пустые строки пропускаются. Число в ячейке xlsx читается как целое без дробной части (3528052718.0 → 3528052718); ведущий ноль, потерянный ещё в Excel (0123456789 → 123456789), не восстанавливается — строка получит INVALID_INN (ИНН нужно задавать текстом или использовать csv) [открыто, OQ-93].
  • Технические пределы файла (не правила предметной области; нарушение — ошибка файла целиком, §13.3): размер ≤ 20 МиБ (IMPORT_MAX_BYTES, ответ 413), не более 100 000 строк листа/записей csv включая пустые (MAX_IMPORT_ROWS), суммарный распакованный размер частей xlsx ≤ 200 МиБ (MAX_UNCOMPRESSED_BYTES); допустимы только расширения xlsx и csv.

13.2 Строка → решение

Строки обрабатываются в порядке файла; порядок проверок внутри строки: тип → ИНН → КПП → дубль ИНН в файле → ГК (пустой iD ГК, конфликт имени) → РФ → ГК (создание/обновление) → ЮЛ → флаги ГСК/ГСЗ. У каждой строки ровно один итоговый код (шесть кодов пропуска — UNSUPPORTED_TYPE, INVALID_INN, INVALID_KPP, DUPLICATE_IN_FILE, LE_IN_OTHER_GROUP, GK_NAME_CONFLICT, — и LE_CREATED / LE_UPDATED / LE_EXISTS) и ноль или более сопутствующих событий той же строки (RF_*, GK_*, GSK_*/GSZ_*, MEMBER_ADDED, UNKNOWN_FLAG). Счётчик errors = число строк с кодом пропуска.

Шаг Правило Код в отчёте
Тип ЮЛ / ИП → entity_type; иное (нерез., ФЛ, нет инн, пусто) → строка пропускается [открыто, OQ-87] UNSUPPORTED_TYPE
ИНН ЮЛ — ровно 10 цифр, ИП — 12; иначе пропуск (контрольные разряды в Срезе 1 не проверяются [открыто, OQ-88]) INVALID_INN
КПП v5.17, OQ-31: пустой КПП — допустим (у ЮЛ необязателен). Непустой: у ИП — запрещён, у ЮЛ — по формату §13.5 (9 символов: 4 цифры, 2 цифры/латинские A–Z, 3 цифры); нарушение — строка пропускается с сообщением INVALID_KPP
Дубль в файле второй и далее ИНН в файле пропускается. ИНН считается «встречавшимся» только после успешных проверок типа и формата, поэтому «ФЛ», идущая после ИП с тем же ИНН, получает UNSUPPORTED_TYPE, а не DUPLICATE_IN_FILE DUPLICATE_IN_FILE
РФ по regional_office.name (точное после trim); нет → создаётся regional_office(name, is_head_office=false) с предупреждением (один раз на имя в файле); пусто → base_regional_office_id = NULL. РФ разрешается один раз на ГК — по первой строке ГК и только после проверок пустого iD ГК и конфликта имени (отвергнутая строка филиал не создаёт); у существующей ГК при skip филиал из файла всё равно создаётся, но ГК не меняется. Значения «ЦКБ» и «Головная организация» в выгрузке — [открыто, OQ-89]: до решения создаются как обычные филиалы RF_CREATED
ГК пустой iD ГК → строка пропускается (GK_NAME_CONFLICT, сообщение «пустой iD ГК»). По company_group.crm_id = iD ГК: нет → создать (code генерируется GK-NNNNNN, name, base_regional_office_id, valid_from = now); есть → при стратегии update обновить name/base_regional_office_id c снимком в company_group_history и audit_event(GROUP_CHANGE / ASSIGNMENT_CHANGE), при skip — оставить; GK_EXISTS ставится и для неизменной ГК при update (нет различий в имени и РФ — нечего обновлять; это обеспечивает идемпотентность). Разные имена одной ГК в файле (имя первой строки против последующих) → ошибка ГК, строка пропускается. Архивные ГК (is_archived) входят в сопоставление и при update обновляются без разархивации [открыто, OQ-94]. update пишет снимок company_group_history и событие audit_event теми же функциями, что ручная правка (PATCH); смена только имени — GROUP_CHANGE, смена РФ (в т.ч. вместе с именем) — одно ASSIGNMENT_CHANGE GK_CREATED, GK_UPDATED, GK_EXISTS, GK_NAME_CONFLICT
ЮЛ по legal_entity.inn: нет → создать (entity_type, name, company_group_id, regional_office_id = base RF ГК, valid_from = now); есть в этой же ГК → update: обновить name/entity_type со снимком legal_entity_history(UPDATE), skip: оставить; есть в другой ГК или вне ГК (монозаёмщик) → строка пропускается, перевод между ГК только явной операцией (§13.4) [открыто, OQ-90]; сообщение называет имя ЮЛ, независимо от филиала инициатора — импорт доступен только DIRECTORY_ADMIN [открыто, OQ-95]. regional_office_id нового ЮЛ = действующий РФ ГК. LE_UPDATED пишет снимок legal_entity_history(UPDATE) и audit_event(LEGAL_ENTITY_CHANGE) как ручная правка; LE_EXISTS — и при skip, и при update без различий LE_CREATED, LE_UPDATED, LE_EXISTS, LE_IN_OTHER_GROUP
ГСК / ГСЗ флаг = нормализованное входит в гск / входит в гсз; любое другое непустое значение → предупреждение и «не входит». Если у ГК есть хотя бы один член с флагом — создаётся (если нет) одна группа вида gsk/gsz с name = имя ГК и code GSK-NNNNNN/GSZ-NNNNNN [открыто, OQ-91]; член добавляется записью related_group_member(kind, valid_from = now, is_current), если текущего членства этого вида (в этой или другой группе) нет — существующее членство импорт не меняет и не закрывает. Флаги обрабатываются и при skip (недостающее членство добавляется у существующих ЮЛ). Группа создаётся по самому факту флага, даже если у ЮЛ уже есть членство того же вида в другой группе (возможна пустая группа). Значение, не равное ожидаемому для колонки (в т.ч. «входит в ГСЗ» в колонке ГСК), — UNKNOWN_FLAG (предупреждение, не ошибка: «не входит»). Отсутствие флага членство не закрывает (импорт не удаляет) GSK_CREATED, GSZ_CREATED, MEMBER_ADDED, UNKNOWN_FLAG

Перечень кодов строки (17; совпадает с ImportCode в arm-core/api/src/domain/directory_import.py):

Код Роль в строке Счётчик отчёта
UNSUPPORTED_TYPE, INVALID_INN, INVALID_KPP, DUPLICATE_IN_FILE, LE_IN_OTHER_GROUP, GK_NAME_CONFLICT итоговый, строка пропущена errors
LE_CREATED, LE_UPDATED, LE_EXISTS итоговый entities_created, entities_updated, entities_skipped
RF_CREATED сопутствующий offices_created
GK_CREATED, GK_UPDATED, GK_EXISTS сопутствующий groups_created, groups_updated, groups_skipped
GSK_CREATED, GSZ_CREATED сопутствующий (создана группа) —
MEMBER_ADDED сопутствующий memberships_created
UNKNOWN_FLAG сопутствующий (предупреждение) —

Допущения по неоднозначностям выгрузки — OQ-87…OQ-91 (в коде — ASSUMPTION(OQ-nn)); вопросы, появившиеся при реализации, — OQ-92 (пустые «Организация»/«Группа компаний» при непустом iD ГК импортируются как есть, с пустым именем), OQ-93, OQ-94, OQ-95.

13.3 Транзакция, отчёт, след

  • Один импорт — одна транзакция; ошибки строк не прерывают импорт (строка пропускается), прерывают только ошибки файла. dry_run = true — тот же прогон с откатом, отчёт идентичен; в API dry_run по умолчанию true (запись — явным dry_run=false). Номера GK-/GSK-/GSZ- из последовательностей PostgreSQL при откате расходуются (дыры в нумерации допустимы). Ошибки файла (прерывают импорт до любых записей, ответ 422): MISSING_COLUMNS (с перечнем недостающих колонок) и UNSUPPORTED_FILE (расширение не xlsx/csv, повреждённый xlsx, csv не в UTF-8, превышены лимиты строк и распакованного размера §13.1); размер файла больше 20 МиБ — 413.
  • Стратегия дублей — параметр on_existing ∈ {skip, update} (макет: «Пропустить существующие» — skip по умолчанию); update обновляет у ГК имя и РФ (РФ — только непустым значением из файла), у ЮЛ — имя и тип.
  • Отчёт: счётчики (rows, groups_created/updated/skipped, entities_created/updated/skipped, memberships_created, offices_created, errors) и построчный список {row, inn, code, message}. Отчёт возвращается в ответе и пишется в audit_event(action = DIRECTORY_IMPORT, section = DIRECTORIES, object_type = 'directory_import', after_value = отчёт, description = имя файла, user_id = инициатор). Файл сохраняется в S3 как uploads/imports/{audit_event_id}/{original_name} и регистрируется в s3_object(bucket_role = uploads, created_by = user:{id}) (size_bytes, sha256, content_type по расширению); имя файла приводится к basename (путь отбрасывается, управляющие и форматирующие символы убираются, длина ≤ 200 символов с сохранением расширения; пустое → import). Запись s3_object создаётся до put_object (сбой S3 откатывает импорт); сам объект кладётся до commit транзакции, поэтому при сбое commit в бакете может остаться объект без записи (зачистка — follow-up arm-core). При dry_run файл не сохраняется и DIRECTORY_IMPORT не пишется. Без подключённого S3 (dev) файл не сохраняется, в лог пишется предупреждение.
  • Помимо сводного DIRECTORY_IMPORT импорт при update пишет собственные события изменения объектов — GROUP_CHANGE/ASSIGNMENT_CHANGE (ГК), LEGAL_ENTITY_CHANGE (ЮЛ) с description = «импорт ЕЦП»; создания объектов отдельных событий не порождают (только сводное, снимок в *_history при создании не пишется — он появляется при первом изменении).
  • Повторный импорт того же файла идемпотентен при skip: ранее принятые строки получают GK_EXISTS/LE_EXISTS, новых MEMBER_ADDED нет; ранее пропущенные (ошибочные) строки остаются ошибками.
  • Лимит тела запроса (20 МиБ на пути импорта) обязан ставить ingress: multipart разбирается до аутентификации (требование к платформе, arm-core/README.md, M-1).

13.4 Ручные операции справочника (вне импорта)

  • Перевод ЮЛ в другую ГК (макет [79-01]): legal_entity.company_group_id := новая, снимок в legal_entity_history(event_type = MOVE_TO_GROUP, effective_date = дата перевода ≥ сегодня, OQ-21), все текущие членства ЮЛ в ГСК/ГСЗ старой ГК закрываются (valid_to, is_current = false), audit_event(MEMBERSHIP_CHANGE).
  • Вывод из состава ГК (макет [80]): событие CLOSE (close_date, ЮЛ остаётся с company_group_id для истории? — нет: company_group_id := NULL, close_date := дата) или REMOVE_FROM_GROUP (company_group_id := NULL, без close_date); членства закрываются; legal_entity_history(event_type, effective_date).
  • ГСК/ГСЗ: ЮЛ может входить одновременно в одну ГСК и одну ГСЗ (v5.14), но только в группы своей ГК; добавление в группу другой ГК → ошибка. Удаление группы — только пустой (без текущих членов) [OQ-34].
  • Алиасы (§3): legal_entity_alias(alias, normalized_alias); normalized_alias уникален в пределах ЮЛ; конфликт с алиасом другого ЮЛ (OQ-28/ANL-028) — ошибка с указанием ЮЛ-владельца.
  • ГК: перезакрепление за другим филиалом — audit_event(ASSIGNMENT_CHANGE) + снимок company_group_history; архив — is_archived (ГК с активными консолидациями архивировать нельзя — проверка появится в Срезе 4).

13.5 КПП (v5.17, OQ-31, решение владельца 07.10.2026)

  • Поле legal_entity.kpp (и снимок legal_entity_history.kpp), nullable. Формат — ровно 9 символов: ^[0-9]{4}[0-9A-Z]{2}[0-9]{3}$ (4 цифры, 2 символа — цифра или латинская A–Z, 3 цифры); иначе INVALID_KPP (422 в API). Контрольных разрядов у КПП нет.
  • ИП: КПП запрещён — непустой kpp при entity_type = ИП → INVALID_KPP (создание и PATCH; при PATCH проверяется итоговая пара «тип, КПП»: смена типа на ИП у ЮЛ с КПП без очистки КПП — INVALID_KPP, а смена типа без смены ИНН невозможна и по длине ИНН — INVALID_INN). Для ЮЛ КПП необязателен; очистить его (null) можно.
  • Уникальность: только inn (КПП — атрибут). Сопоставление контрагентов ОСВ по ИНН (§3) КПП не использует.
  • Изменение КПП — обычный PATCH ЮЛ: снимок legal_entity_history(event_type = UPDATE) с прежними значениями и audit_event(LEGAL_ENTITY_CHANGE) с before/after, содержащими kpp.
  • Импорт: колонка КПП необязательна; для существующего ЮЛ при стратегии update непустой КПП из файла обновляет kpp (со снимком, как name/entity_type), пустая ячейка существующий КПП не стирает.
  • Поиск ЮЛ: подстрочный поиск q по справочнику ЮЛ (имя, ИНН) ищет и по КПП; точный фильтр inn не меняется.

Все мутации пишут audit_event (GROUP_CHANGE, ASSIGNMENT_CHANGE, MEMBERSHIP_CHANGE, LEGAL_ENTITY_CHANGE — v5.14, DIRECTORY_IMPORT) с before/after.



§11. Нормализация форм ББ/ОФР — общая цепочка (scan-gateway и tabular-parser)

Статус: целевое правило change unified-statement-normalization (RSHB/arm-ka-docs#116; дизайн — openspec/changes/unified-statement-normalization/design.md, решения владельца Р-1…Р-19 от 17.10.2026), контракт v2.0. Порядок выкладки — «сначала ядро» (ядро принимает 1.x и 2.0); пока сервис пишет 1.x, он работает по прежним правилам — прежний текст §11, §11.1 и §12 (Срезы 2b, 3a, 3b) — в истории git репозитория docs, коммит 43b4b80. §11 объединяет прежние §11 (маппинг json ИС), §11.1 (очистка OCR-текста) и §12 (ББ и ОФР из Excel); §12 оставлен заголовком-ссылкой.

Источник требований: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР»), DOC-037734, DOC-221281, FNR01/FNR02; формат — Приказ 66н / ФСБУ 4/2023, на практике — скан бланка (ИС Сканирование), выгрузка 1С («Регламентированный отчёт»), шаблон банка и вручную набитая таблица.

Принципы.

  1. Один выход на шаг. Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист Excel → кандидат»). Шаги 2–8 — общий код с общими словарями. Единственное место, где общий код смотрит на источник, — разбор запятой и точки в сумме (шаг 4).
  2. Ничего не выбрасывать молча. Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со статусом, а не превращается в счётчик; положение в источнике (ref), порядок строк (row_index) и сырой текст ячейки (text) доезжают до базы и экрана. Подтвердить документ, не разобрав строки с суммами без кода, нельзя (Р-13).
  3. Качество — структурно и с привязкой к месту: уверенность ячейки (от источника), is_recognized (разбор числа), статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, is_recognized и §7 сервис сводит в один признак needs_review у значения, строки и поля шапки и из них выводит статус документа. Ядро при загрузке ничего из этого не пересчитывает.
  4. Один словарь — одно место: проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, подписи колонок, упорядоченные перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде сервисов; соответствие «код → наименование» — только в ядре (Р-3).
  5. Очистка OCR — не отдельный шаг, а первая стадия типовых разборов текста там, где уже известен смысл ячейки (дата, подпись колонки, код, разлепление, сумма).

Где код. Шаги 2–8, общие разборы и словари — пакет arm_statements/ репозитория arm-statements (Р-1); в tabular-parser, scan-gateway и arm-core он лежит копией (раскладывает и проверяет sync-statements в docs, прямые правки копии запрещены, маркер версии — service.statements_version). Golden-тесты пакета — на синтетических и обезличенных данных внутри пакета; тесты на fixtures/ остаются в сервисах. Изменения правил ниже — только через этот параграф и словари пакета.

§11.1 Источник OCR: ИС Сканирование (адаптер шага 1 в scan-gateway)

Формат ответа — по реальным файлам fixtures/ocr/*.json (проверено 04.10.2026; OQ-13, OQ-14, OQ-77):

  • Каталог ответа <job_id>/ содержит несколько json — по одному на распознанный комплект отчётности. Шлюз читает все json каталога и склеивает их в raw/{job_id}.json ({job_id, files[{name, response, parse_error}]}) до нормализации; ошибка одного json не отменяет остальные. Допущение владельца (17.10.2026): несколько периодов в одном файле ИС отдаёт разными json; образца нет — проверить на первом таком файле (OQ-167).
  • Корень: {file_uid, error {code, message}, data_ocr}. Успех — error.code == ""; ошибка — непустой code (INVALID_DOCUMENT, FILE_READ_ERROR, INVALID_FORMAT, RECOGNITION_FAILED, UNKNOWN_ERROR).
  • data_ocr — объект: поля шапки label, organization, inn, units, date — каждое {form_name, confidence} (сырой OCR-текст; date.form_name ненадёжна — у aep_6 и «БО 1 кв 2025» там название организации), одна шапка на json; table_structure[] — таблицы {label {form_name, confidence}, rows[{row_index, cells[{column_index, value, confidence}]}]}; column_index не обязательно подряд; confidence — проценты 0–100 (NaN → 0). В одном json встречаются ББ и ОФР одного периода под общей шапкой. ИС отдаёт ячейку на каждую позицию строки: пустая — пустой текст с уверенностью 100, непрочитанная — пустой текст с уверенностью 0.

Адаптер. Каждая таблица успешного json — кандидат (FormCandidate): source = ocr; locator = file:<имя json>;table:<i> (как в golden-эталонах); шапка json копируется в каждую свою таблицу (Cell.text = form_name, confidence ИС); label.form_name таблицы — заголовок в evidence (тип — правилом §11.2 «тип-подсказка» по словарю заголовков; прежний statement_labels.yaml уходит в словарь пакета); сетка строится по row_index и column_index плотно, пустая позиция — null; Cell.ref = row:<row_index>, value = null.

Ошибки набора. Ошибочный json (parse_error, непустой error.code, корень не объект) при хотя бы одном успешном соседе — предупреждение файла JSON_FAILED (error, locator = file:<имя json>, в тексте код и сообщение ИС; решение № 28 среза 3a: потерянный документ, например ОФР, не должен остаться незамеченным); все json ошибочны — конверт error с кодом первого. Исключение при разборе таблицы — предупреждение файла TABLE_PARSE_ERROR (error, locator = file:<имя json>;table:<i>; документа у такой таблицы нет), остальные таблицы разбираются; как и JSON_FAILED, оно блокирует RECOGNIZED всех документов файла (§9.3); ни одной таблицы не разобрано — error RECOGNITION_FAILED.

Перенос из старого ядра. Источник очистки и разборов — модуль DKB_ARMKA api/src/services/ofr_result_conversion/ (utils.py, date_utils.py, consts.py; локально — reference-old-arm/api/src/core/PDFWorker/); решение владельца 06.10.2026: проверен на стенде ИС Сканирование, перенос разрешён — единственное исключение из правила «доменный код из reference-old-arm не переносить». Перенесённые функции теперь — общие разборы пакета (§11.3–§11.6), отличия от источника перечислены там.

§11.2 Источник Excel: tabular-parser (адаптер шага 1)

Остаётся в сервисе: лимиты и чтение книги (PARSE_MAX_BYTES 50 МБ, PARSE_TIMEOUT_SECONDS 120 с, формат по сигнатуре байтов — OLE2 → XLS, zip с xl/workbook.bin → XLSB, иной zip → XLSX, иначе UNSUPPORTED_FORMAT; несовпадение с file_type — FILE_TYPE_MISMATCH info; лимиты распакованного zip и площади объединённых ячеек; библиотека упала — UNREADABLE), классификация листов и путь ОСВ (§1).

  1. Классификация листов: лист ОСВ (признак ведомости в первых 30 строках, §1) → форма → продолжение → прочее. Форма — заголовок («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД в любой строке до таблицы; титул «Бухгалтерская отчетность» — не тип. Продолжение — лист без заголовка, но с маркером «Форма … с.2», или с подписью «Код» и периодами правее, или с колонкой кодов, или с одним-двумя кодами сразу после листа формы. Прочее — SHEET_OTHER (info), если у листа нет ни типа, ни кодов.
  2. Блоки форм на листе: строки с заголовком или ОКУД — кандидаты; без кодов между ними блоки сливаются, заголовок после кодов — новая форма. Шапка своя у каждого блока в его границах: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки), ИНН 10/12 цифр у метки «ИНН» (ИНН аудитора не берётся: строки после метки «аудитор» исключены), ОКПО, ОКОПФ, ОКФС, ОКЕИ, адрес, единицы, дата (подзаголовок формы или тройка «число | месяц | год» у метки «Дата»; для ОФР — конец периода). Шапка другого листа не используется. Найденное поле — Cell с confidence = 100, не найденное — null. ОКЕИ — поле okei шапки контракта.
  3. Ячейки: text — как показывает числовой формат ячейки, value — число по формату (tp#42, OQ-138 пересмотрен 15.10.2026): масштабирующие запятые (#,##0, → тысячи), число знаков после точки, секции и условия ([=0]"-", [=-646532]"(647)"; литерал условной секции разбирается как текст), округление half-up, знак — от сырого значения, условия сравниваются с точностью 15 значащих цифр; General / пустой формат — значение как в ячейке; формат, который не удалось разобрать (%, экспонента, дробь, даты, @, неизвестные знаки вне кавычек, больше четырёх секций, условие не по шаблону, результат вне numeric(18,2)), — значение как в ячейке и одно NUMBER_FORMAT_UNPARSED (info) на документ с перечнем форматов; xlsb — стили не читаются, XLSB_NO_FORMATS (info). ref = <лист>!<A1> по правилу Excel: имя листа берётся в одинарные кавычки, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры, апостроф внутри имени удваивается (Баланс!C27, 'Бухгалтерский баланс. Лист 1'!C14; решение владельца 18.10.2026, Р-22 / S-15); confidence = 100.
  4. Склейка листов-продолжений — до раскладки. Строки продолжения дописываются в конец сетки открытой формы по маркеру «Форма … с.2» или по позиции листа; граница листа — в segment_breaks (всегда граница сегмента раскладки). Тип маркера сверяется с типом формы: расхождение — отдельный кандидат и CONTINUATION_TYPE_CONFLICT (error). Лист без маркера и шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок сумм совпадает с формой (проверки прежнего join_blocker); иначе — отдельный кандидат и SHEET_NOT_JOINED (error). locator = sheet:<A>[,<B>…]. Страницы в разных файлах не склеиваются (1С печатает форму постранично): страница 2 без первой — документ без шапки, needs_check (OQ-140). Две половины формы одного типа и периода в одном файле склеиваются только листом-продолжением; иначе это две формы (Р-15, вопрос — OQ-165).
  5. Пятизначные подстроки (11101, 12301…) — обычные строки. Шаблоны банка (коды есть, сумм и шапки нет) — needs_check (OQ-142).

Тип-подсказка (type_hint) — одно правило у обоих источников: ОКУД по словарю (0710001 → BALANCE, 0710002 → INCOME, прочие 0710xxx — 0710003 капитал, 0710004 ДДС и др. → DISCLOSURE) → иначе заголовок по словарю заголовков (у OCR — label таблицы ИС; «Пояснения», «Расшифровка» → DISCLOSURE) → иначе null. Окончательный тип — по кодам строк (§11.7).

Шапка — одно правило разбора по Cell.text: ИНН — только цифры, без замены двойников (иначе «3|1 21 2|» из aep_3 даёт лишние единицы и MISMATCH с ЮЛ), длина 10 или 12, иначе null; дата отчёта — словарь периодов §11.3 п. 3; единицы — словарь единиц пакета statement_units.yaml (написания «тыс. руб.», «в тыс. рублей», «руб.», «млн руб.» → тыс. руб. / руб. / млн руб.); единицы не найдены по тексту, а ОКЕИ есть — по коду ОКЕИ тем же словарём (384 → тыс. руб., 383 → руб., 385 → млн руб.); ОКЕИ — только цифры. Сырой текст и уверенность каждого поля — в контракт (<поле>_text, <поле>_confidence); ядро хранит их в financial_statement_meta.header_source.

§11.3 Шаг 2 — раскладка таблицы

Очистка OCR-текста (общий разбор, применяется к любой ячейке без value; у Excel — к текстовым ячейкам, почти всегда без изменений). Буквы-двойники рядом с цифрами О/о/O/o → 0, З/з → 3, б → 6, S/s → 5, I/l/|/і → 1, Ч → 4 заменяются только вплотную к цифре, в несколько проходов («2ОО5» → «2005»), слова не портятся; если первая строка значения состоит только из цифр, пробелов, |, точек и двоеточий — | считается разделителем клеток («2|0|2|4|» → «2 0 2 4»). Применяется к датам, подписям колонок, кодам и суммам (Р-11; отменяет прежнее «только к датам» и решение № 22 среза 3a): сумма с заменой — is_recognized = false, код с заменой — неуверенный (уверенность не выше 99); в суммах крайние | (след границы таблицы: «| : 122477») удаляются, а не заменяются. ИНН двойники не трогают. Текст длиннее 200 символов датой не разбирается (решение № 31 среза 3a: backtracking регулярок).

Диапазоны кодов (словарь пакета): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала 3100–3699, отчёт о движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании средств 6100–6499. «Кодовые диапазоны» для поиска столбца — объединение отрезков (годы 2000–2099 и четырёхзначные суммы вне отрезков кодами не считаются). «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца; им проверяются разлепление строк, слабые продолжения и наследование столбца в сегментах.

Разбор кода: убрать пробелы и переносы → двойники (| → 1, только если результат в кодовом диапазоне: «| 140» → 1140) → краевая пунктуация («1220 ,» → 1220) → 4–5 цифр (допускается .0: 1110.0 из Excel), первые четыре — в кодовом диапазоне. Многострочная ячейка — код, только если каждая её часть код.

  1. Столбец кодов. (а) Столбец с подписью из словаря («код», «коды», «код строки», «код показателя» — после нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью доля кодов среди непустых ≥ 0,6 или кодов ≥ 3; несколько кандидатов — с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом. Защита обязательна: в выгрузках 1С под «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267, 12300 — «коды баланса» по первым цифрам), ОКЕИ — один код из девяти, доля 0,11. Документ из двух строк остаётся документом (два из двух, доля 1,0). (б) Подписи нет — столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6. Нет — CODE_COLUMN_NOT_FOUND; сопоставление по наименованиям строк не делается (OQ-139).

  2. Сегменты. Новый сегмент открывает строка-шапка (в ней есть подпись кода из словаря, или не менее двух её непустых ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница листа из segment_breaks. У сегмента своя раскладка: по его шапке; без шапки — столбец кодов по п. 1, а если сегмент не первый и кодов меньше трёх — ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (лист «_2» НПЗ с единственной строкой 2400, у которого код в другой колонке). Колонки значений сегмента без шапки — столбцы правее кода, где в строках с кодами числа, сопоставление с первым сегментом по порядку; число не совпало — SEGMENT_COLUMNS_MISMATCH (error), значения по позиции. Столбец кодов не найден ни так, ни так — SEGMENT_WITHOUT_CODES (error); строки сегмента не теряются (роль data, статусы по §11.4).

  3. Дата колонки — один словарь периодов statement_periods.yaml, объединяющий прежний словарь парсера и шаблоны dates.py шлюза (порядок приоритета текстовых форм: диапазон «с 01.01.2025 по 30.09.2025» — конец; «30 сентября 2025 г./r./года»; «I квартал 2026» / «1-й кв.» / «первый квартал»; «1 полугодие» / «1 п/г»; «9 месяцев 2025»; «январь – сентябрь 2025» — последний месяц; «за 2025 г.» / «2025 год» — 31.12; затем числовые: три группы цифр «30.09.2025», «2025 03 31», «30/09/25»; иначе склеенные цифры — 8 → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный), 4 → год (31.12, 1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается; двузначный год → 20YY; квартал / полугодие / N месяцев → последний день последнего месяца; не дата → null). Строки test_dates.py::test_parses_real_forms шлюза — обязательные тесты словаря. Результат — period_date (конец периода колонки; во внутренних типах пакета может называться иначе, в контракте и БД — period_date) и period_kind:

    • абсолютные подписи → absolute;
    • относительные подписи бланка 66н без впечатанных дат (Р-6): «На отчётную дату», «За отчётный период» → relative:current (= эффективная дата); «На 31 декабря предыдущего года» → relative:prev_year_end (31.12 года эффективной даты − 1); «На 31 декабря года, предшествующего предыдущему» → relative:prev_prev_year_end (31.12 года − 2); «За аналогичный период предыдущего года» → relative:prev_period (эффективная дата − 1 год; 29.02 → 28.02);
    • подписи нет → none.

    Эффективная дата документа — дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок (та же формула, что период формы в ядре, §9.5 п. 2); нет ни той, ни другой → у относительных колонок period_date = null, вид остаётся относительным, ядро досчитает после правки даты отчёта. Подпись OCR с уверенностью ниже 100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два года, 31 декабря предыдущего и предшествующего ему года; иначе COLUMN_DATE_UNCERTAIN (error, колонка).

  4. Строка заголовка — последняя над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе последняя над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); двухстрочный заголовок («На» + «20__ г.», перенос внутри ячейки) склеивается. Роль title — только строки выше найденной строки заголовка (титульный блок «Дата 30 | 9 | 2025»); в сегменте без заголовка роли title нет, строки выше первого кода с содержимым идут в данные. Заголовка нет ни у одного сегмента → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа, label пустая, period_date = null, period_kind = none, одно HEADER_ROW_NOT_FOUND (error; прежний синтез шапки шлюза и «рулинг 06.10.2026» заменены этим правилом). Заголовок есть хотя бы у одного сегмента — остальные берут его колонки без предупреждения (листы 2–5 ББ НПЗ без шапки остаются done).

  5. Строка нумерации «1 2 3 4 5» — роль numbering, в данные не попадает (AURORA-1517). Строка — нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников (§11.3, «1 2 З 4» → 1 2 3 4) — целое число, числа слева направо возрастают на 1, начинаются с 1 или 2 и допускают один пропуск («1 2 4 5», «2 3 4 5» без первой ячейки; в Excel Межениновской в столбце кодов строк нумерации стоит «2»). Решение владельца 18.10.2026 (Р-22, S-13): иначе сбой OCR в нумерации превращает её в строку unreadable с «суммами» и блокирует подтверждение. Запись нумерации в столбце кодов («2», «3») кодом не считается — вне кодовых диапазонов.

  6. Подпись без периода (Р-6): период по словарю (абсолютный или относительный) → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск, COLUMN_SKIPPED (info); иначе, если в столбце есть числа в строках с кодами (OCR-мусор «| | г» с уверенностью 17, обрезанная или самодельная подпись), — колонка остаётся с подписью как есть, period_date = null, period_kind = none, COLUMN_WITHOUT_DATE (error, колонка); аналитик ставит дату через PATCH (§9.5 п. 9); иначе пропуск. Колонка без даты не мешает периоду формы (эффективная дата), но межформенная проверка и гудвилл «на отчётную дату» по ней не считаются, пока дата не проставлена. Уверенность подписи — label_confidence (Excel — 100, подписи нет — null).

  7. Столбец наименования — подпись, начинающаяся с «наименование», в строке заголовка; иначе столбец левее кода с наибольшим числом текстов, кроме «Пояснения» (пустые или ≤ 3 символов); наименование строки — ячейка этого столбца, пуста — ближайшая текстовая ячейка левее кода, кроме «Пояснения».

  8. Номер колонки (columns[].column_index, values[].column_index, column_index предупреждений) — номер столбца в сетке кандидата шага 1: у OCR — column_index ИС как есть (не обязательно подряд: 2, 4, 5), у Excel — номер столбца листа с 0 (столбец D — 3). Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номера. Предупреждение о столбце, которого нет среди columns[] (COLUMN_SKIPPED), несёт номер этого столбца в сетке; ядро хранит его в document_issue.column_index (колонки формы нет — column_id = null).

§11.4 Шаг 3 — отбор строк данных и статусы

Строка данных — строка сетки с ролью data, у которой есть содержимое: в ячейке кода есть хотя бы одна цифра (разобранный код или мусор вроде «44 30\nI |» aep_6), или хотя бы одна ячейка значений даёт сумму (amount не null), или непуста с уверенностью ниже 100 (признак потерянной суммы: «[190 :», «" :» в aep_3). Строка только с текстом наименования — не строка данных (заголовок раздела). Строка unreadable без значений («44 30\nI |» при пустых суммах) — строка данных: документ needs_check (не все строки ok / detail), но подтверждение она не блокирует (LINES_UNRESOLVED — только строки со значениями, §9.5 п. 4). У строки: row_index (номер в документе с 0, по порядку сетки после разлепления), ref (уникален в документе: OCR row:<row_index ИС>, разлепленные row:<i>#<k> с k от 1; Excel — адрес ячейки столбца кодов строки <лист>!<A1>, без столбца кодов — первой непустой ячейки), статус, ячейки кода и наименования, ячейки значений по колонкам сегмента, parent_ref. Статусы:

  • ok — код разобран;
  • unreadable — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «изо :», «[190 :»); ячейка без единой цифры («}», «:») считается пустой;
  • detail (Р-8) — строка без кода с содержимым под строкой с кодом (в том же или в предыдущих сегментах — родитель продолжается через разрыв страницы): расшифровка («в том числе: …», «из них …», «Оборудование к установке», «Товар (41.01)»); родитель — ближайшая строка с кодом выше. Сервисы её отдают, ядро хранит; в проверки §7, агрегаты и done она не входит; экран по умолчанию не показывает;
  • missing — строка без кода с содержимым, над которой во всём документе нет строки с кодом («Выручка» aep_3, где 2110 уехал в ячейку значения); строки роли title сюда не попадают.

Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. Счётчиков отброшенных строк нет — есть статусы (отменяет «строк с нечисловым кодом пропущено», «строк без кода с суммами пропущено», OQ-36 / OQ-141; OQ-149 — восстановление строк по наименованию — остаётся открытым). Строка формы с пустой ячейкой кода под другой строкой с кодом попадёт в detail и будет скрыта; её значения не войдут в сумму раздела, нарушение §7 подсветит родителя (пустая цель при непустых членах — нарушение), аналитик раскроет расшифровки и назначит код (Р-13) — осознанная цена Р-8.

Повтор кода (Р-4) — законный случай (один код, разные наименования; договорённость ДБД в AURORA-1431, закрывает AURORA-1406): все строки ok, порядок как в файле, ни предупреждения, ни duplicate_line_codes (отменяет «оставлена первая строка», решение № 15 среза 3a и OQ-152). Для §7 и всего после — сумма по коду (агрегат формы по коду, §9.5 п. 2). Неверно прочитанный код, совпавший с существующим, ловит структурная проверка (§11.9).

Разлепление склеенных строк (OQ-156; Р-17 заменяет решение владельца 08.10.2026 о безусловном needs_check) — до разбора кода: ячейка кода содержит N ≥ 2 частей через перевод строки, каждая непустая ячейка значений — ровно N частей, и хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в диапазоне ББ). Получается N строк (ref row:<i>#<k>): k-й код (нечитаемая часть — строка unreadable), k-я часть каждой ячейки значений, наименование — k-я из непустых частей, если их тоже N, иначе целиком; line_code_text и text — k-е части; уверенность — исходной ячейки. Значения разлепленных строк — is_recognized = false, нуждаются в покрытии §7 (при сошедшихся соотношениях не подсвечиваются); само разлепление — ROW_SPLIT (info). Число частей не совпало или ни одна часть не код — одна строка, многострочное значение → amount = null, string_value = текст, is_recognized = false. Пример: ББ РБ и Счастливочной за 1 кв. 2025 — код 1500\n1700, суммы 917 775\n2 371 333. У Excel не срабатывает.

§11.5 Шаг 4 — значения ячеек

Выход: {column_index, amount, string_value, text, confidence, is_recognized} (+ needs_review, §11.9). text — всегда сырой текст источника; string_value — только когда amount = null и текст непуст и не прочерк.

  1. value есть (Excel): число → amount с квантованием до 2 знаков half-up, не больше 16 целых цифр (иначе amount = null, string_value = текст, is_recognized = false, VALUE_OUT_OF_RANGE info); дата или логическое → amount = null, string_value = текст, is_recognized = false.
  2. value нет — parse_amount(text, source), один для обоих: двойники (§11.3) и крайние |; пробелы, NBSP и тонкие пробелы; минус «−» (U+2212) как знак; скобки → минус, } и ] принимаются как закрывающая скобка (OQ-147: «(7744536}» → −7744536, is_recognized = false); прочерк (-, –, —, − без цифр) и пусто → amount = null, is_recognized = true (OQ-148). Разделители:
    • ocr: запятая — тысячи, если весь текст (после strip и удаления пробелов, с учётом знака и пары скобок на всю строку) — [+-]?[1-9]\d{0,2}(,\d{3})+ (AURORA-1405, sg#35: 4,200 → 4200, 1,234,567 → 1234567); точка — тысячи при [+-]?[1-9]\d*(\.\d{3})+ (sg#37: 837.994 → 837994, 1.234.567 → 1234567, 1 234.567 → 1234567); удаление таких разделителей исправлением не считается. Иначе запятая и точка — десятичный разделитель (4,2 → 4.2, 1 234,56 → 1234.56); смешанные записи (1.234,56, 1,234.56) — цифры подряд, is_recognized = false. Три и более знаков после десятичного разделителя (0,125, 12.3456) — amount = null, string_value = текст (решение № 30 среза 3a сохраняется для OCR-текста: такое значение не укладывается в numeric(18,2));
    • excel: запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5; как прежний to_money).
  3. Пробел внутри числа допустим только как разделитель тысяч (группы ровно по три цифры); иначе amount — «лучшая догадка» (цифры подряд, не длиннее 16 цифр, иначе null), is_recognized = false, сырой текст — в text («42831 1» → 428311; AURORA-1511, 1519). Многострочное значение в строке с однострочным кодом → amount = null, string_value = текст, is_recognized = false. Excel-ячейки «число текстом» идут тем же путём.
  4. is_recognized — один смысл у обоих: текст разобран как число без выброшенных и исправленных символов (замена двойника, } / ] вместо скобки — исправление). confidence — отдельно, от источника (у Excel — 100); прежнее «is_recognized = confidence == 100 и ничего не выброшено» шлюза отменено.
  5. Пустая ячейка (Р-2): позиции нет, текст пуст или прочерк → amount = null, string_value = null, is_recognized = true, confidence ячейки (позиции нет — 100); собственной подсветки нет. Непрочитанная ячейка ИС (пустой текст, уверенность 0) нуждается в покрытии по §11.9.
  6. Единицы: «тыс. руб.» и ≥ 10 % дробных значений → FRACTIONAL_IN_THOUSANDS (info, документ остаётся done; OQ-138 — единицам шапки верим); то же в книге xlsb без числовых форматов — XLSB_SCALE_SUSPECT (error: вероятен масштаб ×1000, а §7 сходится при любом масштабе).

Разбор сумм ОСВ (to_money в classify.py, §1) этими правилами не затрагивается.

§11.6 Шаг 5 — код, наименование, редакция

Строка: {row_index, ref, line_status, line_code, line_code_text, line_code_confidence, line_name, line_name_confidence, parent_ref, needs_review, values}. line_code — разобранный код или null у unreadable / missing / detail; line_code_text — сырой текст ячейки кода; line_code_confidence — от источника (у кода с заменой двойника — не выше 99), без срезания до 99 по справочнику; line_name — распознанный текст наименования как есть, с уверенностью источника (Р-3: имя из справочника шлюза api/rules/statement_lines.yaml отменено, файл удаляется; соответствие «код → наименование» — только в ядре, §7 «Полный состав формы»). Редакция (66n / fsbu4) в сервисах нужна только для упорядоченного перечня кодов (§11.9): проверяются обе редакции, берётся та, где подпоследовательность длиннее, а при равенстве — по эффективной дате (fsbu4 — после 2024-12-31, как edition ядра, OQ-145): в 2025 году формы сдают и на бланке 66н (Межениновская, 3 кв. 2025), и выбор только по дате не подтвердил бы её код 1120 (в перечне fsbu4 его нет). Прежний выбор редакции шлюза для имён (по дате, по кодам, «редакция не определена» / «не заполнена» → needs_check) отменён.

§11.7 Шаг 6 — тип формы, вариант, документ

  1. Тип — большинство кодов строк ok в отрезке ББ → BALANCE, ОФР → INCOME, прочих форм и пояснений → DISCLOSURE; совпало с type_hint или type_hint = null — тип по кодам; противоречит — тип по кодам и TYPE_CONFLICT (error); кодов нет — type_hint. Поиск столбца кодов (шаг 2 п. 1) выполняется всегда, в том числе при type_hint = DISCLOSURE: подпись «Пояснения» или имя листа «Расшифровка» над таблицей с кодами 1110–1700 не должны превратить баланс в пустое пояснение — большинство кодов в отрезке ББ или ОФР даёт тип по кодам и TYPE_CONFLICT (error). Без столбца кодов: type_hint есть → документ без строк (DISCLOSURE — done, иначе needs_check с CODE_COLUMN_NOT_FOUND error); type_hint нет → у OCR таблица ИС с содержимым — предупреждение файла TABLE_LOST (error, locator = file:<имя json>;table:<i>), у Excel — лист «прочее», предупреждение файла SHEET_OTHER (info, locator = sheet:<имя>); документа в обоих случаях нет. DISCLOSURE (по type_hint без кодов форм или по кодам прочих форм) — шаги 3–7 не выполняются, columns и lines — пустые массивы (ядро строки пояснений не хранит), checks нет, документ done.
  2. Вариант формы form_variant — только по структуре кодов (ОКУД 0710096 — код КНД комплекта, на бланках 0710001 / 0710002, признаком не служит): ББ — есть 1600 и нет ни 1100, ни 1200 → simplified; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → simplified; иначе full; у DISCLOSURE / OTHER — null. Шаг 7 и перечень кодов берут набор варианта. Образец упрощённой формы — в fixtures при первой возможности (OQ-166).
  3. locator — пары ключ:значение через ;: file:<имя json>;table:<i> / sheet:<A>[,<B>]. Шапка документа — поля кандидата (text → <поле>_text, разобранное → поле, confidence → <поле>_confidence).

§11.8 Шаг 7 — самопроверки §7

Правила счёта — §7 («Самопроверка в сервисах»): один словарь у обоих сервисов, значение кода — сумма строк ok, «посчитанное» соотношение, допуск и округление, abs_terms подтверждают величину, но не знак, полный или упрощённый набор по form_variant, родитель подстроки. checks есть у каждого BALANCE / INCOME со строками и отсутствует у документа без строк. Проверки по словарю имён и перечню кодов («код не в словаре», «код не из редакции») — в ядре, info к строке.

§11.9 Шаг 8 — needs_review, статус, предупреждения

Подтверждение проверкой (Р-5, Р-7, Р-11, Р-17, Р-21). Проверка §7 — арбитр, уверенность — подсказка там, где проверки нет.

  • Значение нуждается в покрытии, если confidence < 100 (Р-5: у ИС уверенность дискретная — 100, ~80, 71, 67, 60, 50, 25, 0; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80) или is_recognized = false (грязный текст, замена двойников, разлепленная строка). Чистые значения в покрытии не нуждаются.
  • Значение покрыто, если amount значения не null и в его колонке есть соотношение с участием его кода, которое посчитано и сошлось: жёсткое — в пределах допуска ±1 или округления 0,001 %; мягкое — только точно, в пределах допуска ±1, без «округления» (Р-21, решение владельца 18.10.2026). Мягким покрываются строки, у которых жёстких соотношений нет: 2400, 2410, 2411, 2412, 2460 (ОФР полного набора; 2400 упрощённого — тоже мягкое, S-5), 2510, 2520, 2530, 2500 (2500 = 2400 + 2510 + 2520 + 2530), пятизначные подстроки («сумма подстрок = родитель»).
  • Знак члена abs_terms, нуждающегося в покрытии: подтверждён, если форма записи знака (скобки, минус, без знака — по text) совпадает с формой у большинства членов abs_terms той же колонки с непустым amount, is_recognized = true и без правки аналитика; не совпадает или сравнивать не с чем — не подтверждён (то же правило — при покрытии мягким соотношением, например 2410 в 2400 = 2300 − 2410 + 2420). «(7744536}» подтверждается, если 2210 и 2220 той же колонки в скобках; «{38378)» при остальных в скобках — подсвечивается.
  • needs_review = true, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности. Иначе false: сырой текст и уверенность остаются в подсказке ячейки. Мягкое соотношение, не сошедшееся точно, подсветки не даёт.

Не покрыто проверками и остаётся на уверенности: строки 29xx (прибыль на акцию), 2420 и 2530 вне сошедшихся мягких соотношений, детализация без строки-родителя, DISCLOSURE, колонки, где код не участвует ни в одном посчитанном соотношении. Риск двух взаимно компенсирующих ошибок в одном соотношении принимается. Закрывает AURORA-1524 без ущерба для 1519.

Код строки (Р-5, Р-12, Р-16, Р-20). Код неуверенный — line_code_confidence < 100 или заменён двойник; структурная проверка — только для неуверенных кодов (у Excel и чистых OCR-кодов расхождение с перечнем — info ядра CODE_NOT_IN_EDITION, не needs_check: пятизначные подстроки 1С и нестандартные коды клиента не блокируют автоподтверждение). В пакете — упорядоченный перечень кодов каждой формы по редакциям (66н, ФСБУ 4) и вариантам, без наименований, в порядке бланка (итоги после слагаемых: 1110 … 1190, 1100; 2110, 2120, 2100). Данные перечня — contracts/statement-code-orders.yaml (источник; в пакете — его байтовая копия, сверяет sync-statements --check): объединение кодов шаблонов банка (словарь ядра) и кодов печатных форм 1С и сканов из fixtures/ (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; код без образца в перечень не входит (кандидаты — OQ-168), упрощённые варианты — по бланку (OQ-166). Законные повторы кода (Р-20, решение владельца 18.10.2026) — часть перечня (repeats): у fsbu4 — 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощённой системе налогообложения» (печатная форма 1С, AURORA-1431), у 66n — раздел III некоммерческой организации после раздела III (aep_3). Алгоритм: четырёхзначные коды строк ok документа — последовательность; для каждой редакции перечня (66н и ФСБУ 4 — всегда обе: Межениновская за 3 кв. 2025 сдана на бланке 66н) — наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (повтор допустим столько раз, сколько код стоит в перечне); редакция — с более длинной (при равенстве — по эффективной дате: fsbu4 после 2024-12-31, OQ-145); уверенные коды — подтверждённые якоря; при нескольких наибольших подпоследовательностях подтверждены только общие коды. Пятизначный код подтверждён, если подтверждён его родитель (§7) и подстрока стоит после него. Повтор кода, которого нет в перечне (или копий больше, чем в перечне), у которого хотя бы одна копия неуверенна, — needs_review у неуверенной копии (Р-16); повтор из перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — needs_review строки, документ needs_check. Подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120) структура не ловит — это закрывает ядро: для строк с неуверенным кодом условие RECOGNIZED включает совпадение распознанного наименования со справочным (§9.3).

Совпадение наименований (ядро; S-4 с оговорками ревью, решение владельца 18.10.2026): обе строки — нижний регистр, ё → е, всё кроме букв и цифр → пробел, пробелы схлопываются; совпало, если строки равны, или более короткая (≥ 5 символов) входит в более длинную, или difflib.SequenceMatcher ≥ 0,8 — с любым справочным именем кода в любой редакции словаря ядра (у кода может быть несколько имён: синонимы и законные повторы — Р-20). Исключение: если хотя бы одно из сравниваемых имён начинается с «итого по разделу», совпадение — только равенство («итого по разделу i» входит в «итого по разделу ii» и похоже на него — без исключения подмена 1100 ↔ 1200 ↔ 1300 не ловилась бы именем). Кода нет в словаре или наименование пусто — не совпало; несовпадение — NAME_MISMATCH (info) у строки. По fixtures/ocr коды часто приходят с уверенностью 50 и 25 — требовать 100 нельзя.

Поля шапки. Дата отчёта с уверенностью ниже 100 подтверждена, если совпала с самой поздней абсолютной датой колонок; иначе report_date попадает в needs_review_fields и REPORT_DATE_UNCERTAIN (error) — неверно прочитанный год давал бы неверный период и автоподтверждение. У бланка 66н только с относительными подписями («На отчётную дату», …) абсолютных дат колонок нет, и неуверенная дата отчёта структурой не подтверждается никогда — такой документ всегда needs_check с REPORT_DATE_UNCERTAIN; аналитик сверяет дату с бланком и подтверждает её (PATCH header.report_date тем же значением или исправленным, §9.5 п. 9). ИНН и организация с уверенностью ниже 100 — info (их сверяет ядро: ИНН — MISMATCH с ЮЛ, организация — по алиасам), в needs_review_fields не попадают (needs_review_fields — поля, которые держат документ в needs_check; подсветка ИНН и организации на экране — по предупреждениям с field; S-10, решение владельца 18.10.2026); единицы и прочие поля на статус не влияют.

Предупреждение — структура {code, severity, message, line_refs?, column_index?, field?}; уровень файла (metrics.warnings) — ещё locator?. Место — locator документа (у предупреждения файла — его locator), не текст. line_refs — только ref строк из lines[] (о строках ролей title / header / numbering — в message); column_index — номер столбца сетки (§11.3 п. 8). Принцип: error — только то, что меняет данные или их полноту; error → needs_check, info на статус не влияет. Предупреждение уровня файла с error блокирует RECOGNIZED всех документов файла (§9.3).

Код Уровень Когда Чем снимается в ядре
JSON_FAILED error, файл json набора ИС ошибочен при успешном соседе перезагрузка
TABLE_PARSE_ERROR error, файл исключение при разборе таблицы ИС (locator таблицы; документа нет) перезагрузка
TABLE_LOST error, файл таблица ИС с содержимым без столбца кодов и без типа перезагрузка
CODE_COLUMN_NOT_FOUND error форма BALANCE / INCOME без столбца кодов перезагрузка
SEGMENT_WITHOUT_CODES error сегмент с содержимым без столбца кодов (в т. ч. лист-продолжение) перезагрузка
SHEET_NOT_JOINED error лист с ≥ 3 кодами и другим числом колонок не присоединился перезагрузка
CONTINUATION_TYPE_CONFLICT error тип маркера продолжения противоречит форме перезагрузка
SEGMENT_COLUMNS_MISMATCH error колонки сегмента не совпали с первым перезагрузка
COLUMN_WITHOUT_DATE error числовая колонка без разобранной подписи PATCH columns[].period_date
COLUMN_DATE_UNCERTAIN error неуверенная подпись не попала в ожидаемые даты PATCH columns[].period_date
HEADER_ROW_NOT_FOUND error заголовка нет ни у одного сегмента перезагрузка
TYPE_CONFLICT error тип по кодам противоречит type_hint перезагрузка
NO_ORGANIZATION_NO_INN error нет ни организации, ни ИНН PATCH header.inn / organization_name
PERIOD_UNKNOWN error нет ни даты отчёта, ни абсолютных дат колонок PATCH header.report_date
REPORT_DATE_UNCERTAIN error неуверенная дата отчёта не совпала с датами колонок (у бланка только с относительными подписями — всегда) PATCH header.report_date (в том числе тем же значением — подтверждение)
XLSB_SCALE_SUSPECT error xlsb без форматов, «тыс. руб.», ≥ 10 % дробных перезагрузка
INN_NOT_FOUND, INN_UNCERTAIN info ИНН не найден / неуверен при найденной организации —
ORGANIZATION_NOT_FOUND, ORGANIZATION_UNCERTAIN info организация не найдена / неуверенна при найденном ИНН —
REPORT_DATE_UNPARSED info дата отчёта не разобрана при наличии дат колонок PATCH header.report_date
UNITS_NOT_FOUND info единицы не найдены —
FILE_TYPE_MISMATCH info, файл формат файла не совпал с file_type —
COLUMN_SKIPPED info подпись из словаря исключений пропущена —
FRACTIONAL_IN_THOUSANDS info дробные при «тыс. руб.» (кроме xlsb без форматов) —
XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED info числовые форматы недоступны или не разобраны —
VALUE_OUT_OF_RANGE info число Excel длиннее 16 целых цифр —
SHEET_OTHER info, файл лист Excel «прочее» без типа и кодов (locator = sheet:<имя>; документа нет) —
ROW_SPLIT info разлеплена склеенная строка —
OSV_* info предупреждения листа ОСВ (§1) —
CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH info, ядро проверки ядра (§9.3, §9.5) пересчёт
RESULT_STATUS_INCONSISTENT info, ядро document.status = done из результата противоречит строкам, needs_review, нарушениям или предупреждениям документа — ядро считает его needs_check (§9.3) пересчёт после правки
MAPPING_WARNING info, ядро предупреждение маппера 1.x по документу (повтор кода, значение вне колонок) —
LEGACY_WARNING info, файл строка metrics.warnings результата 1.x (recognition_job.metrics) —
RELATION_FAILED / RELATION_SOFT error / info код записи document_issue вида relation / soft — нарушение §7 из checks (или пересчёта ядра) с codes, relation, expected, actual пересчёт после правки; relation — ещё акцепт

Назначение кода и отметка detail снимают нарушения, пересчитываемые §7. Новый код предупреждения вводится словарём пакета и строкой этой таблицы; в контракте code — строка, не перечисление.

Статус документа — одна функция общего кода: done ⇔ раскладка найдена ∧ (организация или ИНН) ∧ период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки ok или detail ∧ нет значений, строк и полей шапки с needs_review ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений документа уровня error; иначе needs_check. DISCLOSURE — done без условий о раскладке, строках и проверках. Квартал периода, сверка ИНН и наименований — знание ядра (§9.3), в done не входят. У ОСВ document.status = done, когда контрольные суммы §1.4 листа сошлись (§1.6, S-22), её предупреждения — info.

Интерфейс функции статуса (K-14) — один для сервиса и ядра, на входе только данные, без типов источника: document_type; layout_found (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть хотя бы одна строка из источника, row_index не null); has_organization_or_inn; period_known (дата отчёта или абсолютная дата хотя бы одной колонки — у ядра document.reporting_date не null); строки {line_status, needs_review}; значения {needs_review}; число жёстких нарушений вне допуска и округления; accepted (акцепт — жёсткие нарушения не учитываются); уровни предупреждений документа (у ядра — записи document_issue вида warning с severity = error). needs_review строк и значений на входе уже учитывает подтверждения: у ядра правленое (is_modified, line_code_modified) или подтверждённое (is_confirmed) — false. Сервис собирает вход из FormCandidate и результата шагов 2–7, ядро — из строк БД; так один код даёт один статус.

Ожидание на реальных сканах (fixtures/ocr, оценка ревью спеки 18.10.2026, K-4): после Р-20 (повторы 1220 / 1520 / 2410 в перечне fsbu4 и синонимы в словаре имён) и Р-21 (мягкие соотношения ОФР) законные повторы и строки 24xx / 25xx перестают держать документ. needs_check остаётся там, где есть что смотреть: строки unreadable / missing с суммами (aep_3), значения с уверенностью ниже 100 вне соотношений и в несошедшихся соотношениях, разлепленные строки без покрытия, неуверенная дата отчёта. Доля done на текущих шести документах ожидается небольшой; golden-эталоны fixtures/ocr/expected_normalize/ пересчитываются в PR scan-gateway (шаг 5 выкладки), фактическая доля — в его описании.

Статус конверта: done ⇔ все документы done ∧ нет предупреждений файла уровня error; иначе needs_check; документов нет — error (RECOGNITION_FAILED у scan-gateway, INVALID_DOCUMENT у tabular-parser — коды описывают разные причины). Порядок документов tabular-parser — сначала ОСВ в порядке листов, затем формы. Статус конверта в условие RECOGNIZED ядра не входит (Р-19).

§11.10 Что отменено и заменено этим параграфом

Прежнее правило Теперь
Строки без кода не отдаются и не хранятся (OQ-36, OQ-141; §12 п. 5) статусы detail / missing / unreadable, хранятся (Р-8); OQ-36 и OQ-141 закрыты
Двойники — только в датах (§11.1 п. 1, решение № 22 среза 3a) двойники в датах, подписях, кодах и суммах (Р-11); ИНН — без двойников
Три и более знаков после разделителя → amount = null (решение № 30 среза 3a) сохраняется для OCR-текста; для числа Excel — квантование до 2 знаков
Разлепленная строка → документ needs_check (решение владельца 08.10.2026 по OQ-156) значения нуждаются в покрытии §7, ROW_SPLIT — info (Р-17)
Повтор кода: остаётся первая строка (решение № 15 среза 3a, §12 п. 5, OQ-152) все строки ok, сумма по коду (Р-4)
Наименование — из справочника шлюза, уверенность кода ≤ 99 вне справочника (§11.1 п. 6, п. 10) наименование из источника, уверенность от источника (Р-3)
Без шапки — синтез шапки и needs_check (рулинг 06.10.2026) правило строки заголовка §11.3 п. 4
Подпись без периода не читается (§12 п. 4) колонка без даты и error (Р-6)
Закрывающая } / ] не даёт минус (решение № 12, OQ-147) } / ] — закрывающая скобка, is_recognized = false
Длинное тире → is_recognized = false (решение № 20, OQ-148) прочерк любого вида — null, is_recognized = true (OQ-148 закрыт решением владельца 18.10.2026, Р-22 / S-7)
is_recognized OCR = уверенность 100 ∧ ничего не выброшено; любое предупреждение → needs_check is_recognized — только разбор; статус — §11.9
checks ядро игнорирует и пересчитывает §7 при загрузке хранит как пришли, пересчитывает только после правок

§11.11 Отложено

Восстановление строк по наименованию (OQ-149); сопоставление формы без столбца кодов по именам строк (OQ-139); склейка страниц из разных файлов (OQ-140) и половин формы из одного файла (OQ-165, Р-15); приведение единиц сверх числового формата (OQ-138); формулы §7 по редакциям и знаки 2410/2420 (OQ-12, OQ-146); образец упрощённой формы (OQ-166); коды перечня без образца в fixtures (OQ-168); несколько периодов в одном файле ИС (OQ-167, допущение владельца); переключатель «как в файле» на экране (Р-14).

Что это даёт ядру: один маппинг statementDocument → document + financial_statement_* + document_issue для PDF и Excel; экран «Проверка распознавания» показывает статусы строк и предупреждения у места.


§12. ББ и ОФР из Excel — см. §11

Правила разбора форм из Excel (tabular-parser) с контракта v2.0 — часть общей цепочки нормализации ББ/ОФР: источник Excel — §11.2, раскладка — §11.3, строки — §11.4, значения и числовой формат ячейки — §11.2 п. 3 и §11.5, тип и вариант — §11.7, самопроверка — §7 и §11.8, статус — §11.9. Прежний текст §12 (Срез 2b, контракт v1.2) — в истории git, коммит 43b4b80. Ссылки «§12 п. N» в старых документах: п. 1 → §11.2 п. 1; п. 2 → §11.3 п. 1; п. 3 → §11.3 п. 7; п. 4 → §11.3 п. 3, п. 6; п. 5 → §11.2 п. 3, §11.4, §11.5; п. 6 → §11.2 п. 2; п. 7 → §7, §11.8; п. 8 → §11.7 п. 1; п. 9 → §11.2 п. 4; п. 10 → §11.9; п. 11 → §11.11.