arm-ka-docs/context/05-domain-rules.md
Andrey Epifancev 45ee08c0f8 docs: §1.3/§1.5/§3/§9 по факту маппинга, архитектура (цикл маппинга), OQ, план срезов, qa-guide
05-domain-rules: §1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 (загрузка, маппинг, автозамена, подтверждение, удаление, аудит).
03-architecture: цикл маппинга, порядок блокировок, схема ключей S3. 07-open-questions: закрыты OQ-67/69/70/59/109,
новые OQ-110…116. 09-delivery-plan: Срез 2c по факту. dev/qa-guide: раздел 6.6 «Загрузка ОСВ и просмотр строк через API».
tasks.md: 2.1 выполнен.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 00:11:52 +04:00

85 KiB
Raw Blame History

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1.3 Строки

Для каждой строки табличной части: row_number (исходный номер), level (outline level Excel, в xls — rowinfo_map; без группировок — каскад §1.0 п. 5), row_value (текст первой колонки), шесть сумм, ИНН (только из колонки ИНН; в ячейке оставляются цифры, результат только при 10 или 12 цифрах — «ИНН 352819549855» → 352819549855, иначе NULL; число из 9/11 цифр — ИНН региона 01–09 с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН внутри текста строки контрагента («Полиграф, 3528315710») парсер не извлекает: counterparty_name = весь текст строки, [открыто] OQ-100), 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).
  • По каждому родителю: суммы родителя = Σ детей того же 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). Если корня нет (пустая ОСВ: заголовок и сразу «Итого» — в фикстурах две, 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

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 запроса; несовпадение — предупреждение. Листы форм ББ/ОФР (§12 п. 1) и листы без признаков ОСВ пропускаются с предупреждением (sheet=<имя> skipped: statement (slice 2b) / skipped: нет признаков ОСВ). Другие предупреждения: sheet=<имя> hierarchy=<стратегия> (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение «xlsb: outline level недоступен»).

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

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


§2. Приведение периодов (DOC-259006)

Комплект (ББ + ОФР + ОСВ) за один период нарастающим итогом. Если ОСВ квартальные (is_cumulative = false), перед консолидацией строится расчётное представление (не хранится):

opening_* = opening_* первой ОСВ года (Q1)
turnover_* = Σ turnover_* по кварталам Q1..Qn
closing_* = closing_* последней ОСВ (Qn)

Ключ соединения строк между кварталами — (счёт, контрагент, [договор]). [открыто]: поведение при отсутствии промежуточного квартала; агрегировать на уровне контрагента или договора.


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

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

  1. Для каждой компании периметра: document типа OSV со статусом CONFIRMED за year/quarter версии, входящие в consolidation_document версии (если версия ещё не зафиксировала документы — последние подтверждённые).
  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 (ручное сопоставление). 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.

Ручное сопоставление (алгоритм в DOC-221278): список кандидатов = компании ГСК и ГК; поиск по ИНН/имени/алиасам; выбор → проверка дубля normalized_alias у другой компании (предложить перенести или отменить) → создать legal_entity_alias → osv_entry.counterparty_id = выбранное; counterparty_name/inn не трогать → audit_event(OSV_COUNTERPARTY_MAP) → автоприменить к другим строкам версии с тем же именем, если counterparty_id пуст, алиас однозначен и ИНН строки не противоречит.

Наблюдение по фикстурам: у «Счастливочной» контрагент «Русский бисквит» идёт с ИНН 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 как детализацию.


§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_*).

Когда: после распознавания; после «Сохранить» правок; при «Подтвердить данные». Непройдено → document.status = NEEDS_CHECK; пройдено/акцептовано + нажато «Подтвердить» → CONFIRMED + audit_event(DOCUMENT_CONFIRM). Отрицательные значения в формах — в скобках (2100) → -2100. Значения с confidence < 100 подсвечиваются.


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

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

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

КФО (ТЗ 6.4, Прил. 10): по каждому коду строки ББ/ОФР: sum_amount = Σ по компаниям (значения отчётной колонки ББ / ОФР за период, в тыс. руб.) → eliminated_auto (по §4–5, пересчёт в тыс. руб.) → eliminated_manual = Σ correction APPLIED → goodwill_adjust → consolidated_amount; vgo_amount = eliminated_auto + eliminated_manual; разрез kfo_result_line_company (amount, share_percent = доля компании в консолидированном показателе). Эталон колонок — Консолидация_Группа РБ.xlsx, лист 01.10.2024: «значение на дату | в т. ч. взаимные обязательства | очищенные показатели | доля в показателе по группе».

Пороги (ТЗ 6.8, FNR03 шаги 4–5), kfo_result.threshold_*:

  • ББ: если 1300 < 0.1 × 1600 → Σ расхождений ≤ 1 % × 1600 (ВБ), иначе ≤ 10 % × 1300 (ЧА).
  • ОФР: если 2200 / 2110 < 10 % или отрицательна → ≤ 1 % × 2110 (выручка), иначе ≤ 10 % × 2200. Превышение → threshold_justification обязателен (принятие риска), подсветка.

Сравнение с КФО клиента (scenario = CONFIRM): client_kfo_line построчно: client_amount, client_vgo_amount (ручной ввод), bank_amount, bank_vgo_amount (из kfo_result_line), deviation_percent к наименьшему, причина.


§9. Статусные модели

9.1 КФО (consolidation_status, DOC-037734 + ТЗ 6.10)

Из В Кто Когда
— INITIATED КА РФ / КА ГО создана карточка, зафиксирован периметр, сценарий, подход
INITIATED DOCUMENTS_PROCESSING система/КА загрузка и распознавание
DOCUMENTS_PROCESSING DATA_CORRECTION КА есть документы NEEDS_CHECK
DATA_CORRECTION CONSOLIDATION_RF КА РФ все обязательные документы CONFIRMED; инициатор — РФ
DATA_CORRECTION CONSOLIDATION_GO КА ГО инициатор — ГО
CONSOLIDATION_RF ADJUSTMENT система расхождение ≥ 5 % в парах (подстатус)
CONSOLIDATION_RF VERIFICATION_RF КА РФ «отправить на верификацию»
VERIFICATION_RF CONSOLIDATION_GO РКА РФ подтвердил
VERIFICATION_RF CONSOLIDATION_RF РКА РФ вернул (комментарий обязателен)
CONSOLIDATION_GO CONSOLIDATION_RF КА ГО вернул на доработку (комментарий)
CONSOLIDATION_GO VERIFICATION_GO КА ГО если инициатор — КА ГО, верифицирует РКА ГО [открыто]
CONSOLIDATION_GO / VERIFICATION_GO VERIFIED КА ГО / РКА ГО внесены № и дата решения УОБ; версия read-only; пишется ex_snapshot
любой нефинальный INITIATED КА нужен другой периметр/сценарий/комплект
любой нефинальный CANCELLED КА аннулировано
VERIFIED — — только «новая версия» (previous_version_id)
из макета REJECTED, ARCHIVED ANL-035 — подтвердить с заказчиком

Каждый переход → consolidation_status_history + audit_event(CONSOLIDATION_STATUS_CHANGE).

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 консолидаций ещё нет, поэтому удаление разрешено, кроме файла с CONFIRMED документом и файла, заменившего другие (§9.5); поведение после появления консолидаций — OQ-110.

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

RECOGNIZED — задание done, checks парсера без нарушений, counterparty_check ≠ MISMATCH и нет предупреждений маппинга этого листа (повтор счёта в файле, строки без родителя, нарастающая по данным Q1); иначе NEEDS_CHECK (OQ-05 закрыт: RECOGNIZED нужен); «Подтвердить данные» → CONFIRMED; ERROR — зарезервирован (в Срезе 2c ядро документ в ERROR не создаёт: файл без разбираемой ОСВ получает ERROR на уровне файла). counterparty_check: ИНН шапки равен ИНН владельца файла → MATCH; ИНН нет — нормализованное имя шапки совпало с именем или алиасом владельца → MATCH; иначе MISMATCH; пустая шапка → UNKNOWN. period_check — UNKNOWN до Среза 4.

9.4 Задание (recognition_status): queued → published → processing → done | needs_check | error | timeout → dlq;

replay: dlq → queued (attempt + 1). max_attempts: OCR 1, парсер 3; reply_deadline OCR 5 мин.

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

  1. Загрузка (POST /legal-entities/{id}/files, право DOCUMENT_EDIT, ЮЛ в видимости): тип файла по расширению и сигнатуре (xls — BIFF8, xlsx/xlsb — zip, pdf, png, jpg); лимит UPLOAD_MAX_BYTES (50 МиБ); дедуп по sha256 в пределах ЮЛ среди файлов не SUPERSEDED. Принятый файл: объект S3 uploads/{file_id}/{job_id}/{имя}, stored_file(UPLOADED, version_no = 1), recognition_job(queued), audit_event(FILE_UPLOAD). Тип документа и период при загрузке не запрашиваются: их определяет парсер.
  2. Маппинг (фоновый цикл ядра, отдельный от inbox): задание done/needs_check + файл PROCESSING → проверка ключа результата (бакет и префикс results/{file_id}/{job_id}/, OQ-70) → чтение S3 вне транзакции → одна транзакция: прежние строки файла удаляются, на каждый лист ОСВ создаётся document (osv_account_code — нормализованный счёт шапки, period_year/quarter, is_cumulative по §1.5, locator), вставляются osv_entry (§1.3), резолвится counterparty_id (§3 п. 4), пишутся recognition_job.group_schema и metrics.mapping_warnings, файл → PROCESSED, выполняется автозамена. Второй лист того же файла с тем же счётом документа не создаёт (предупреждение, первый документ — NEEDS_CHECK). Повторяемые сбои (БД, S3) — до MAPPING_MAX_ATTEMPTS (3) попыток, затем файл ERROR (metrics.mapping_error); неповторяемые (ключ вне папки, нет объекта, результат другого задания, нет листов ОСВ) — сразу ERROR. Документы типов ББ/ОФР/прочее в результате игнорируются (Срез 2b/3).
  3. Автозамена. После успешного маппинга другие файлы того же ЮЛ (не SUPERSEDED), содержащие документ с теми же document_type, osv_account_code, period_year, period_quarter, переводятся в SUPERSEDED (superseded_by_file_id = новый файл); version_no нового = максимум заменённых + 1. Заменяется и файл с CONFIRMED документом (решение владельца 05.10.2026); документ без года или квартала в замене не участвует. Документы и строки заменённых файлов сохраняются, но по умолчанию не показываются. Если новый файл содержит лишь часть документов заменяемого, заменяемый уходит в SUPERSEDED целиком (OQ-06: замена файлом целиком; частный случай — OQ-115). Аудит: системное FILE_REPLACE (user_id = NULL, object_id — заменённый файл, after_value — новый, в description перечень документов и confirmed=true).
  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. Правка строк и корректировки — подсрез 2d/Срез 3.
  5. Удаление (DELETE /files/{id}, DOCUMENT_EDIT): запрещено при CONFIRMED документе (FILE_HAS_CONFIRMED_DOCUMENTS) и если файл заменил другие (FILE_SUPERSEDES_OTHERS); SUPERSEDED файл удалить можно. Удаляются строки, документы, задания и stored_file; объектам S3 ставится expires_at = now() (физически не стираются — OQ-111); аудит FILE_DELETE с карточкой файла в before_value.
  6. Аудит. FILE_UPLOAD, FILE_REPLACE, FILE_DELETE, DOCUMENT_CONFIRM (раздел PRIMARY_DOCUMENTS); смены статусов задания/файла в обмене и в цикле маппинга audit_event не пишут (OQ-55).
  7. Порядок блокировок (против взаимоблокировок): задание → файл → документы; автозамена дополнительно берёт advisory-lock по ЮЛ. Подробности — 03-architecture.md.

§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) — фильтр видимости. Матрица в ТЗ помечена «будет разработана» (сноска 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, ГСК, ГСЗ — необязательные.
  • Значения: 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, 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
Дубль в файле второй и далее ИНН в файле пропускается. ИНН считается «встречавшимся» только после успешных проверок типа и формата, поэтому «ФЛ», идущая после ИП с тем же ИНН, получает 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

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

Код Роль в строке Счётчик отчёта
UNSUPPORTED_TYPE, INVALID_INN, 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).

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



§11. Маппинг JSON ИС Сканирование (FNR01/FNR02) → формы

Формат ответа — по реальным файлам fixtures/ocr/aep_3.json, aep_3_ofr.json, aep_6.json (проверено 04.10.2026; ТЗ и первая редакция этого параграфа расходятся с ними — см. OQ-13, OQ-14, OQ-77):

  • Каталог ответа <job_id>/ содержит несколько json — по одному на распознанный документ файла (пример: aep_3.json — успех, aep_3_ofr.json — ошибка по второму документу того же PDF). Шлюз читает все json каталога и склеивает в один ответ (raw/{job_id}.json: {job_id, files[{name, response, parse_error}]}); ошибка одного файла не отменяет остальные.
  • Корень файла: {file_uid, error {code, message}, data_ocr}. Успех — error.code == "" (поле error не null, а {"code": "", "message": ""}); ошибка — непустой code (INVALID_DOCUMENT, FILE_READ_ERROR, INVALID_FORMAT, RECOGNITION_FAILED, UNKNOWN_ERROR), data_ocr с пустыми полями и table_structure: [].
  • data_ocr — объект (не список): label, organization, inn, units, date — каждый {form_name, confidence} (в form_name шумный OCR-текст; date.form_name ненадёжна, ИНН может быть мусором), table_structure — список таблиц {label {form_name, confidence}, rows[]}: label.form_name «Бухгалтерский баланс» → BALANCE, «Отчет о финансовых результатах» → INCOME. Наблюдается: в одном json может быть несколько таблиц разных форм (в aep_3.json и aep_6.json — баланс и ОФР), каждая со своим label; тип документа задаёт label. Разбиение одной формы на несколько таблиц (сегменты) в образцах не встречалось и не предполагается; если одна форма вдруг придёт двумя таблицами или label повторится — это нештатный случай: документ не создаётся молча, а уходит в NEEDS_CHECK с причиной (решение — Срез 3, OQ-77). Неизвестный label → OTHER.
  • rows[]: row_index, cells[] {column_index, value, confidence}; column_index не обязательно подряд (бывают 0, 1, 2, 4, 5), value — сырой OCR-текст (мусор в ячейках, переносы строк, скобки для отрицательных, пробелы-разделители тысяч). В aep_6.json rows[0] — строка-шапка («Наименование показателя», «Код», «На 30 июня 2025 г», …); в aep_3.json шапки нет — первая строка данных. Бывают строки без кода (расшифровки «в том числе…», OQ-36) и код-строка внутри ячейки наименования («АКТИВ / I. Внеоборотные активы / Гудвил» с кодом 1105).
  • confidence — числа 0–100 (в ТЗ — доли 0..1; в реальных файлах проценты) → numeric(5,2) как есть.

Маппинг (Срез 3): таблица table_structure[i] с label «Бухгалтерский баланс» / «Отчет о финансовых результатах» → document (BALANCE/INCOME, locator = pages:…) + financial_statement_meta (ИНН, имя, units, report_date, okud — из organization/inn/units/date.form_name, с низкой уверенностью → NEEDS_CHECK) + financial_statement_column (из шапки rows[0], если она есть; period_date распарсить из «На 30 июня 2025 г», is_current = первая; без шапки — колонки без дат, документ NEEDS_CHECK) + financial_statement_line (cells[1] код, cells[0] имя; только строки с кодом) + financial_statement_value (число: убрать пробелы/-, скобки → минус; confidence; is_recognized). Сверки: ИНН/имя vs legal_entity → document.counterparty_check; дата vs период версии → period_check.


§12. ББ и ОФР из Excel (tabular-parser, второй тип документа)

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

Принцип тот же, что в §1.0: структура ищется по содержимому, результат — тот же statementDocument контракта, что и у scan-gateway, чтобы ядро маппило ББ/ОФР одинаково независимо от источника (confidence = 100, is_recognized = true для табличного пути).

  1. Классификация листа: ячейки с «Бухгалтерский баланс» / «0710001» → BALANCE; «Отчет о финансовых результатах» / «Отчет о прибылях и убытках» / «0710002» → INCOME. На одном листе могут быть обе формы подряд (ББ, затем ОФР) — разрезать по второму заголовку; в одном файле несколько листов — несколько document. Листы ОСВ и форм в одном файле — допустимо.
  2. Колонка кодов строк: та, где ≥ 60 % непустых значений — четырёхзначные коды из допустимого набора (1110–1700 для ББ, 2110–2500 для ОФР, плюс 5xxx для расшифровок). Заголовок «Код» необязателен. Если колонки кодов нет — попытаться сопоставить по наименованиям показателей (словарь rules/statement_lines.yaml: «Основные средства» → 1150) с пометкой line_code_confidence < 100 и warning; агент-аналитик подтвердит.
  3. Колонка наименований: ближайшая текстовая слева от кодов; «Пояснения» (1–2 символа/цифры) слева от неё — не наименование.
  4. Колонки-периоды: ячейки заголовка справа от кодов, матчащие дату («На 31 декабря 2024 г.», «31.12.2024», «2024», «За 9 месяцев 2025 г.», «За январь–сентябрь 2025 г.») → financial_statement_column с period_date (для ОФР — дата конца периода) и is_current = самая поздняя. Число колонок — от 1 до 3+. Колонки без даты («Изменение», «%») — в extra.
  5. Значения: Decimal по правилам §1.2; скобки → минус; «–»/«-»/пусто → NULL. Единица измерения — из шапки («тыс. руб.», «млн руб.», ОКЕИ 384/385) → header.units; если не найдена — по порядку величины относительно ОСВ той же организации не угадывать, оставить NULL (ядро спросит).
  6. Шапка: организация, ИНН, дата, ОКПО/ОКОПФ/ОКФС/ОКВЭД — регулярками по области выше таблицы; как в §1.0 п. 6.
  7. Самопроверка: формулы §7 по каждой колонке; расхождение > 0 (в единицах формы) → needs_check с перечнем непройденных соотношений в metrics.warnings. Это не замена проверок ядра, а сигнал, что структура угадана неверно (например, перепутаны колонки периодов).
  8. Расшифровки к строкам (лист К балансу, рекомендуемые формы банка) — в первой очереди не разбирать; классифицировать как DISCLOSURE/OTHER и сохранить locator.

Что это даёт ядру: один маппинг statementDocument → document + financial_statement_* для PDF и Excel; экран «Проверка распознавания» для Excel-источника показывает слева не страницы, а рендер листа (или просто таблицу) — UI-решение вне парсера.