Andrey Epifancev 7dcffbd10d Резолв контрагента: алиас приоритетнее ЮЛ-тёзки по нормализованному имени (#57)
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
2026-10-08 00:21:15 +04:00

154 lines
16 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.

# 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`