16 KiB
Raw Blame History

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 значение не изменилось

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