CounterpartyIndex.resolve: порядок шагов ИНН -> точное имя -> алиас -> нормализованное имя (OQ-128, §3 п. 4). Алиас уникален (ALIAS_TAKEN), поэтому владелец алиаса берётся с match_kind = ALIAS, даже если есть ЮЛ-тёзки по нормализованному имени; NULL остаётся при нескольких совпадениях только по имени. Правило общее для маппинга (Срез 2c) и дорезолва свода на чтение (§3 п. 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
154 lines
16 KiB
Markdown
154 lines
16 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_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` пуст после дорезолва), `match_kind` (повторяемый), `q`
|
||
(подстрока без регистра в `counterparty_name` или префикс `counterparty_inn`). `GET …/osv/summary` SHALL отдавать
|
||
`rows_total`, `unmapped_total`, `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`, `total` = `summary.unmapped_total`
|
||
|
||
#### 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<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`
|