# consolidated-osv Specification ## Purpose Сводная ОСВ версии как чистая функция (DECISION-5; §2, §3 `05-domain-rules.md`; ТЗ DOC-221278; макет `30`, `32-osv-no-inn`, `32-osv-map-counterparty`): приведение квартальных ОСВ к нарастающим, признак контрагента, кандидаты и ручное сопоставление, экспорт xlsx. Маршруты — под `/api/v1/consolidations/versions/{id}`. ## Requirements ### Requirement: Выбор источников и приведение периодов Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов, привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` ОСВ за `Q1` с любым `is_cumulative` служит квартальной `Q1` (нарастающий с начала года за первый квартал и есть квартал `Q1`, R-14); при наличии и нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из строки `Q1`, `turnover_*` = сумма по кварталам, `closing_*` из строки последнего квартала; строка, отсутствующая в квартале, считается нулями; две строки с одним ключом внутри квартала суммируются. `entry_ids` строки свода SHALL перечислять все исходные `osv_entry`. Расчёт — функция в `domain/` без ORM; результат MUST NOT сохраняться. #### Scenario: Три квартала - **WHEN** версия 2024 Q3; у ЮЛ привязаны квартальные ОСВ 60 за Q1, Q2, Q3; контрагент «А» есть во всех трёх со значениями `opening_debit` 100/300/450 и `turnover_debit` 200/150/50, `closing_debit` 300/450/500 - **THEN** одна строка «А»: `opening_debit = 100`, `turnover_debit = 400`, `closing_debit = 500`, `period = "2024 Q3"`, `is_cumulative = false`, `entry_ids` — три идентификатора #### Scenario: Контрагент только во втором квартале - **WHEN** контрагент «Б» есть только в ОСВ Q2 (`opening 0`, `turnover_debit 70`, `closing_debit 70`) - **THEN** строка «Б»: `opening_* = 0`, `turnover_debit = 70`, `closing_* = 0` #### Scenario: Пробел - **WHEN** у ЮЛ привязаны ОСВ 60 за Q1 и Q3, за Q2 нет - **THEN** строк по счёту 60 этого ЮЛ нет; `summary.gaps` содержит `{legal_entity_id, account_code: "60", missing_quarters: ["Q2"]}` #### Scenario: Нарастающий рядом с квартальными - **WHEN** у ЮЛ привязаны квартальные ОСВ 62 за Q1–Q3 и нарастающая ОСВ 62 за Q3 - **THEN** строки берутся из нарастающей один в один, `is_cumulative = true` ### Requirement: Строки свода, признак и дорезолв Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period` (`"<год> Q"`), `is_cumulative`, `row_number`, `counterparty_name` и `counterparty_inn` как в файле, `counterparty_id`, `counterparty_entity_name` и `counterparty_entity_inn` (имя и ИНН сопоставленного ЮЛ справочника; `null` без сопоставления), `counterparty_type` ∈ {`GSK`, `GK`, `EXTERNAL`}, `match_kind` ∈ {`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`, null}, `match_stored`, шесть сумм (`Decimal`, в JSON — строки), `warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение последнего квартала; расхождение между кварталами — в `warnings`); при пустом — резолв по ИНН → имя → алиас на лету по всему справочнику без policy, результат показывается с `match_stored = false` и MUST NOT записываться. `counterparty_type`: `GSK` — `counterparty_id` ∈ `consolidation_member` версии; `GK` — `legal_entity.company_group_id` = ГК консолидации; иначе `EXTERNAL` (в том числе при пустом `counterparty_id`). #### Scenario: Признаки на фикстурах РБ - **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО РБ, ООО Пекарня Счастливочная}; строка Счастливочной по 60 с контрагентом «Русский бисквит», ИНН 3528052718 смаплена по ИНН; строка РБ «Пекарня Счастливочная ООО» без ИНН не сопоставлена; строка РБ с контрагентом ЧКФ (в ГК, не в ГСК) - **THEN** первая — `GSK`, `match_kind = INN`, `match_stored = true`; вторая — `EXTERNAL`, `counterparty_id` null; третья — `GK` #### Scenario: Дорезолв на чтение - **WHEN** у строки `counterparty_id` пуст, а после маппинга в справочнике появился алиас, по которому имя резолвится однозначно - **THEN** в ответе `counterparty_id` заполнен, `match_kind = ALIAS`, `match_stored = false`; в `osv_entry` значение не изменилось #### Scenario: Алиас приоритетнее ЮЛ-тёзки - **WHEN** у строки `counterparty_id` пуст, в справочнике есть ЮЛ-тёзки по нормализованному имени строки и владелец алиаса с тем же `normalized_alias` (OQ-128, §3 п. 4) - **THEN** дорезолв отдаёт владельца алиаса, `match_kind = ALIAS`; без алиаса несколько тёзок дают `counterparty_id = null` ### Requirement: Страница свода и сводка `GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, `row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped=true` (строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** `counterparty_inn` пуст или из пробелов; строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — решение владельца 08.10.2026, отмена П-1; `unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name` или префикс `counterparty_inn`). `GET …/osv/summary` SHALL отдавать `rows_total`, `unmapped_total` (строки `unmapped=true`), `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`), `documents_total` (привязанные ОСВ-документы) и `gaps[]` (`legal_entity_id`, `legal_entity_name`, `account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться. #### Scenario: Только несопоставленные - **WHEN** запрошено `GET …/osv?unmapped=true` - **THEN** все строки ответа с `counterparty_id = null` и без `counterparty_inn`, `total` = `summary.unmapped_total` #### Scenario: Строка с ИНН вне справочника не требует сопоставления - **WHEN** у строки `counterparty_inn = "7701000001"`, такого ИНН и имени в справочнике нет (`counterparty_id = null`) - **THEN** строки нет в `GET …/osv?unmapped=true`, она не входит в `summary.unmapped_total` #### Scenario: ИНН сопоставленного ЮЛ - **WHEN** строка без ИНН в файле сопоставлена с ЮЛ по алиасу (на лету или хранимо) - **THEN** `counterparty_inn = null`, `counterparty_entity_inn` — ИНН этого ЮЛ из справочника #### Scenario: Фильтр по способу - **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS` - **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total` ### Requirement: Кандидаты для сопоставления `GET …/counterparty-candidates` (`CONSOLIDATION_VIEW`, policy) с параметрами `q`, `scope` ∈ {`group` (по умолчанию), `all`}, `entry_id` SHALL возвращать до 100 ЮЛ: `scope=group` — ЮЛ периметра версии и остальные ЮЛ той же ГК, `q` необязателен; `scope=all` — весь справочник без policy видимости, `q` SHALL быть не короче 3 символов (иначе 422 `QUERY_TOO_SHORT`). Поиск: ИНН по префиксу, имя и алиасы — подстрока без регистра. Кандидат: `id`, `inn`, `name`, `company_group {id, name}`, `gsk {id, name}` / `gsz {id, name}` (текущее членство), `counterparty_type` относительно версии, `recommended` — `normalize_alias(name)` или алиас совпали с `normalize_alias(counterparty_name)` строки `entry_id`. Рекомендуемые — первыми, затем по имени. #### Scenario: Рекомендуемая Счастливочная - **WHEN** запрошены кандидаты для строки РБ «Пекарня Счастливочная ООО» без `q` - **THEN** первой идёт ООО «Пекарня Счастливочная» с `recommended = true`, `counterparty_type = GSK`; ЧКФ в списке с `recommended = false`, `counterparty_type = GK` ### Requirement: Ручное сопоставление контрагента `POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя блокировка) с телом `{legal_entity_id, move_alias = false}` SHALL в одной транзакции: проверить, что строка принадлежит ОСВ-документу, привязанному к версии (иначе 404 `NOT_FOUND` с сообщением `ENTRY_NOT_FOUND`; код фиксированный, идентификатор — текст сообщения), и что ЮЛ существует (без policy; иначе 404 `NOT_FOUND` с сообщением `LEGAL_ENTITY_NOT_FOUND`); вычислить `normalized = normalize_alias(counterparty_name)`: пустой → алиас не создаётся; алиас с таким `normalized` у других ЮЛ и `move_alias = false` → 409 `ALIAS_TAKEN` с `owners[] {legal_entity_id, name}`; с `move_alias = true` → их алиасы удаляются (`audit_event(LEGAL_ENTITY_CHANGE)` на каждый); у выбранного ЮЛ алиаса нет → создаётся (`audit_event(LEGAL_ENTITY_CHANGE)`); строке — `counterparty_id`, `counterparty_match_kind = MANUAL`, `modified_by/at` не трогаются (`counterparty_name/inn` неизменны); автоприменение — всем строкам ОСВ-документов, привязанных к версии, с тем же `counterparty_name.strip()`, пустым `counterparty_id` и ИНН пустым или равным ИНН выбранного ЮЛ → `counterparty_id`, `PROPAGATED`; один `audit_event(OSV_COUNTERPARTY_MAP, section = CONSOLIDATION, object = osv_entry, consolidation_version_id)` с `before_value {counterparty_id, match_kind}`, `after_value {counterparty_id, match_kind, propagated_entry_ids, alias_created, alias_moved_from}`. Уже связанную строку SHALL быть можно перевязать. Ответ — `{row, propagated, alias_created, alias_moved_from}`. `DIRECTORY_DELETE` для переноса алиаса MUST NOT требоваться. #### Scenario: Сопоставление из плана среза - **WHEN** в версии РБ 2024 Q3 строка РБ «Пекарня Счастливочная ООО» (счёт 60) связана с ООО «Пекарня Счастливочная» - **THEN** 200; у строки `counterparty_type = GSK`, `match_kind = MANUAL`; создан `legal_entity_alias` «Пекарня Счастливочная ООО»; все остальные строки РБ с тем же именем во всех привязанных ОСВ (60, 62, 66 …) получили тот же `counterparty_id` и `PROPAGATED`; `propagated` ≥ 1; `summary.unmapped_total` уменьшился на число СТРОК сводной ОСВ, получивших контрагента (квартальные записи одного контрагента склеены в одну строку), а `1 + propagated` равно числу записей `osv_entry` с этим именем во всех привязанных ОСВ, включая пары-пробелы и записи, склеенные в одну строку #### Scenario: Алиас занят - **WHEN** `normalized` имени строки уже есть алиасом у другого ЮЛ, `move_alias = false` - **THEN** 409 `ALIAS_TAKEN` с `owners`; строка и алиасы не изменились #### Scenario: Перенос алиаса - **WHEN** тот же запрос с `move_alias = true` - **THEN** 200; алиас у прежнего владельца удалён, у выбранного создан, `alias_moved_from` = прежний владелец; два `audit_event(LEGAL_ENTITY_CHANGE)` и один `OSV_COUNTERPARTY_MAP` #### Scenario: ИНН противоречит - **WHEN** у другой строки с тем же именем `counterparty_inn` заполнен и не равен ИНН выбранного ЮЛ - **THEN** она не получает `counterparty_id` при автоприменении ### Requirement: Экспорт xlsx `GET …/osv/export` (`CONSOLIDATION_VIEW`, policy; фильтры как у `GET …/osv`, без пагинации) SHALL отдавать файл `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` с `Content-Disposition: attachment; filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v.xlsx`. Лист «Сводная ОСВ»: строка заголовков — Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт, Обороты Дт, Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы — числовые ячейки с форматом `#,##0.00` в рублях без деления; порядок строк как у API. Лист «Пробелы»: Компания ГК, Счёт, Нет ОСВ за кварталы; при отсутствии пробелов — одна ячейка «пробелов нет». Файл SHALL строиться в памяти без S3; `audit_event(EXPORT, section = EXPORT, object = consolidation_version, after_value = {filters, rows})`. #### Scenario: Экспорт читается обратно - **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl` - **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы» — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` ### Requirement: Шахматка по сводной ОСВ версии `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и `POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual` (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии и её периметра. ALL включает балансовые счета (`is_off_balance = false`) вида `A`/`AP` для дебета и `P`/`AP` для кредита; забалансовые счета (001–012) в ALL MUST NOT входить. MANUAL принимает обязательные непустые `debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета по иерархии и оставляет подходящий вид; забалансовые счета в MANUAL допускаются. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, `exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`. Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, `amount_b` — сумма `closing_credit` строк B с контрагентом A по кредитовым. Расхождение — модуль разности; процент — расхождение / меньшую сумму × 100 или null при нуле. `exceeds_threshold` SHALL быть true при точном проценте > 5, до округления; суммы и процент в JSON SHALL быть десятичными строками с двумя знаками после запятой (`ROUND_HALF_UP`). `one_sided` (пара без встречной стороны) SHALL быть true тогда и только тогда, когда ровно одна из сумм `amount_a`, `amount_b` равна нулю (знак ненулевой суммы не важен); наличие строк ОСВ с нулевым сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count` SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы `osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны. #### Scenario: Пустой периметр и отсутствие ОСВ - **WHEN** у версии нет членов периметра - **THEN** `companies = []`, `cells = []`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами #### Scenario: Только одна сторона пары - **WHEN** у A есть строка ОСВ с контрагентом B и ненулевым сальдо, а у B встречной строки нет - **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст #### Scenario: Встречная строка с нулевым сальдо - **WHEN** у A по B сальдо 100 000 000, а у B по A есть строка ОСВ с нулевым сальдо (одни обороты) - **THEN** `one_sided = true`, `exceeds_threshold = false`, пара подсвечивается #### Scenario: Обе стороны нулевые - **WHEN** у A и у B есть строки ОСВ друг на друга, но обе суммы равны 0 - **THEN** `one_sided = false`, `exceeds_threshold = false`, пара не подсвечивается #### Scenario: Отрицательное сальдо - **WHEN** у A по B сальдо −50, у B по A — 0 - **THEN** `one_sided = true` #### Scenario: Забалансовые счета - **WHEN** у A по B есть сальдо на счёте 001 (`is_off_balance = true`) - **THEN** в режиме ALL оно в суммы не входит; в MANUAL с выбранным счётом 001 — входит #### Scenario: Счёт не сопоставлен - **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids` - **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2 #### Scenario: Эталон «Русский бисквит» - **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур - **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) ### Requirement: XLSX шахматки `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. #### Scenario: Экспорт выбранных счетов - **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 - **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования #### Scenario: Подсветка пары без встречной стороны - **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0` - **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета» ### Requirement: Шахматка по правилу-пресету `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}` (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать шахматку в режиме PRESET с тем же ответом, что ALL и MANUAL. Счета дебета и кредита SHALL браться из правила элиминации `rule_id`: дебет — сторона `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона; исключения (`excluded`) вычитаются, выбранные счета раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила: `closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`). Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же `version_no`, иначе — без `valid_to` и с максимальным открытым `version_no`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422: `RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила, `ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`. #### Scenario: Пресет по сальдо - **WHEN** правило с `side_1 = D`, `measure = closing` содержит дебетовые счета на стороне 1 и кредитовые на стороне 2 - **THEN** `amount_a` пары — сумма `closing_debit` по счетам стороны 1, `amount_b` — сумма `closing_credit` по счетам стороны 2 #### Scenario: Пресет по оборотам - **WHEN** у правила `measure = turnover` - **THEN** суммы считаются по `turnover_debit`/`turnover_credit` #### Scenario: Правило другой версии - **WHEN** у версии `elimination_rules_version = 2`, а `rule_id` принадлежит правилу версии 1 - **THEN** 404 для расчёта и выгрузки #### Scenario: Неизвестное правило - **WHEN** `rule_id` не существует - **THEN** 404 #### Scenario: Правило ОФР - **WHEN** у правила `statement_type = income` - **THEN** расчёт и выгрузка возвращают 422 `INVALID_RULE` ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` - **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` #### Scenario: Правило не найдено - **WHEN** `rule_id` не существует - **THEN** 404, файл не отдаётся, событие аудита не пишется