328 lines
34 KiB
Markdown
328 lines
34 KiB
Markdown
# 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<n>"`), `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`, `counterparty_id` (строка с этим `counterparty_id` после дорезолва; AURORA-1746), `unmapped=true`
|
||
(строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** `counterparty_inn` пуст или из пробелов;
|
||
строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — решение владельца 08.10.2026, отмена П-1;
|
||
`unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name`
|
||
или префикс `counterparty_inn`). Те же фильтры SHALL принимать `GET …/osv/export`. `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`) и `counter_pairs[]` — пары участников без встречных записей
|
||
(требование «Пары участников без встречных записей»). Версия в любом статусе, включая `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`
|
||
|
||
#### Scenario: Строки одной стороны пары
|
||
- **WHEN** в `summary.counter_pairs` есть пара `holder_id = А`, `missing_id = Б` и запрошено
|
||
`GET …/osv?legal_entity_id=А&counterparty_id=Б`
|
||
- **THEN** `total` = `holder_rows` пары, у всех строк ответа ЮЛ А и `counterparty_id = Б`
|
||
|
||
#### Scenario: Кандидаты другой стороны пары
|
||
- **WHEN** для той же пары запрошено `GET …/osv?legal_entity_id=Б&unmapped=true`
|
||
- **THEN** `total` = `missing_unmapped_rows` пары
|
||
|
||
### 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<n>.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<n>.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<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
|
||
содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер
|
||
правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
|
||
«Сальдо»).
|
||
|
||
#### Scenario: Выгрузка по оборотам
|
||
- **WHEN** пользователь выгружает пресет с `measure = turnover`
|
||
- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-<rule_no>.xlsx`
|
||
|
||
#### Scenario: Правило не найдено
|
||
- **WHEN** `rule_id` не существует
|
||
- **THEN** 404, файл не отдаётся, событие аудита не пишется
|
||
|
||
### Requirement: Пары участников без встречных записей
|
||
Ядро SHALL вычислять по своду версии пары участников без встречных записей (§3 п. 9 `05-domain-rules.md`, AURORA-1746):
|
||
для участников периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна
|
||
строка ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение) и нет ни одной строки ЮЛ Б с
|
||
`counterparty_id = А`. А — сторона, у которой записи есть (`holder`), Б — сторона без встречных записей (`missing`).
|
||
Пара, где записей нет ни у А, ни у Б, в список MUST NOT входить. Строки ЮЛ с контрагентом — самим собой не
|
||
учитываются. Счета, суммы и тип строки (`data_type`) на признак не влияют: учитываются все строки свода. Расчёт —
|
||
чистая функция в `domain/` от строк свода и периметра; результат MUST NOT сохраняться. Порядок списка — по имени Б,
|
||
затем по имени А.
|
||
|
||
У каждой пары SHALL быть: `holder_id`, `holder_name`, `missing_id`, `missing_name`, `holder_rows` (число строк А с
|
||
контрагентом Б), `missing_unmapped_rows` (число строк Б с `unmapped = true`), `missing_rows_total` (число всех строк Б в
|
||
своде; 0 — у Б нет строк, например не привязано ни одной ОСВ или все её пары счетов — пробелы).
|
||
|
||
#### Scenario: Русский бисквит до сопоставления
|
||
- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО «Русский бисквит», ООО «Пекарня Счастливочная»}, 22 ОСВ
|
||
из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` подтверждены; у Счастливочной контрагент «Русский бисквит» с ИНН
|
||
3528052718 сопоставлен по ИНН; у РБ контрагент «Пекарня Счастливочная ООО» без ИНН не сопоставлен
|
||
- **THEN** список содержит одну пару: `holder` — ООО «Пекарня Счастливочная», `missing` — АО «Русский бисквит»,
|
||
`holder_rows` ≥ 1, `missing_unmapped_rows` ≥ 1
|
||
|
||
#### Scenario: Пара закрыта ручным сопоставлением
|
||
- **WHEN** в той же версии строка РБ «Пекарня Счастливочная ООО» связана с ООО «Пекарня Счастливочная»
|
||
(`POST …/osv/entries/{entry_id}/counterparty`)
|
||
- **THEN** список пар пуст
|
||
|
||
#### Scenario: Компании не связаны
|
||
- **WHEN** в периметре три участника; у А и Б нет записей друг с другом ни в одну сторону
|
||
- **THEN** пары (А, Б) и (Б, А) в списке нет
|
||
|
||
#### Scenario: У стороны нет строк
|
||
- **WHEN** у А есть строки с контрагентом Б, а у Б в своде нет ни одной строки (ОСВ не привязаны)
|
||
- **THEN** пара (А, Б) в списке, `missing_rows_total = 0`, `missing_unmapped_rows = 0`
|