346 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# consolidations-api Specification
## Purpose
Консолидация ГК за период: создание, дерево версий, карточка версии, периметр, блокировка «взять в работу», статусы
Среза 4 и чек-лист комплекта документов (§9.1, §10 `05-domain-rules.md`; DBML `consolidation*`, `required_document_rule`;
макет `34`, `35`, `36`, `37`, `42`). Префикс всех маршрутов — `/api/v1`.
## Requirements
### Requirement: Создание консолидации
`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter,
legal_entity_ids?}` SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year,
Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`,
`approach = BASIC`, `goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя,
`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись
`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см.
`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии.
При создании версии система SHALL автоматически записать в `consolidation_version.elimination_rules_version` номер
самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату
создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146).
Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера.
Если на дату создания нет действующих правил, поле остаётся NULL.
Периметр SHALL быть: без
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
запроса; состав ГСК при явном списке не проверяется и не меняется. ГСК SHALL быть обязательной, принадлежать ГК и иметь
`kind = gsk`, иначе 422 `RELATED_GROUP_MISMATCH`. Периметр меньше двух ЮЛ (членов ГСК без списка либо выбранных ЮЛ после
схлопывания) → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть → 409 `CONSOLIDATION_EXISTS` (в том числе при
гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); `year` вне 2000..2100 → 422. Порядок проверок: ГК
видима (404) → ГСК видима (404) и соответствует ГК (422) → периметр (422) → период свободен (409). Блокировка на
создателя MUST NOT ставиться; `MEMBERSHIP_CHANGE` при создании MUST NOT писаться. `related_group_id` в `consolidation`
SHALL оставаться NULL (фиксируется при верификации, Срез 7); `related_group_member` MUST NOT изменяться.
#### Scenario: Консолидация РБ за Q3 2024
- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3, без
`legal_entity_ids`
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: Правила действуют на дату создания версии
- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде
- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил
#### Scenario: Выбранный периметр шире ГСК
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
#### Scenario: ГСК из одного ЮЛ, второе отмечено вручную
- **WHEN** в ГСК один текущий член, в `legal_entity_ids` он и ещё одно ЮЛ той же ГК
- **THEN** 201; в `members` два ЮЛ
#### Scenario: ГСК из одного ЮЛ
- **WHEN** в ГСК один текущий член, `legal_entity_ids` не передан
- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы
#### Scenario: ЮЛ другой ГК в списке
- **WHEN** в `legal_entity_ids` попало ЮЛ, `company_group_id` которого не равен ГК консолидации
- **THEN** 422 `ENTITY_NOT_IN_GROUP` с этим ЮЛ в `legal_entity_ids`; `consolidation` не создана
#### Scenario: Дубли схлопываются до одного ЮЛ
- **WHEN** `legal_entity_ids` — один и тот же идентификатор дважды
- **THEN** 422 `PERIMETER_TOO_SMALL`
#### Scenario: Повтор периода
- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна
- **THEN** 409 `CONSOLIDATION_EXISTS`
### Requirement: Список консолидаций ГК и карточка версии
`GET /company-groups/{id}/consolidations` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать периоды ГК (`consolidation_id`,
`year`, `quarter`, `reporting_date`) по убыванию года и квартала, в каждом — версии по убыванию `version_no`
(`id`, `version_no`, `status`, `scenario`, `approach`, `lock` — активная блокировка или null, `created_at`,
`members_count`); параметр `in_work_only=true` SHALL оставлять только версии со статусом не из
{`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}. `GET /consolidations/versions/{id}` SHALL отдавать карточку: поля
версии и консолидации, `company_group {id, name}`, инициатора и его офис, `lock`, `members[]`, `history[]`
(`from_status`, `to_status`, `changed_by`, `changed_at`, `comment`). Член периметра SHALL содержать `id`, `name`, `inn`,
`entity_type` (`ЮЛ`/`ИП`, из `legal_entity`), `is_head_entity`, `checklist[]` (ячейки по правилам подхода, см.
«Чек-лист комплекта») и `osv_accounts[]` — уникальные `osv_account_code` документов ЮЛ, у которых `document_type = OSV`,
`period_year` равен году версии, `period_quarter` не позже квартала версии, статус документа не `ERROR`, файл не в
`SUPERSEDED` и не в `ERROR`; документы без квартала или без кода счёта не учитываются; статус подтверждения и привязка к
версии не важны; порядок — натуральная сортировка кода счёта (как столбцы чек-листа: `01 < 51 < 58.03 < 60`). Консолидация
ГК вне видимости и несуществующий идентификатор — 404 `NOT_FOUND` с сообщением `CONSOLIDATION_NOT_FOUND` /
`VERSION_NOT_FOUND` и одинаковым телом (так же во всех спецификациях среза: идентификаторы `…_NOT_FOUND` — текст
сообщения при фиксированном коде `NOT_FOUND`).
#### Scenario: Дерево версий
- **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список
- **THEN** первым период 2024 Q3, затем 2024 Q1 с версиями `[2, 1]`; `members_count` у каждой версии
#### Scenario: Чужой филиал
- **WHEN** `ka_rf` другого филиала запрашивает карточку версии
- **THEN** 404 `NOT_FOUND` (сообщение `VERSION_NOT_FOUND`), тело совпадает с ответом на случайный `uuid`
#### Scenario: Счета ОСВ участника
- **WHEN** версия за 2024 Q3; у ЮЛ есть ОСВ 60 за Q1 2024 (`RECOGNIZED`), ОСВ 51 за Q3 2024 (`CONFIRMED`), ОСВ 58.03 за
Q3 2024 (`NEEDS_CHECK`), ОСВ 62 за Q4 2024, ОСВ 01 за 2023, ОСВ 76 в заменённом (`SUPERSEDED`) файле и ОСВ 90 в
документе `ERROR`
- **THEN** `osv_accounts = ["51", "58.03", "60"]`
#### Scenario: Тип ЮЛ
- **WHEN** в периметре индивидуальный предприниматель
- **THEN** у него `entity_type = "ИП"`, у остальных — `"ЮЛ"`
### Requirement: Периметр версии
`PUT /consolidations/versions/{id}/members` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя активная блокировка)
с телом `{legal_entity_ids: [...]}` SHALL заменить периметр версии: все ЮЛ SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем; меньше двух → 422
`PERIMETER_TOO_SMALL`; дубли в списке схлопываются. Для добавленных ЮЛ SHALL выполняться подхват подтверждённых
документов, для удалённых — отвязка их `consolidation_document`; `audit_event(MEMBERSHIP_CHANGE, section =
CONSOLIDATION, object = consolidation_version)` с `before_value`/`after_value` — полные списки ЮЛ. `related_group_member`
MUST NOT изменяться. Ответ — карточка версии.
#### Scenario: Добавление ЮЛ с подтверждёнными документами
- **WHEN** в периметр версии 2024 Q3 добавлено ЮЛ ГК, у которого подтверждена ОСВ 60 за 2024 Q3 (нарастающая)
- **THEN** 200; в `members` три ЮЛ; у нового ЮЛ ячейка ОСВ 60 `CONFIRMED`; `consolidation_document` на этот документ есть
#### Scenario: ЮЛ другой ГК
- **WHEN** в список попало ЮЛ, `company_group_id` которого не равен ГК консолидации
- **THEN** 422 `ENTITY_NOT_IN_GROUP`, периметр не изменился
#### Scenario: Без блокировки
- **WHEN** у версии нет активной блокировки, пользователь с `CONSOLIDATION_EDIT` меняет периметр
- **THEN** 409 `LOCK_REQUIRED`
### Requirement: Взять в работу, перехват, снятие
`POST /consolidations/versions/{id}/lock` (`CONSOLIDATION_EDIT`, policy; версия в любом статусе, кроме `CANCELLED` —
409 `VERSION_NOT_EDITABLE`) SHALL: при отсутствии активной блокировки создать `consolidation_lock(locked_by, locked_at)`
и `audit_event(CONSOLIDATION_LOCK)` → 201; при своей активной — вернуть её без изменений → 200; при чужой без
`takeover_reason` → 409 `LOCKED_BY_OTHER` с `locked_by`, `locked_by_name`, `locked_at` в `detail`; при чужой с
непустым `takeover_reason` (≤ 1000 символов) — закрыть прежнюю (`released_at = now`), создать новую с
`taken_over_from` и `takeover_reason`, `audit_event(CONSOLIDATION_LOCK_TAKEOVER, before = прежний владелец, after =
новый, comment = причина)` → 201. Два одновременных `POST` без активной блокировки → у одного 201, у другого 409
`LOCKED_BY_OTHER` (частичный UNIQUE). `DELETE /consolidations/versions/{id}/lock` SHALL снять свою активную
блокировку (`released_at = now`, `audit_event(CONSOLIDATION_LOCK, after_value.released = true)`) → 204; чужую — 409
`NOT_LOCK_OWNER`; без активной — 204. Блокировка MUST NOT истекать по времени (OQ-119).
#### Scenario: Перехват с причиной
- **WHEN** версию держит `ka_rf` А, `ka_rf` Б того же филиала вызывает `POST …/lock {takeover_reason: "Отпуск"}`
- **THEN** 201; у Б активная блокировка с `taken_over_from = A`; у А `released_at` заполнен;
`audit_event(CONSOLIDATION_LOCK_TAKEOVER)` с `comment = "Отпуск"`
#### Scenario: Перехват без причины
- **WHEN** Б вызывает `POST …/lock` без тела при активной блокировке А
- **THEN** 409 `LOCKED_BY_OTHER`, `detail.locked_by = A`
#### Scenario: Чужое снятие
- **WHEN** Б вызывает `DELETE …/lock` при активной блокировке А
- **THEN** 409 `NOT_LOCK_OWNER`, блокировка А активна
### Requirement: Статусы Среза 4
Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to,
changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`.
Автоматические переходы: загрузка в консолидацию (`POST /consolidations/{id}/legal-entities/{le}/files`, см. `primary-files`) —
`INITIATED → DOCUMENTS_PROCESSING` у каждой версии консолидации «в работе» с ЮЛ в периметре; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) —
`DOCUMENTS_PROCESSING → DATA_CORRECTION`; подтверждение документа, привязанного к версии (см. `documents-api`) —
`INITIATED` или `DOCUMENTS_PROCESSING → DATA_CORRECTION`. Ручной `POST /consolidations/versions/{id}/status`
(`CONSOLIDATION_EDIT`, policy, своя блокировка) с телом `{to_status, comment}` SHALL допускать только: `CANCELLED` из
любого статуса «в работе» (`comment` обязателен, иначе 422 `COMMENT_REQUIRED`; `cancelled_reason = comment`,
`is_in_work = false`, активная блокировка снимается) и `INITIATED` из `DOCUMENTS_PROCESSING` / `DATA_CORRECTION`
(`comment` необязателен; `consolidation_document` сохраняются). Любой другой `to_status` → 409 `TRANSITION_NOT_ALLOWED`
с текущим и запрошенным статусами. Повтор того же статуса → 409 `TRANSITION_NOT_ALLOWED`. Статус «в работе» =
{`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}.
#### Scenario: Аннулирование
- **WHEN** владелец блокировки переводит версию `DATA_CORRECTION` в `CANCELLED` с комментарием
- **THEN** 200; `status = CANCELLED`, `cancelled_reason` = комментарий, `is_in_work = false`, блокировка снята; запись
истории `DATA_CORRECTION → CANCELLED` с комментарием; `audit_event(CONSOLIDATION_STATUS_CHANGE)`
#### Scenario: Переход Среза 7
- **WHEN** запрошен `to_status = CONSOLIDATION_RF`
- **THEN** 409 `TRANSITION_NOT_ALLOWED`, статус не изменился
#### Scenario: Аннулирование без комментария
- **WHEN** `to_status = CANCELLED`, `comment` пуст
- **THEN** 422 `COMMENT_REQUIRED`
### Requirement: Чек-лист комплекта
`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила
`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`,
затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{document_type,
account, status, missing_quarters, document_ids}`: `document_type` и `account` — правила ячейки (ячейка
самоописывающая, фронт не опирается на позицию); `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными
документами (ОСВ: по правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии);
`NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии
(приоритет `NEEDS_CHECK`); `PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по
`uploaded_at` файл ЮЛ в `ERROR`; иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при
частичном покрытии. Правило ОСВ SHALL закрываться только документом с `osv_account_code`, равным `account` правила
(OQ-04). Те же ячейки (с `document_type` и `account`, в том же порядке) SHALL отдаваться в карточке версии у каждого
члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; charset=utf-8` с BOM и разделителем `;`:
заголовок «Организация;ИНН;<правило>…» (правила — «Бух. баланс», «ОФР», «ОСВ <счёт>»), ячейки — подписи макета:
`CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` «Требуется проверка», `ERROR` «Ошибка
загрузки», `MISSING` «Отсутствует» (с « (нет: Q1, Q2)» при `missing_quarters`); `Content-Disposition` — `attachment` с
именем `checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987.
#### Scenario: Пробел Q2
- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3
- **THEN** ячейка ОСВ 60: `document_type = OSV`, `account = "60"`, `status = MISSING`, `missing_quarters = ["Q2"]`,
`document_ids` — два документа
#### Scenario: Субсчёт не закрывает правило
- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии
- **THEN** ячейка ОСВ 58 — `MISSING`
#### Scenario: Файл в обработке
- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет
- **THEN** все ячейки ЮЛ — `PROCESSING`
#### Scenario: Ячейки карточки подписаны
- **WHEN** запрошена карточка версии подхода `BASIC`
- **THEN** у каждого члена `checklist[i].document_type` и `checklist[i].account` равны `rules[i]` чек-листа той же
версии; ячейка ББ — `document_type = BALANCE`, `account = null`
#### Scenario: Чек-лист в CSV
- **WHEN** запрошен чек-лист версии с `format=csv`, у ЮЛ подтверждён ББ, ОСВ 60 отсутствует за Q2
- **THEN** файл начинается с BOM, первая строка — «Организация;ИНН;Бух. баланс;ОФР;ОСВ 60…», в строке ЮЛ — «Загружен» и «Отсутствует (нет: Q2)»
### Requirement: Границы входных данных
Входы консолидаций SHALL иметь верхние границы: `legal_entity_ids` тела `PUT …/members` и тела `POST /consolidations` —
не больше 500 идентификаторов, `comment` тела `POST …/status` — не больше 1000 символов, `q` фильтров `GET …/osv` и
экспорта — не больше 500 символов. Превышение SHALL давать 422 со списком ошибок валидации (`detail[].loc` указывает
поле) до обращения к БД. Версия консолидации SHALL проверяться на видимость до блокировки `FOR UPDATE`: невидимая
версия отвечает 404 сразу, не ожидая транзакцию, которая держит её строку.
#### Scenario: Периметр длиннее предела
- **WHEN** в `PUT …/members` передано 501 ЮЛ
- **THEN** 422, `detail[0].loc = ["body", "legal_entity_ids"]`, периметр не изменился
#### Scenario: Периметр создания длиннее предела
- **WHEN** в `POST /consolidations` передано 501 ЮЛ в `legal_entity_ids`
- **THEN** 422, `detail[0].loc = ["body", "legal_entity_ids"]`, консолидация не создана
#### Scenario: Невидимая версия при занятой строке
- **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock`
- **THEN** 404 `VERSION_NOT_FOUND` без ожидания
### Requirement: Правила элиминации версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан
`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а
`rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` —
открытые правила (`valid_to IS NULL`) только с максимальным `version_no`; ответ SHALL содержать этот фактический
`version_no`. При отсутствии открытых правил `version_no = null`, `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`,
`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
#### Scenario: Правило с исключением
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
#### Scenario: Версия правил зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version = 1`
- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1`
#### Scenario: Версия правил не зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version` пуст
- **THEN** ответ содержит `is_version_fixed = false`, фактический `version_no` и открытые правила только этой версии
#### Scenario: Открыты две версии правил
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
### Requirement: Сопоставленные строки сводной ОСВ по правилу и паре компаний
`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs` SHALL требовать `CONSOLIDATION_VIEW` и проверять
видимость версии по ГК. Запрос SHALL принимать обязательные query-параметры `rule_id`, `company_a_id`, `company_b_id`.
Компании SHALL быть разными участниками периметра версии, а правило — пресетом ББ её версии правил. Для этой тройки
SHALL возвращаться только её результат; строки других правил и направленных пар компаний в расчёт не входят.
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
перечня допустимых пар счетов. Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
`abs(amount_a - amount_b)`.
Невидимая и несуществующая версия или отсутствующее правило SHALL отвечать 404 `NOT_FOUND`;
недопустимая пара компаний — 422 `INVALID_COMPANY_PAIR`. Пары и группы не сохраняются.
Строка сводной ОСВ неделима, даже если содержит несколько `entry_ids`. Внутри выбранной тройки строки с распознанным
встречным ЮЛ и допустимыми счетами группируются как 1:1, 1:N или N:1 при N ≤ 5; N:M не допускается. Комбинации
строятся из всех строк выбранных счетов соответствующей стороны правила, без дополнительного требования совпадения
конкретных счетов внутри группы.
Сравниваются модули остатков по соответствующей стороне. Нулевые строки остаются в `unpaired` с причиной
`ZERO_AMOUNT`. Сначала выбираются группы с наименьшим общим числом строк: 1:1, затем 1:2 и 2:1, и так до 1:5 и 5:1.
Внутри размера сначала точные, затем с расхождением не более 5 % относительно меньшей из двух сумм, по возрастанию
расхождения. Строка входит не более чем в одну группу данного правила. Если группы одного размера с одинаковым
расхождением пересекаются, все строки конкурирующих групп исключаются из автоматического поиска и возвращаются в
`unpaired` с причиной `AMBIGUOUS` для ручного разбора. Остальные строки без группы возвращаются в `unpaired` с
причиной `NO_MATCH` независимо от того, отсутствуют ли встречные суммы, превышен ли порог или подходящие строки уже
заняты другой группой.
Упаковка не меняет сумму элиминации.
#### Scenario: Исключённый счёт не входит в пары
- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5
- **THEN** строки счёта 76.07.5 и его потомков не входят в сопоставленные группы и `unpaired`
#### Scenario: Две строки свода упакованы с одной встречной
- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100
- **THEN** `matched[]` содержит пару с суммами 100 и 100, нулевым расхождением и исходными `osv_entry_ids_a/b`
#### Scenario: Пара с допустимым расхождением
- **WHEN** группа имеет суммы 103 и 100 при пороге 5 %
- **THEN** она входит в `with_discrepancy[]`, а не в `matched[]`
#### Scenario: Другая пара компаний не смешивается с выбранной
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
- **THEN** ответ содержит только строки A→Б и Б→A
### Requirement: Результирующая таблица ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
#### Scenario: Компания без ОСВ
- **WHEN** в периметре есть компания без подходящих строк ОСВ
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
#### Scenario: Нет обязательного счёта
- **WHEN** в плане счетов отсутствует счёт 58.03
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`
### Requirement: XLSX результирующей таблицы ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
#### Scenario: Совпадение с JSON
- **WHEN** пользователь выгружает результирующую таблицу ГК «Русский бисквит» за 2024 Q1, версия 1
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, порядок компаний, названия статей, суммы по компаниям и
«Итого по группе» совпадают с ответом `GET …/result-table` той же версии
#### Scenario: Нет счёта статьи
- **WHEN** в плане счетов нет кода 58.03
- **THEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` с кодом в сообщении, файл не формируется
#### Scenario: Версия вне видимости
- **WHEN** пользователь филиала выгружает таблицу версии чужой ГК
- **THEN** ответ 404 с тем же телом, что у несуществующей версии