Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
16 KiB
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_debit100/300/450 иturnover_debit200/150/50,closing_debit300/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_idnull; третья —GK
Scenario: Дорезолв на чтение
- WHEN у строки
counterparty_idпуст, а после маппинга в справочнике появился алиас, по которому имя резолвится однозначно - THEN в ответе
counterparty_idзаполнен,match_kind = ALIAS,match_stored = false; вosv_entryзначение не изменилось
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