Refs #26 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
109 KiB
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/ — это примеры двух организаций, а не спецификация формата. Парсер обязан:
- Найти лист(ы) с ОСВ по содержимому: ячейка в первых 30 строках листа (
SHEET_MARKER_ROWS: шапка ведомости всегда над таблицей), матчащаяоборотно[- ]сальдовая ведомость(без учёта регистра, «ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколькоdocument. Листы без признаков ОСВ/ББ/ОФР — пропускать с warning, не падать. - Найти заголовок таблицы по содержимому: строка(и), где встречаются одновременно «Сальдо на начало…»,
«Оборот(ы) за период», «Сальдо на конец…» (с вариантами: «Нач. сальдо», «Обороты», «Кон. сальдо», «Дебет/Кредит»,
«Дт/Кт», «Д-т/К-т»). Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на
начало» (иначе заголовок не найден →
INVALID_FORMAT, §1.6). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет» в первой колонке, так и строкой «Субконто», так и перечислением через запятую (вариант 2). - Определить колонки сумм по тексту, а не по позиции: для каждой из трёх групп («начало», «оборот», «конец»)
найти подзаголовки «Дебет»/«Кредит» в следующей строке под ней (с учётом merged-диапазонов). Допустимы ОСВ без
«начала» (только обороты и конец), с одной свёрнутой колонкой сальдо (знак = сторона), с дополнительными
колонками («Количество», «Валюта», «ИНН», «КПП», «Код»). Неизвестные колонки — сохранять в
extraстроки, не терять. - Определить колонку текста строки — первая непустая слева от сумм (обычно A, но не обязательно); ИНН — колонка с
заголовком
ИННили колонка, где ≥ 80 % непустых значений — ровно 10/12 цифр (число из 9/11 цифр при этом выборе нулём не дополняется, иначе числовой КПП сошёл бы за ИНН — R-24). - Определить иерархию строк по outline level Excel; если уровней нет (файл пересохранён, csv, другая система) —
по отступу (ведущие пробелы /
alignment.indent), по жирности счёта, по шаблону кода счёта и, в крайнем случае, по факту «сумма детей = родитель». Стратегии применяются каскадом, выбранная стратегия пишется вmetrics.warningsкакsheet=<имя листа> hierarchy=<outline|indent|account_pattern|sums|trivial>. Строка «Итого» в каскаде не участвует (всегда уровень 0); при меньше чем двух строках данных (пустая ОСВ) —trivial. - Шапку документа (организация, счёт, период, «выводимые данные») искать регулярками по всем ячейкам выше заголовка
таблицы; отсутствие любого поля — не ошибка, а
null+ warning (ядро попросит аналитика уточнить при проверке). - Самопроверка результата (контрольные суммы §1.4) — единственный способ понять, что структура угадана верно.
Если тождества не сходятся — не «подгонять», а вернуть
needs_checkс диагностикой, какой шаг не уверен. - Правила парсера — данные, а не код: словари синонимов заголовков, группировок, периодов лежат в
tabular-parser/api/rules/*.yaml(osv_headers,osv_groups,periods,data_types) и расширяются без релиза. Новый формат ОСВ = новая фикстура + при необходимости новый синоним, а не новая веткаif.
Тесты парсера строятся по принципу «свойств»: для любого входного файла результат удовлетворяет инвариантам §1.4,
число строк-контрагентов ≥ 0, Итого = корень. Фикстуры — регрессия, не оракул формата.
1.1 Шапка — два варианта 1С (как примеры, не исчерпывающий список)
Вариант 1 (все наши фикстуры):
r1 <Наименование организации> ← владелец ОСВ (сверить с legal_entity → document.counterparty_check)
r2 Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г. ← счёт после «по счету», период после «за»
r4 Выводимые данные: БУ (данные бухгалтерского учета) ← data_type шапки
r7 Параметры: | Валюта БУ, НУ: RUB (или «руб.»)
r9 Счет | Сальдо на начало периода | Обороты за период | Сальдо на конец периода
r10 Контрагенты | [ИНН] | Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
r11 Договоры ← может отсутствовать
Группировки — отдельные строки первой колонки блока заголовка (Счет, Подразделение, Контрагенты, Договоры),
порядок строк = порядок вложенности. Ячейка ИНН в той же строке, что Контрагенты → has_inn = true.
Вариант 2 (встречается у других клиентов):
Оборотно-сальдовая ведомость по счету 60
Период: 1 квартал 2026 г.
Детализация по субсчетам, субконто: Контрагенты, Договоры ← группировки через запятую, первая неявно ACCOUNT
Выводимые данные: сумма
Субконто | Сальдо на начало периода | Оборот за период | Сальдо на конец периода
| Дебет | Кредит | Дебет | Кредит | Дебет | Кредит
Нормализация значений шапки в типы:
| Текст в шапке | group_type |
|---|---|
| Счет, Счета, Субконто (первая) | ACCOUNT |
| Подразделение, Подразделения | DEPARTMENT |
| Контрагент, Контрагенты | COUNTERPARTY |
| Договор, Договоры, Договоры контрагентов | CONTRACT |
| прочее (Валюта, Номенклатура, Статьи…) | OTHER |
Результат шапки → recognition_job.group_schema (по листу): {"locator":"sheet:Лист_1","account":"60", "period_text":"1 квартал 2025 г.","data_type":"BU","groups":[{"type":"ACCOUNT"},{"type":"COUNTERPARTY","has_inn":true}, {"type":"CONTRACT"}]} и → document(document_type=OSV, osv_account_code, period_year, period_quarter, is_cumulative, locator).
Шапка без меток и без строки организации (реализовано, PR tabular-parser #13, OQ-134; ОСВ 63 Новоросметалл). Период
может стоять отдельной ячейкой («Бухгалтерский | 9 месяцев 2025 г.»: periods.yaml — standalone, data_types.yaml —
standalone_header), организация — хвостом названия ведомости, а колонки счёта — подписями «Счет: Код / Наименование»
(osv_headers.yaml — column_captions: «Код» и «Наименование» под группой «Счет» не группировки). Код счёта берётся из
первой строки-счёта таблицы, если «по счету …» в шапке нет, — с предупреждением «код счёта … из строки таблицы». Текст
строки может стоять правее колонки текста: уровень задаёт колонка, в которой он найден.
1.2 Колонки сумм
Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту
(§1.0 п. 3). Всё правее последней колонки «Кредит» — не часть таблицы (в фикстурах там встречаются посторонние
числа): игнорируется, в суммы и в extra не попадает. В rows[].extra (контракт v1.1) идут только колонки внутри
таблицы, не сопоставленные с шестью суммами и ИНН (ключ — заголовок колонки: «КПП», «Количество»…). Числа могут быть numeric или текстом («1 234 567,89», «(1 234)»,
«-», «—», неразрывные пробелы) — нормализовать в Decimal; пустая ячейка/прочерк = NULL (не 0). Отрицательные возможны.
1.3 Строки
Для каждой строки табличной части: row_number (исходный номер), level (outline level Excel, в xls — rowinfo_map; без группировок — каскад §1.0 п. 5),
row_value (текст первой колонки), шесть сумм, ИНН (только из колонки ИНН; в ячейке оставляются цифры, результат
только при 10 или 12 цифрах — «ИНН 352819549855» → 352819549855, иначе NULL; число из 9/11 цифр — ИНН региона 01–09
с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке
приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем строка без сумм прямо над названием на том же уровне
оформления (в rows не попадает, предупреждение «ИНН … над строкой … отнесён к ней»), затем конец текста названия
(«Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»).
Из текста ИНН отрезается всегда: counterparty_name без ИНН и разделителя, row_value — исходный текст; только для строк
COUNTERPARTY, договоры и прочее не трогаются; правила — osv_headers.yaml (counterparty_inn_suffix, inn_only_row,
inn_kpp_cell); реализовано, OQ-100, OQ-130 (tabular-parser #9, PR #15; синтетика s17–s19),
data_type (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки).
Строки «Вал.», «Кол.» и трёхбуквенный код валюты («USD») — не уровень группировки, а вид сумм родителя:
group_type = OTHER, data_type = CURRENCY | QUANTITY, позицию в group_schema не занимают, не наследуют
counterparty_name/counterparty_inn (контрагента потребитель находит по родителю), account_code_raw наследуют от
ветки; их суммы остаются в шести полях, но в проверки родителя не входят (§1.4).
Классификация строки (п. 6–10 «Парсинг ОСВ»):
- Строка — счёт, если текст матчится
^\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. - Не-счёт: позиция в ветке = количество не-счётных предков + 1 → соответствующая группировка из
group_schema(после последнего ACCOUNT). Увеличение уровня — ребёнок, равный — сосед, уменьшение — конец группы. - После COUNTERPARTY дочерние продолжают классифицироваться по схеме (CONTRACT, затем OTHER).
- Строка «Итого» (level 0, последняя) — контрольная сумма всего документа; не сохранять как
osv_entry(OQ-01 закрыт). Парсер отдаёт её вrowsкакgroup_type = TOTAL(level 0, без родителя); ядро (Срез 2c) при маппинге строку TOTAL отбрасывает (osv_entryеё не содержит,TOTAL— не значение хранимогоgroup_type); вmetrics.rowsона не считается. - Все остальные строки сохраняются (п. 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); если корней уровня 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 запроса; несовпадение — предупреждение. Листы форм ББ/ОФР (§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; Срез 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 — квартал версии:
- есть привязанный документ с
is_cumulative = trueиperiod_quarter = n— он один, строки берутся как есть; - иначе нужны привязанные квартальные документы за каждый квартал
Q1..Qn; любого нет — пробел (Gap(missing_quarters)), строк пары в своде нет, пробел показывается в сводке (gaps[]) и в листе «Пробелы» экспорта; - нарастающий и квартальные одновременно — берётся нарастающий (он полнее по определению);
- 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!).
- Для каждой компании периметра:
documentтипа OSV, входящие вconsolidation_documentверсии (явная привязка, а не «последние подтверждённые»). Документ подходит версии, если ЮЛ — член периметра,period_year= год версии, и: нарастающий (ББ/ОФР — всегда) —period_quarter= квартал версии; квартальный —period_quarter≤ квартал версии; ОСВ заQ1подходит версии любого квартала года (§2 п. 4, OQ-124). Привязываются толькоCONFIRMEDдокументы незаменённых файлов. Точки привязки: подтверждение документа (ко всем версиям «в работе»), создание версии и добавление ЮЛ в периметр (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым позднимconfirmed_at); при привязке документ с тем же ключом у того же ЮЛ заменяется. Точки отвязки: автозамена файла (§9.5 п. 3), удаление ЮЛ из периметра. ВерсииCANCELLEDпривязок не меняют. Выбор источников и склейка кварталов — §2. - Строки
osv_entryэтих документов сgroup_type = COUNTERPARTY;data_type IN (BU, SUM). - Строки не агрегируются: сто строк по 60.01 у компании — сто строк в своде.
counterparty_name/innпоказываются как в файле. - Резолв контрагента (при маппинге результата — Срез 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) не ищется. - Признак контрагента: ГСК / ГК / Внешний (см.
04-data-model-guide.md). - Количество строк свода = количеству отобранных
osv_entryдля нарастающей ОСВ; для квартальных документов строки склеиваются по ключу (§2),entry_idsстроки свода перечисляет источники. - Дорезолв на чтение. Строка без
counterparty_idрезолвится на лету тем жеCounterpartyIndex; результат не сохраняется (производное, DECISION-5): в ответеmatch_stored = false,match_kind— шаг резолва. Сохранённое значениеosv_entry.counterparty_idприоритетнее; при расхождении между кварталами берётся значение самого позднего квартала (расхождение — вwarningsстроки). - Способ сопоставления
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 символов (иначе 422QUERY_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). - Пустое имя (после нормализации) — алиас не создаётся и автоприменения нет. Неоднозначность дорезолва при тёзке — 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 как детализацию.
§6. Шахматка и результирующая таблица (DOC-221277)
Вход: версия + набор дебетовых счетов (kind ∈ A, AP) и кредитовых (kind ∈ P, AP); предустановки: «58.03 vs
67.03 + 66.03», «60 + 62 + 76 vs 60 + 62 + 76», «все счета».
Строки/столбцы — компании периметра; диагональ пуста.
amount_a(A,B) = Σ closing_debit строк A с counterparty_id = B по дебетовым счетам
amount_b(A,B) = Σ closing_credit строк B с counterparty_id = A по кредитовым счетам
variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amount_b) * 100; > 5 % — подсветка
ячейка: amount_a / amount_b / (variance)
Фильтры строк — как в §3 (COUNTERPARTY, BU/SUM, account_id IS NOT NULL).
Результирующая таблица «компания против группы» по статьям: Дебиторка (Дт 60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76 кроме 76.07.5/76.07.9), Кредиторка (Кт 60.01, 60.03, 60.21, 60.31, 62.02, 62.22, 62.32, 76 кроме 76.07.1), ФВ (Дт 58.01, 58.03), Долгосрочные займы (Кт 67, 67.03), Краткосрочные (Кт 66, 66.03, 66.04); колонка «Итого по группе».
§7. Логические проверки ББ/ОФР (DOC-221281, FNR03 шаг 1)
Выполняются по каждой колонке-дате отдельно; ничего не меняют; результат не хранится.
ББ: 1700 − 1600 = 0; 1100 = 1110 + 1150 + 1160 + 1170 + 1180 + 1190 (+1120, 1130, 1140 если есть);
1200 = 1210 + 1220 + 1230 + 1240 + 1250 + 1260; 1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370;
1400 = 1410 + 1420 + 1430 + 1450; 1500 = 1510 + 1520 + 1540 + 1550; 1600 = 1100 + 1200; 1700 = 1300 + 1400 + 1500.
ОФР: 2100 = 2110 − 2120; 2200 = 2100 − 2210 − 2220; 2300 = 2200 + 2310 + 2320 − 2330 + 2340 − 2350;
2400 = 2300 − 2410 + 2420 (знаки 2410/2420/2430 — по ФСБУ 4/2023 уточнить [открыто]).
Межформенная: 1370(отчётная дата) − 1370(начало года) = 2400(ОФР за период) — при расхождении КА может акцептовать
с комментарием (financial_statement_meta.checks_accepted_*).
Парсер tabular-parser (§12 п. 7) считает жёсткими (нарушение → needs_check) соотношения ББ — 1100, 1200
(+1215), 1300, 1400, 1500, 1600 = 1100 + 1200, 1700 = 1300 + 1400 + 1500, 1600 = 1700 — и ОФР — 2100, 2200,
2300; мягкими (предупреждение) — 2400 = 2300 − 2410 + 2420 и варианты ФСБУ 4/2023 (2400 = 2300 + 2410 + 2460,
2410 = 2411 + 2412), 2500 = 2400 + 2510 + 2520 + 2530 и «сумма пятизначных подстрок = родитель»
(ASSUMPTION(OQ-12)). Допуск ±1 в единицах формы, расходы — двойной расчёт знаков (abs_terms). Межформенная проверка
1370 − 1370(начало года) = 2400 парсером не выполняется. Это сигнал о структуре и данных файла, а не замена проверок
ядра: ядро (Срез 3) пересчитывает §7 и ведёт акцепт (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).
Срез 4 реализует только переходы, не требующие маршрута согласования (consolidation_status.transition — единственное
место смены статуса; остальные переходы таблицы — Срез 7, вручную дают 409 TRANSITION_NOT_ALLOWED):
| Событие | Переход | Кто (changed_by) |
|---|---|---|
загрузка файла с consolidation_version_id, принят ≥ 1 файл |
INITIATED → DOCUMENTS_PROCESSING | загрузивший |
маппинг создал документ NEEDS_CHECK, подходящий версии |
DOCUMENTS_PROCESSING → DATA_CORRECTION | stored_file.uploaded_by (нет — переход пропускается, лог) |
| подтверждение документа, привязанного к версии | INITIATED / DOCUMENTS_PROCESSING → DATA_CORRECTION | подтвердивший |
POST …/status {CANCELLED, comment} (комментарий обязателен, 422 COMMENT_REQUIRED) |
любой «в работе» → CANCELLED | пользователь с блокировкой |
POST …/status {INITIATED} |
DOCUMENTS_PROCESSING / DATA_CORRECTION → INITIATED (привязки сохраняются) | пользователь с блокировкой |
«В работе» = INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION. CANCELLED снимает активную блокировку и is_in_work.
Загрузка и подтверждение блокировкой не ограничиваются; запрет загрузки/подтверждения после DATA_CORRECTION (ТЗ 03_9 A1) —
Срез 7 (OQ-118). Условие «все обязательные документы CONFIRMED» для DATA_CORRECTION → CONSOLIDATION_* — Срез 7.
9.2 Файл (file_status): UPLOADED → PROCESSING → PROCESSED | ERROR; любой → SUPERSEDED (замена файлом целиком,
superseded_by_file_id, version_no + 1). Статус меняют только по текущему заданию файла
(current_recognition_job_id): PROCESSING — ack сервиса (или результат done/needs_check, если ack потерян);
ERROR — задание error/timeout/dlq либо ошибка маппинга; PROCESSED — маппинг завершён; SUPERSEDED — автозамена.
PROCESSED и SUPERSEDED событиями обмена не перезаписываются. Физическое удаление: ANL-020 запрещает его после
INITIATED; в Срезе 2c консолидаций ещё нет, поэтому удаление разрешено, кроме файла с 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: подходящесть документа версии — функция (документ, версия), а один документ входит в несколько версий (§3 п. 1, Срез 4; OQ-120).
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)
- Загрузка (
POST /legal-entities/{id}/files, правоDOCUMENT_EDIT, ЮЛ в видимости): тип файла по расширению и сигнатуре (xls— BIFF8,xlsx/xlsb— zip,pdf,png,jpg); лимитUPLOAD_MAX_BYTES(50 МиБ); дедуп поsha256в пределах ЮЛ среди файлов неSUPERSEDED. Принятый файл: объект S3uploads/{file_id}/{job_id}/{имя},stored_file(UPLOADED, version_no = 1),recognition_job(queued),audit_event(FILE_UPLOAD). Тип документа и период при загрузке не запрашиваются: их определяет парсер. - Маппинг (фоновый цикл ядра, отдельный от 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). - Автозамена. После успешного маппинга другие файлы того же ЮЛ (не
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: автозамена снимает привязки (consolidation_document) всех документов заменённого файла из всех версий «в работе» (CANCELLEDне трогаются); версии, где привязка снята, —FILE_REPLACE.after_value.unbound_version_ids; новый документ привязывается только после подтверждения (§3 п. 1). - Подтверждение (
POST /documents/{id}/confirm,DOCUMENT_EDIT):RECOGNIZED/NEEDS_CHECK→CONFIRMED(confirmed_by/at,audit_event(DOCUMENT_CONFIRM)); повтор — 409ALREADY_CONFIRMED; документ заменённого файла — 409FILE_SUPERSEDED; прочие статусы — 409DOCUMENT_NOT_CONFIRMABLE. Правка строк и корректировки — подсрез 2d/Срез 3. - Удаление (
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. OQ-110 закрыт для Среза 4: к версии привязываются толькоCONFIRMEDдокументы, а файл с таким документом удалить нельзя — удалить файл с документом в версии невозможно; расширять удаление не требуется. - Аудит.
FILE_UPLOAD,FILE_REPLACE,FILE_DELETE,DOCUMENT_CONFIRM(разделPRIMARY_DOCUMENTS); смены статусов задания/файла в обмене и в цикле маппингаaudit_eventне пишут (OQ-55). - Порядок блокировок (против взаимоблокировок): задание → файл → документы (
FOR NO KEY UPDATE) → advisory-lock ГК → версии консолидации (FOR UPDATE, по возрастаниюid); автозамена дополнительно берёт advisory-lock по ЮЛ. Подробности —03-architecture.md. - Загрузка в контексте версии (
?consolidation_version_id=, Срез 4): до приёма — версия видима (404), «в работе» (409VERSION_NOT_EDITABLE), ЮЛ — член периметра (422ENTITY_NOT_IN_PERIMETER); после приёма хотя бы одного файла INITIATED → DOCUMENTS_PROCESSING (§9.1); сбой этого перехода не отменяет принятые файлы. Блокировка версии для загрузки не нужна.
§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,ГСК,ГСЗ— необязательные. - Значения: 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— тот же прогон с откатом, отчёт идентичен; в APIdry_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.jsonrows[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 для табличного пути).
Статус: реализовано (Срез 2b, tabular-parser feat/14, контракт v1.2; решения владельца 06.10.2026). Модули —
api/src/parser/statements/, словари — api/rules/statement_headers.yaml, statement_periods.yaml,
statement_checks.yaml; ожидания по реальным файлам — api/tests/statements_manifest.yaml (06-test-data §4б).
- Классификация листов: лист ОСВ → форма → продолжение → прочее. Форма: заголовок («Бухгалтерский баланс»,
«Отчет о финансовых результатах», «Отчет о прибылях и убытках») или ОКУД в любой строке до таблицы (у НПЗ он стоит
под титулом); титул «Бухгалтерская отчетность» — не тип, тип берёт ближайший следующий заголовок. Тип по ОКУД:
0710001 →
BALANCE, 0710002 →INCOME, прочие 0710xxx (0710003 капитал, 0710004 ДДС) и пояснения («Пояснения к бухгалтерскому балансу», «Расшифровка») →DISCLOSURE; при противоречии ОКУД и заголовка — тип по ОКУД и предупреждение. Две формы на одном листе — по второму заголовку, в книге несколько листов — несколькоdocument. Продолжение: лист без заголовка формы, но с маркером «Форма … с.2», либо с подписью «Код» и периодами правее, либо с колонкой кодов. ОСВ и формы в одной книге допустимы. - Колонка кодов: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx,
пятизначные — по первым четырём цифрам), кодов не меньше
min_codes= 3; заголовок «Код/Коды» необязателен, числа вида1110.0читаются как текст «1110». Колонки нет — документ без строк,needs_check, предупреждение «колонка кодов не найдена»; сопоставление по наименованиям строк не делается (решение владельца, OQ-138). - Колонка наименований — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не наименование.
- Колонки-периоды: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024»,
«За 9 месяцев 2025 г.», «За январь - сентябрь 2025 г.», «2024»), по порядку слева направо (
column_index0, 1, 2…);period_date— дата или конец периода;is_currentв контракте нет — ядро берёт самую позднюю. Заголовок в две строки («На» + «20__ г.», перенос внутри ячейки) склеивается; строка нумерации «1 2 3 4 5» пропускается. Колонки без периода («Изменение», «%») не читаются (одно предупреждение на документ): уstatementDocumentполяextraнет. - Значения:
Decimal, скобки → минус, «–»/пусто →null, нечисловой текст →string_value,is_recognized = false. Единицы не нормализуются:amountкак в ячейке,header.units— текст шапки; при «тыс.» и ≥ 10 % значений с дробной частью — предупреждение «N значений с дробной частью при единице тыс. руб.» (OQ-137, решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются (ASSUMPTION(OQ-36), OQ-140); повтор кода — остаётся первая строка, код вchecks.duplicate_line_codes,needs_check. Пятизначные подстроки (11101, 12301…) отдаются как обычные строки. - Шапка: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12
цифр у метки «ИНН» (ИНН аудитора не берётся: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес,
единицы,
report_date_text/report_date— из подзаголовка формы или тройки «число | месяц | год» у метки «Дата» (для ОФР — конец периода). Шапка другого листа не используется: у ОФР НПЗ нет метки «ИНН» →inn = null. Найденное поле —*_confidence = 100, не найденное —null. - Самопроверка: соотношения §7 по каждой колонке — данные
statement_checks.yaml. Жёсткие (нарушение →needs_check): ББ1100,1200(+1215),1300,1400,1500,1600 = 1100 + 1200,1700 = 1300 + 1400 + 1500,1600 = 1700; ОФР2100,2200,2300. Мягкие (только предупреждение,ASSUMPTION(OQ-12)):2400 = 2300 − 2410 + 2420и варианты ФСБУ 4/2023,2500, «сумма пятизначных подстрок = родитель». Допуск ±1 в единицах формы, отсутствующий код = 0, соотношение проверяется, только если присутствует его цель; правая часть считается дважды — как записано и с членамиabs_termsпо модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Результат —documents[].checks(relations_failed,soft_relations_failed,duplicate_line_codes) и предупреждения. Фактически сходятся полные формы только у Межениновской: книга НПЗ (npz/2025-Q3) — ББneeds_check:1600 = 1700расходится на 1.94 / 9.02 / 2.48 руб., потому что значения в рублях с копейками при шапке «тыс. руб.»; допуск ±1 остаётся (решение владельца 06.10.2026, вариант А, OQ-137). ОФР НПЗ и ОФР Унагранде (стр. 1) тожеneeds_checkпо 2100/2200/2300 по той же причине; ББ Унагранде (стр. 1) — только актив,1600 = 1700не сходится. Это сигнал о данных файла, а не о неверно угаданной структуре; допуск под файл не подгоняется. - Расшифровки (0710003/0710004, пояснения, лист «Примечания» шаблона) —
DISCLOSUREсlocatorи шапкой, если нашлась, безcolumns/lines; на статус не влияет (OQ-23). Книга только из таких листов —done. - Склейка: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок
сумм совпадает; строки добавляются в конец,
locatorперечисляет листы через запятую. Страницы в разных файлах не склеиваются (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой иneeds_check, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-139). Продолжение без колонки кодов при непустом результате не теряется молча —needs_check. - Статус: документ
ok⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов ∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга —done, еслиokу всех документов, иначеneeds_check; книга без листов ОСВ и форм —INVALID_DOCUMENT. Порядок документов: сначала ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) —needs_check. - Отложено: запасной путь по именам строк (OQ-138), нормализация единиц (OQ-137), признак продолжения в контракте и
склейка страниц из разных файлов в ядре (OQ-139, Срез 3), строки без кода (OQ-140), образцы ФСБУ (OQ-141), лист
продолжения с единственной кодовой строкой (OQ-142: лист ОФР НПЗ с одной строкой 2400 не распознаётся как форма и
теряется,
min_codes= 3).
Что это даёт ядру: один маппинг statementDocument → document + financial_statement_* для PDF и Excel (маппинг форм —
Срез 3); экран «Проверка распознавания» для Excel-источника показывает не страницы, а таблицу — UI-решение вне парсера.