## MODIFIED Requirements ### Requirement: Шахматка по сводной ОСВ версии `POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает `{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст, для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL, хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида `A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`. Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ. ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. Ответ SHALL содержать `companies[] {id, name}` для периметра (при непустом `legal_entity_ids` — только выбранные ЮЛ в порядке периметра), `total_debit` и `total_credit` с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `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 исходных записей соответствующей стороны. `total_debit` («Итого Дт», колонка справа) суммирует `amount_a` ячеек строки по `company_a_id`, `total_credit` («Итого Кт», строка снизу) — `amount_b` ячеек столбца по `company_b_id`; ключи — ID всех ЮЛ из `companies`. Диагональ не участвует, отрицательные суммы входят со знаком, суммы ячеек точные (`numeric(18,2)`). Для ЮЛ без подходящих строк, в том числе когда в выборке одно ЮЛ, итог равен `"0.00"` (правило аналитика, DOC-221277 «Итоги шахматки», 03.11.2026). #### Scenario: Пустой периметр и отсутствие ОСВ - **WHEN** у версии нет членов периметра - **THEN** `companies = []`, `cells = []`, `total_debit = {}`, `total_credit = {}`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами #### 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) #### Scenario: Итоги Дт и Кт - **WHEN** в периметре A, B, C; `amount_a` ячеек (A, B) = 100 и (A, C) = 20, `amount_b` ячеек (A, B) = 70 и (C, B) = 5 - **THEN** `total_debit[A] = "120.00"`, `total_credit[B] = "75.00"` #### Scenario: Фильтр по ЮЛ - **WHEN** запрос с `legal_entity_ids = [A, B]` при периметре A, B, C - **THEN** `companies` и обе оси — только A и B, ячейки и итоги не учитывают C; ID вне периметра — 422 `ENTITY_NOT_IN_PERIMETER` #### Scenario: Пустая одна сторона - **WHEN** `debit_account_ids = [62]`, `credit_account_ids` пуст - **THEN** дебет — счёт 62 с потомками, кредит — все балансовые счета вида `P`/`AP`; режим выгрузки и аудита — `MANUAL` ### Requirement: XLSX шахматки `POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет `legal_entity_ids` из запроса и использует `mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`. Ответ — XLSX с именем `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. Справа от колонок компаний SHALL быть колонка «Итого Дт» с `total_debit` по каждому ЮЛ строки (ячейка объединена на строки пары); под матрицей SHALL быть строка «Итого Кт» с `total_credit` по каждому ЮЛ колонки; ячейка на пересечении пустая. Значения — те же, что в JSON. #### Scenario: Экспорт выбранных счетов - **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 - **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования #### Scenario: Подсветка пары без встречной стороны - **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0` - **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета»