Refs #26 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
901 lines
118 KiB
Markdown
901 lines
118 KiB
Markdown
# 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)`.
|
||
|
||
**Шапка без меток и без строки организации (реализовано, PR tabular-parser #13, OQ-134; ОСВ 63 Новоросметалл).** Период
|
||
может стоять отдельной ячейкой («Бухгалтерский | 9 месяцев 2025 г.»: `periods.yaml` — `standalone`, `data_types.yaml` —
|
||
`standalone_header`), организация — хвостом названия ведомости, а колонки счёта — подписями «Счет: Код / Наименование»
|
||
(`osv_headers.yaml` — `column_captions`: «Код» и «Наименование» под группой «Счет» не группировки). Код счёта берётся из
|
||
первой строки-счёта таблицы, если «по счету …» в шапке нет, — с предупреждением «код счёта … из строки таблицы». Текст
|
||
строки может стоять правее колонки текста: уровень задаёт колонка, в которой он найден.
|
||
|
||
### 1.2 Колонки сумм
|
||
|
||
Заголовки сумм объединены (merged) и позиции колонок плавают между файлами даже одной организации. Определять по тексту
|
||
(§1.0 п. 3). Всё правее последней колонки «Кредит» — не часть таблицы (в фикстурах там встречаются посторонние
|
||
числа): игнорируется, в суммы и в `extra` не попадает. В `rows[].extra` (контракт v1.1) идут только колонки **внутри**
|
||
таблицы, не сопоставленные с шестью суммами и ИНН (ключ — заголовок колонки: «КПП», «Количество»…). Числа могут быть numeric **или** текстом («1 234 567,89», «(1 234)»,
|
||
«-», «—», неразрывные пробелы) — нормализовать в Decimal; пустая ячейка/прочерк = NULL (не 0). Отрицательные возможны.
|
||
|
||
### 1.3 Строки
|
||
|
||
Для каждой строки табличной части: `row_number` (исходный номер), `level` (outline level Excel, в xls — `rowinfo_map`; без группировок — каскад §1.0 п. 5),
|
||
`row_value` (текст первой колонки), шесть сумм, ИНН (**только из колонки ИНН**; в ячейке оставляются цифры, результат
|
||
только при 10 или 12 цифрах — «ИНН 352819549855» → `352819549855`, иначе NULL; число из 9/11 цифр — ИНН региона 01–09
|
||
с потерянным ведущим нулём — при извлечении из выбранной колонки дополняется нулём слева, R-24; ИНН контрагента — в порядке
|
||
приоритета: колонка ИНН (в том числе склеенная «ИНН/КПП»), затем строка без сумм **прямо над** названием на том же уровне
|
||
оформления (в `rows` не попадает, предупреждение «ИНН … над строкой … отнесён к ней»), затем конец текста названия
|
||
(«Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)» — 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»).
|
||
Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст; только для строк
|
||
`COUNTERPARTY`, договоры и прочее не трогаются; правила — `osv_headers.yaml` (`counterparty_inn_suffix`, `inn_only_row`,
|
||
`inn_kpp_cell`); **реализовано**, OQ-100, OQ-130 (tabular-parser #9, PR #15; синтетика `s17`–`s19`),
|
||
`data_type` (строки «USD», «Вал.», «Кол.» под контрагентом → CURRENCY/QUANTITY; по умолчанию — из шапки).
|
||
|
||
Строки «Вал.», «Кол.» и трёхбуквенный код валюты («USD») — не уровень группировки, а вид сумм родителя:
|
||
`group_type = OTHER`, `data_type = CURRENCY | QUANTITY`, позицию в `group_schema` не занимают, **не наследуют**
|
||
`counterparty_name`/`counterparty_inn` (контрагента потребитель находит по родителю), `account_code_raw` наследуют от
|
||
ветки; их суммы остаются в шести полях, но в проверки родителя не входят (§1.4).
|
||
|
||
Классификация строки (п. 6–10 «Парсинг ОСВ»):
|
||
1. Строка — счёт, если текст матчится `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` (флаги `re.ASCII | re.DOTALL`:
|
||
цифры только 0–9, название после запятой может быть многострочным; регистр учитывается;
|
||
сегмент субсчёта — 1–2 цифры **или 1–3 заглавные буквы**: буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ» встречаются в
|
||
8 реальных файлах; после запятой — опциональное название, пробел необязателен: «60.01, Расчеты с поставщиками»)
|
||
→ ACCOUNT, `account_code_raw` — код как в файле без названия, `account_code` (нормализованный) строит ядро, все
|
||
дочерние строки наследуют счёт. Остаточный риск: имя контрагента вида «12.ООО» будет принято за счёт. Счета с буквой в
|
||
**первом** сегменте (забалансовые «МЦ.04», «ГТД», «НЕ.01.1») не распознаются как ACCOUNT — **[открыто]** OQ-103.
|
||
2. Не-счёт: позиция в ветке = количество не-счётных предков + 1 → соответствующая группировка из `group_schema`
|
||
(после последнего ACCOUNT). Увеличение уровня — ребёнок, равный — сосед, уменьшение — конец группы.
|
||
3. После COUNTERPARTY дочерние продолжают классифицироваться по схеме (CONTRACT, затем OTHER).
|
||
4. Строка «Итого» (level 0, последняя) — контрольная сумма всего документа; не сохранять как `osv_entry`
|
||
(OQ-01 закрыт). Парсер отдаёт её в `rows` как `group_type = TOTAL` (level 0, без родителя); **ядро (Срез 2c)
|
||
при маппинге строку TOTAL отбрасывает** (`osv_entry` её не содержит, `TOTAL` — не значение хранимого `group_type`); в
|
||
`metrics.rows` она не считается.
|
||
5. Все остальные строки сохраняются (п. 11 «Парсинг ОСВ») с `parent_entry_id` по `parent_row_number` (OQ-01 закрыт):
|
||
дерево строится по номеру родителя, строка с несуществующим родителем сохраняется с `parent_entry_id = NULL` и даёт
|
||
предупреждение маппинга (документ `NEEDS_CHECK`). Суммы и `data_type` (включая `NU`) пишутся как в результате.
|
||
|
||
Нормализация кода счёта (ядро, `domain/osv_mapping.py`): `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1` —
|
||
числовой **второй** сегмент (1–3 цифры) дополняется нулём слева до двух цифр, первый и третий сегменты и буквенные
|
||
(`76.АВ`, `МЦ.04`) остаются как в файле. `account_code` строки хранится как в результате (`account_code_raw`),
|
||
нормализация нужна для `document.osv_account_code` и сверки с планом счетов. `account_root` = первый сегмент кода, если он
|
||
цифровой (`66`, `001` — забалансовый), иначе NULL (`ASSUMPTION(OQ-103)`). `account_id` = точное совпадение
|
||
нормализованного кода с `chart_of_account.code`, иначе NULL (администратор добавит; в seed плана счетов нет части
|
||
субсчетов — OQ-113).
|
||
|
||
### 1.4 Контрольные суммы парсера
|
||
|
||
Допуск ±0.01, пустое значение = 0.
|
||
- По каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit == closing_debit − closing_credit` (±0.01).
|
||
- По каждому родителю: суммы родителя = Σ детей того же `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` — квартал версии:
|
||
|
||
1. есть привязанный документ с `is_cumulative = true` и `period_quarter = n` — он один, строки берутся как есть;
|
||
2. иначе нужны привязанные квартальные документы за **каждый** квартал `Q1..Qn`; любого нет — **пробел**
|
||
(`Gap(missing_quarters)`), строк пары в своде нет, пробел показывается в сводке (`gaps[]`) и в листе «Пробелы» экспорта;
|
||
3. нарастающий и квартальные одновременно — берётся нарастающий (он полнее по определению);
|
||
4. **Q1-эквивалентность ОСВ (R-14, design №81; OQ-124):** документ ОСВ за `Q1` равнозначен и нарастающему, и квартальному
|
||
(парсер помечает ОСВ за Q1 как нарастающую: период с начала года = Q1) — в версиях Q2+ он служит квартальным источником
|
||
Q1. ББ/ОФР за Q1 версиям Q2–Q4 не подходят (R-14a: баланс на конец Q1).
|
||
|
||
**Склейка квартальных строк** — ключ `(account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn))`;
|
||
договоры (`group_type = CONTRACT`) в свод не входят — строка COUNTERPARTY уже содержит их сумму:
|
||
|
||
```
|
||
opening_* = opening_* строки Q1
|
||
turnover_* = Σ turnover_* по кварталам Q1..Qn
|
||
closing_* = closing_* строки Qn
|
||
```
|
||
|
||
Отсутствующая в квартале строка ключа = нули; `None` в ячейке = 0; две строки с одним ключом внутри квартала
|
||
суммируются (design №1); `entry_ids` строки свода перечисляет все источники. Нарастающий документ отдаётся строка в
|
||
строку (OQ-125: различие с квартальным Q1 версии Q1 — только на дублях ключа и `None`). Строки свода сортируются:
|
||
имя ЮЛ, счёт (натуральный порядок: `9 < 60 < 60.2 < 60.10`), номер строки.
|
||
|
||
---
|
||
|
||
## §3. Сводная ОСВ и сопоставление контрагентов (DOC-221278, FNR01 п. 9)
|
||
|
||
Вход: `consolidation_version_id`. Периметр — `consolidation_member` версии (не текущий `related_group_member`!).
|
||
|
||
1. Для каждой компании периметра: `document` типа OSV, входящие в `consolidation_document` версии (**явная привязка**, а не
|
||
«последние подтверждённые»). Документ **подходит** версии, если ЮЛ — член периметра, `period_year` = год версии, и:
|
||
нарастающий (ББ/ОФР — всегда) — `period_quarter` = квартал версии; квартальный — `period_quarter` ≤ квартал версии;
|
||
ОСВ за `Q1` подходит версии любого квартала года (§2 п. 4, OQ-124). Привязываются только `CONFIRMED` документы
|
||
незаменённых файлов. Точки привязки: подтверждение документа (ко всем версиям «в работе»), создание версии и добавление
|
||
ЮЛ в периметр (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`);
|
||
при привязке документ с тем же ключом у того же ЮЛ заменяется. Точки отвязки: автозамена файла (§9.5 п. 3), удаление ЮЛ
|
||
из периметра. Версии `CANCELLED` привязок не меняют. Выбор источников и склейка кварталов — §2.
|
||
2. Строки `osv_entry` этих документов с `group_type = COUNTERPARTY`; `data_type IN (BU, SUM)`.
|
||
3. Строки **не агрегируются**: сто строк по 60.01 у компании — сто строк в своде. `counterparty_name/inn` показываются
|
||
как в файле.
|
||
4. Резолв контрагента (при маппинге результата — Срез 2c, и повторно при построении свода, если `counterparty_id IS NULL`):
|
||
ИНН → `legal_entity.inn`; иначе точное имя → `legal_entity.name`; иначе `normalize(name)` →
|
||
`legal_entity_alias.normalized_alias`. Несколько кандидатов → NULL (ручное сопоставление).
|
||
`normalize` = lower, trim, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы.
|
||
Реализация при маппинге (`repositories/counterparties.py:CounterpartyIndex`): справочник читается целиком **без policy
|
||
видимости** (контрагент может быть ЮЛ другого филиала); применяется к строкам `group_type = COUNTERPARTY` с
|
||
`data_type ∈ {BU, SUM}`; ИНН строки сравнивается точно после удаления пробелов (мусор вроде «нерез.» не ищется, строка
|
||
проваливается к имени); каждый шаг считает кандидатов отдельно: единственное точное совпадение имени побеждает
|
||
неоднозначность нормализованного; имя из одних ОПФ (пусто после `normalize`) не ищется.
|
||
5. Признак контрагента: ГСК / ГК / Внешний (см. `04-data-model-guide.md`).
|
||
6. Количество строк свода = количеству отобранных `osv_entry` для нарастающей ОСВ; для квартальных документов строки
|
||
склеиваются по ключу (§2), `entry_ids` строки свода перечисляет источники.
|
||
7. **Дорезолв на чтение.** Строка без `counterparty_id` резолвится на лету тем же `CounterpartyIndex`; результат **не
|
||
сохраняется** (производное, DECISION-5): в ответе `match_stored = false`, `match_kind` — шаг резолва. Сохранённое значение
|
||
`osv_entry.counterparty_id` приоритетнее; при расхождении между кварталами берётся значение самого позднего квартала
|
||
(расхождение — в `warnings` строки).
|
||
8. **Способ сопоставления** `osv_entry.counterparty_match_kind` (DBML v5.16): `INN`/`NAME`/`ALIAS` — шаг, на котором при
|
||
маппинге нашёлся единственный кандидат; `MANUAL` — ручное сопоставление; `PROPAGATED` — автоприменение; `NULL` ⇔
|
||
`counterparty_id IS NULL`. Отдельное поле нужно, потому что после ручного сопоставления появляется алиас и ручное решение
|
||
становится неотличимым от `ALIAS`.
|
||
|
||
**Ручное сопоставление** (алгоритм в DOC-221278; Срез 4, `POST …/osv/entries/{entry_id}/counterparty`): правка версии —
|
||
право `CONSOLIDATION_EDIT`, статус «в работе» и своя блокировка (§10).
|
||
|
||
- **Кандидаты:** `scope=group` (по умолчанию) — ЮЛ периметра и ЮЛ той же ГК, `q` необязателен; `scope=all` — весь справочник
|
||
без policy видимости, `q` не короче 3 символов (иначе 422 `QUERY_TOO_SHORT`); поиск — ИНН по префиксу, имя и алиасы —
|
||
подстрока без регистра; не больше 100; `recommended` — нормализованное имя/алиас совпало с `normalize_alias` имени
|
||
строки `entry_id` (точное совпадение, OQ-127); рекомендуемые первыми.
|
||
- **Строка** — строка свода версии: `group_type = COUNTERPARTY`, `data_type ∈ {BU, SUM}` привязанного ОСВ-документа; строки
|
||
`NU`/`ACCOUNT` — 404 и в автоприменение не входят (R-11, §3 п. 2; OQ-126).
|
||
- **Шаги в одной транзакции:** выбор → проверка дубля `normalized_alias` у другой компании (`409 ALIAS_TAKEN` со списком
|
||
владельцев; с `move_alias = true` алиасы владельцев удаляются и алиас переносится — право `DIRECTORY_DELETE` для переноса
|
||
не требуется, решение владельца 06.10.2026) → создать `legal_entity_alias` (`LEGAL_ENTITY_CHANGE`) →
|
||
`osv_entry.counterparty_id = выбранное`, `counterparty_match_kind = MANUAL`; `counterparty_name/inn` не трогать →
|
||
автоприменить к строкам **всех** ОСВ-документов версии с тем же `counterparty_name.strip()`, пустым `counterparty_id` и
|
||
ИНН пустым или равным ИНН выбранного (`PROPAGATED`; ранее связанные строки не перевязываются) →
|
||
`audit_event(OSV_COUNTERPARTY_MAP)` с `before/after` и `propagated_entry_ids`.
|
||
- Строка `osv_entry` общая у всех версий, куда входит документ: сопоставление в одной версии меняет её для всех (OQ-123).
|
||
- Пустое имя (после нормализации) — алиас не создаётся и автоприменения нет. Неоднозначность дорезолва при тёзке — 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)
|
||
|
||
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:** автозамена снимает привязки (`consolidation_document`) **всех**
|
||
документов заменённого файла из всех версий «в работе» (`CANCELLED` не трогаются); версии, где привязка снята, —
|
||
`FILE_REPLACE.after_value.unbound_version_ids`; новый документ привязывается только после подтверждения (§3 п. 1).
|
||
4. **Подтверждение** (`POST /documents/{id}/confirm`, `DOCUMENT_EDIT`): `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`
|
||
(`confirmed_by/at`, `audit_event(DOCUMENT_CONFIRM)`); повтор — 409 `ALREADY_CONFIRMED`; документ заменённого файла —
|
||
409 `FILE_SUPERSEDED`; прочие статусы — 409 `DOCUMENT_NOT_CONFIRMABLE`. Правка строк и корректировки — подсрез 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`. **OQ-110 закрыт для Среза 4:** к версии
|
||
привязываются только `CONFIRMED` документы, а файл с таким документом удалить нельзя — удалить файл с документом в
|
||
версии невозможно; расширять удаление не требуется.
|
||
6. **Аудит.** `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`); смены статусов
|
||
задания/файла в обмене и в цикле маппинга `audit_event` не пишут (OQ-55).
|
||
7. **Порядок блокировок** (против взаимоблокировок): задание → файл → документы (`FOR NO KEY UPDATE`) → advisory-lock ГК →
|
||
версии консолидации (`FOR UPDATE`, по возрастанию `id`); автозамена дополнительно берёт advisory-lock по ЮЛ. Подробности —
|
||
`03-architecture.md`.
|
||
8. **Загрузка в контексте версии** (`?consolidation_version_id=`, Срез 4): до приёма — версия видима (404), «в работе»
|
||
(409 `VERSION_NOT_EDITABLE`), ЮЛ — член периметра (422 `ENTITY_NOT_IN_PERIMETER`); после приёма хотя бы одного файла
|
||
INITIATED → DOCUMENTS_PROCESSING (§9.1); сбой этого перехода не отменяет принятые файлы. Блокировка версии для загрузки не
|
||
нужна.
|
||
|
||
---
|
||
|
||
## §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` — тот же прогон с откатом, отчёт идентичен; **в 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`.
|
||
|
||
### §11.1 Очистка OCR-текста (перенос из старого ядра, Срез 3)
|
||
|
||
Источник — модуль старого ядра `DKB_ARMKA` `api/src/services/ofr_result_conversion/` (`utils.py`, `date_utils.py`,
|
||
`consts.py`; в локальном `reference-old-arm` тот же код лежит в `api/src/core/PDFWorker/`). Решение владельца
|
||
06.10.2026: модуль **проверен на реальном стенде ИС Сканирование, его код можно переносить** в нормализатор `scan-gateway`.
|
||
Это **единственное исключение** из правила «доменный код из `reference-old-arm` не переносить» (CLAUDE.md workspace).
|
||
Переносятся чистые функции очистки и разбора; обвязка старого ядра (`json_to_db_rows`, dataclass'ы `Meta/DbRow/DbValue`,
|
||
прямая запись в `financial_statement_*`) не переносится — результат ложится в `statementDocument` контракта v1.2
|
||
(`string_value`, `confidence`, `is_recognized`, `period_date`, `period_text`, `line_code_confidence`). Перенос — с тестами
|
||
на `fixtures/ocr/aep_*.json`; изменения правил ниже — только через этот параграф.
|
||
|
||
1. **Буквы-двойники рядом с цифрами** (`date_utils._normalize`, `_LOOKALIKES`): `О/о/O/o → 0`, `З/з → 3`,
|
||
`l/I/|/і → 1`, `б → 6`, `S/s → 5`, `Ч → 4` — заменяются **только если символ стоит вплотную к цифре**, в несколько
|
||
проходов («2ОО5» → «2005»); слова («Общество») не портятся. Если первая строка значения состоит только из цифр,
|
||
пробелов, `|`, точек и двоеточий — `|` считается разделителем клеток формы («2|0|2|4|» → «2 0 2 4»), а не единицей.
|
||
2. **Даты из OCR-текста** (`date_utils._parse_date`): сначала текстовые формы по всей строке в порядке приоритета —
|
||
диапазон «с 01.01.2025 по 30.09.2025» (берётся конец), «30 сентября 2025 г./r./года» (латинская `r` вместо `г`),
|
||
«I квартал 2026» / «1-й кв.» / «первый квартал», «1 полугодие» / «1 п/г», «9 месяцев 2025», «январь – сентябрь 2025»
|
||
(последний месяц), «за 2025 г.» / «2025 год» (31.12); затем числовые — три группы цифр («30.09.2025», «2025 03 31»,
|
||
«30/09/25»), иначе цифры склеиваются: 8 цифр → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный вариант), 4 → год
|
||
(31.12, только 1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается. Двузначный год → 20YY. Период квартала/полугодия/
|
||
N месяцев → последний день последнего месяца. Не дата (название организации) → `None`. Разборщик общий с §12 п. 4
|
||
(Excel), там на вход приходят чистые строки.
|
||
3. **Раскладка колонок** (`_build_layout`, `_infer_layout`): шапка — строка с ячейкой, начинающейся на «код» (после
|
||
нормализации: нижний регистр, всё не-буквенно-цифровое → пробел); наименование — ячейка с «наименование», иначе ближайшая
|
||
заполненная слева от кода; колонки значений — правее кода, где в тексте есть четыре цифры года или текст начинается на
|
||
«на » / «за ». Повторная шапка внутри таблицы **перезаписывает** раскладку. **Без шапки** раскладка выводится по данным:
|
||
код — столбец с наибольшим числом значений из справочника кодов строк; наименование — столбец левее кода с наибольшим
|
||
числом текстовых ячеек; значения — столбцы правее кода, где суммы не реже мусора (полоса скана) или столбец пуст
|
||
(период без данных); шапка синтезируется (`Код` / `Наименование показателя`, тексты периодов пустые). Строки до первой
|
||
настоящей шапки разбираются по выведенной раскладке. Ни шапки, ни столбца кода — таблица пропускается с warning.
|
||
**Рулинг 06.10.2026 (вместо прежнего «без шапки → колонки без дат, `NEEDS_CHECK`»):** раскладку выводить, как в старом
|
||
ядре, но документ без шапки всё равно отдавать `needs_check` с причиной в `metrics.warnings` — колонки без `period_date`
|
||
аналитик подтверждает руками.
|
||
4. **Классы строк** (`_classify_row`): пустой код → строка пропускается; нечисловой код → заголовок (не данные);
|
||
наименование «Баланс» → `BALANCE`, начинается с «итого» → `TOTAL`, иначе обычная строка. Берутся только строки с кодом
|
||
(как в §11 выше); расшифровки без кода — OQ-36.
|
||
5. **Тип таблицы** (`_resolve_table_type`): по первой цифре кодов строк данных — больше `1xxx` → BALANCE, больше `2xxx` →
|
||
INCOME; это запасной путь, когда `label.form_name` зашумлён; при расхождении с `label` — `needs_check`.
|
||
6. **Код и наименование строки**: код очищается до букв, цифр, пробела и точки (`_clear_value`: переносы строк и
|
||
табуляции → пробел, остальное выбрасывается); наименование берётся **из справочника по коду** (`consts.codes_mapping`,
|
||
у нас — `rules/statement_lines.yaml` из §12 п. 2), а из OCR — только если код не найден в справочнике (`line_code_confidence
|
||
< 100`). Код считается распознанным (`line_code_recognized`), если `confidence == 100`, очищенный текст равен сырому и код
|
||
есть в справочнике.
|
||
7. **Суммы** (`_parse_amount`): запятая → точка, пробелы убираются; содержимое скобок — отрицательное; из строки
|
||
удаляется всё, кроме цифр, `+`, `-`, `.`; несколько точек — разделители тысяч, остаётся последняя; `Decimal`, иначе
|
||
`None`. Прочерк, «–», «—», пусто — пустое значение (NULL, не 0). `string_value` — очищенный текст (`_clear_value`).
|
||
**`is_recognized`** значения = `confidence == 100` **и** разбор суммы не потребовал выбрасывать символы (сырой текст без
|
||
скобок равен очищенному). Для ячеек шапки: `is_recognized` = `confidence == 100` и в тексте не было мусора; если дата
|
||
разобралась — `period_text` нормализуется в «На ДД.ММ.ГГГГ», `period_date` — результат п. 2. Ячейки под колонкой
|
||
заголовка нет — пустое значение с `is_recognized = false`, чтобы не сдвигать столбцы.
|
||
8. **Шапка документа**: ИНН — только цифры из `inn.form_name`, «распознан», если `confidence == 100` и в сыром тексте не
|
||
было ничего, кроме цифр; единицы — `_clear_value`, тот же критерий; дата отчёта — п. 2, «распознана», если
|
||
`confidence == 100` и дата разобралась. Нераспознанное → `needs_check` (как в §11).
|
||
9. **Ошибка разбора одного документа** (исключение в разборе `data_ocr`) не роняет файл: документ помечается ошибкой
|
||
(`BACKEND_PARSING_ERROR` + текст исключения), остальные документы файла обрабатываются; хотя бы один документ есть —
|
||
конверт `needs_check`, ни одного — `error` (в терминах `recognition-result-message`).
|
||
|
||
---
|
||
|
||
## §12. ББ и ОФР из Excel (tabular-parser, второй тип документа)
|
||
|
||
Источник: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР (эксель файлов)»), DOC-037734, DOC-221281; формат —
|
||
Приказ 66н / ФСБУ 4/2023, но на практике это и выгрузка из 1С («Регламентированный отчёт»), и шаблон банка
|
||
(`Копия Рекомендуемые формы расшифровок…`, лист `Отчетность` в `Анализ *.xlsx`), и вручную набитая таблица.
|
||
|
||
Принцип тот же, что в §1.0: **структура ищется по содержимому**, результат — тот же `statementDocument` контракта,
|
||
что и у `scan-gateway`, чтобы ядро маппило ББ/ОФР одинаково независимо от источника (`confidence = 100`,
|
||
`is_recognized = true` для табличного пути).
|
||
|
||
**Статус: реализовано (Срез 2b, tabular-parser feat/14, контракт v1.2; решения владельца 06.10.2026).** Модули —
|
||
`api/src/parser/statements/`, словари — `api/rules/statement_headers.yaml`, `statement_periods.yaml`,
|
||
`statement_checks.yaml`; ожидания по реальным файлам — `api/tests/statements_manifest.yaml` (06-test-data §4б).
|
||
|
||
1. **Классификация листов**: лист ОСВ → форма → продолжение → прочее. Форма: заголовок («Бухгалтерский баланс»,
|
||
«Отчет о финансовых результатах», «Отчет о прибылях и убытках») или ОКУД в **любой** строке до таблицы (у НПЗ он стоит
|
||
под титулом); титул «Бухгалтерская отчетность» — не тип, тип берёт ближайший следующий заголовок. Тип по ОКУД:
|
||
0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx (0710003 капитал, 0710004 ДДС) и пояснения («Пояснения к
|
||
бухгалтерскому балансу», «Расшифровка») → `DISCLOSURE`; при противоречии ОКУД и заголовка — тип по ОКУД и
|
||
предупреждение. Две формы на одном листе — по второму заголовку, в книге несколько листов — несколько `document`.
|
||
Продолжение: лист без заголовка формы, но с маркером «Форма … с.2», либо с подписью «Код» и периодами правее, либо с
|
||
колонкой кодов. ОСВ и формы в одной книге допустимы.
|
||
2. **Колонка кодов**: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx,
|
||
пятизначные — по первым четырём цифрам), кодов не меньше `min_codes` = 3; заголовок «Код/Коды» необязателен, числа вида
|
||
`1110.0` читаются как текст «1110». Колонки нет — документ без строк, `needs_check`, предупреждение «колонка кодов не
|
||
найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-138).
|
||
3. **Колонка наименований** — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не
|
||
наименование.
|
||
4. **Колонки-периоды**: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024»,
|
||
«За 9 месяцев 2025 г.», «За январь - сентябрь 2025 г.», «2024»), по порядку слева направо (`column_index` 0, 1, 2…);
|
||
`period_date` — дата или конец периода; `is_current` в контракте нет — ядро берёт самую позднюю. Заголовок в две строки
|
||
(«На» + «20__ г.», перенос внутри ячейки) склеивается; строка нумерации «1 2 3 4 5» пропускается. Колонки без периода
|
||
(«Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет.
|
||
5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`,
|
||
`is_recognized = false`. **Единицы не нормализуются**: `amount` как в ячейке, `header.units` — текст шапки; при «тыс.» и
|
||
≥ 10 % значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» (OQ-137,
|
||
решение владельца). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются
|
||
(`ASSUMPTION(OQ-36)`, OQ-140); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`.
|
||
Пятизначные подстроки (11101, 12301…) отдаются как обычные строки.
|
||
6. **Шапка**: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12
|
||
цифр у метки «ИНН» (**ИНН аудитора не берётся**: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес,
|
||
единицы, `report_date_text`/`report_date` — из подзаголовка формы или тройки «число | месяц | год» у метки «Дата» (для
|
||
ОФР — конец периода). Шапка другого листа не используется: у ОФР НПЗ нет метки «ИНН» → `inn = null`. Найденное поле —
|
||
`*_confidence = 100`, не найденное — `null`.
|
||
7. **Самопроверка**: соотношения §7 по каждой колонке — данные `statement_checks.yaml`. **Жёсткие** (нарушение →
|
||
`needs_check`): ББ `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`,
|
||
`1600 = 1700`; ОФР `2100`, `2200`, `2300`. **Мягкие** (только предупреждение, `ASSUMPTION(OQ-12)`): `2400 = 2300 −
|
||
2410 + 2420` и варианты ФСБУ 4/2023, `2500`, «сумма пятизначных подстрок = родитель». Допуск ±1 **в единицах формы**,
|
||
отсутствующий код = 0, соотношение проверяется, только если присутствует его цель; правая часть считается дважды — как
|
||
записано и с членами `abs_terms` по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Результат —
|
||
`documents[].checks` (`relations_failed`, `soft_relations_failed`, `duplicate_line_codes`) и предупреждения.
|
||
**Фактически сходятся полные формы только у Межениновской**: книга НПЗ (`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` не сходится. Это сигнал о данных файла, а не о
|
||
неверно угаданной структуре; допуск под файл не подгоняется.
|
||
8. **Расшифровки** (0710003/0710004, пояснения, лист «Примечания» шаблона) — `DISCLOSURE` с `locator` и шапкой, если
|
||
нашлась, без `columns`/`lines`; на статус не влияет (OQ-23). Книга только из таких листов — `done`.
|
||
9. **Склейка**: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок
|
||
сумм совпадает; строки добавляются в конец, `locator` перечисляет листы через запятую. **Страницы в разных файлах не
|
||
склеиваются** (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой
|
||
и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-139).
|
||
Продолжение без колонки кодов при непустом результате не теряется молча — `needs_check`.
|
||
10. **Статус**: документ `ok` ⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов
|
||
∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга — `done`, если `ok`
|
||
у всех документов, иначе `needs_check`; книга без листов ОСВ и форм — `INVALID_DOCUMENT`. Порядок документов: сначала
|
||
ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`.
|
||
11. **Отложено**: запасной путь по именам строк (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-решение вне парсера.
|