23 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_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_idnull; третья —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 пуст после дорезолва и
counterparty_inn пуст или из пробелов; строка с ИНН из файла, которого нет в справочнике, сопоставления не требует —
решение владельца 08.10.2026, отмена П-1; unmapped=false — все остальные строки), match_kind (повторяемый), q
(подстрока без регистра в counterparty_name или префикс counterparty_inn). 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). Версия в любом статусе, включая 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
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 включает все счета вида A/AP для дебета и P/AP для кредита. MANUAL принимает
обязательные непустые debit_account_ids: UUID[] и credit_account_ids: UUID[], раскрывает выбранные счета
по иерархии и оставляет подходящий вид. Неизвестный счёт 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 при наличии исходных записей ОСВ только с одной стороны,
в том числе при нулевой сумме. 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 у пары есть строка свода с
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, шапкой (ГК, период, версия, единицы, режим,
выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары.
Scenario: Экспорт выбранных счетов
- WHEN пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
- THEN имя содержит
Русский бисквит_2024-Q1_v1.xlsx, шапка содержит выбранные счета, ячейки процента показывают два знака и символ%без повторного масштабирования