From 9c57f680af7d59525aa056796ac8c0a6b7e51fd2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 23:02:24 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D0=B2=20OpenSpec=20eli?= =?UTF-8?q?mination-pairs-rule-totals=20(PR=20#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 0 .../README.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/consolidations-api/spec.md | 0 .../specs/data-model/spec.md | 0 .../tasks.md | 0 openspec/specs/consolidations-api/spec.md | 147 ++++++++++++++---- openspec/specs/data-model/spec.md | 19 ++- 9 files changed, 137 insertions(+), 29 deletions(-) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/.openspec.yaml (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/README.md (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/design.md (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/proposal.md (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/specs/consolidations-api/spec.md (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/specs/data-model/spec.md (100%) rename openspec/changes/{elimination-pairs-rule-totals => archive/2026-10-23-elimination-pairs-rule-totals}/tasks.md (100%) diff --git a/openspec/changes/elimination-pairs-rule-totals/.openspec.yaml b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/.openspec.yaml similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/.openspec.yaml rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/.openspec.yaml diff --git a/openspec/changes/elimination-pairs-rule-totals/README.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/README.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/README.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/README.md diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/design.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/design.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/design.md diff --git a/openspec/changes/elimination-pairs-rule-totals/proposal.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/proposal.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/proposal.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/proposal.md diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/specs/consolidations-api/spec.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/specs/consolidations-api/spec.md diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/specs/data-model/spec.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/specs/data-model/spec.md diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/tasks.md similarity index 100% rename from openspec/changes/elimination-pairs-rule-totals/tasks.md rename to openspec/changes/archive/2026-10-23-elimination-pairs-rule-totals/tasks.md diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index abd8832..201f922 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -26,6 +26,11 @@ Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_versi видима (404) → ГСК видима (404) и соответствует ГК (422) → периметр (422) → период свободен (409). Блокировка на создателя MUST NOT ставиться; `MEMBERSHIP_CHANGE` при создании MUST NOT писаться. `related_group_id` в `consolidation` SHALL оставаться NULL (фиксируется при верификации, Срез 7); `related_group_member` MUST NOT изменяться. +В `consolidation_version.elimination_rules_version` SHALL записываться номер версии правил элиминации, действующей на +московскую календарную дату создания: максимальный `version_no` среди правил с `valid_from <= дата` и `valid_to IS NULL` +либо `valid_to >= дата` (OQ-174; DBML v5.23, поле NOT NULL). Пользователь версию правил не выбирает; год, квартал и +`reporting_date` на номер не влияют; позже номер не меняется. Нет правил, действующих на дату, → 409 +`ELIMINATION_RULES_MISSING`, консолидация не создаётся. #### Scenario: Консолидация РБ за Q3 2024 - **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3, без @@ -57,6 +62,14 @@ SHALL оставаться NULL (фиксируется при верифика - **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна - **THEN** 409 `CONSOLIDATION_EXISTS` +#### Scenario: Правила действуют на дату создания версии +- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде +- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил + +#### Scenario: Нет действующих правил +- **WHEN** у всех правил `valid_from` позже даты создания +- **THEN** 409 `ELIMINATION_RULES_MISSING`; `consolidation` и версия не созданы + ### Requirement: Список консолидаций ГК и карточка версии `GET /company-groups/{id}/consolidations` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать периоды ГК (`consolidation_id`, `year`, `quarter`, `reporting_date`) по убыванию года и квартала, в каждом — версии по убыванию `version_no` @@ -224,33 +237,6 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` - **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock` - **THEN** 404 `VERSION_NOT_FOUND` без ожидания -### Requirement: Правила элиминации версии -`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) -SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан -`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а -`rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` — -открытые правила (`valid_to IS NULL`) только с максимальным `version_no`; ответ SHALL содержать этот фактический -`version_no`. При отсутствии открытых правил `version_no = null`, `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`, -`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`; -каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов. -Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. - -#### Scenario: Правило с исключением -- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5 -- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded` - -#### Scenario: Версия правил зафиксирована -- **WHEN** у версии консолидации `elimination_rules_version = 1` -- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1` - -#### Scenario: Версия правил не зафиксирована -- **WHEN** у версии консолидации `elimination_rules_version` пуст -- **THEN** ответ содержит `is_version_fixed = false`, фактический `version_no` и открытые правила только этой версии - -#### Scenario: Открыты две версии правил -- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no` -- **THEN** ответ содержит только правила версии 2 и `version_no = 2` - ### Requirement: Результирующая таблица ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра @@ -288,3 +274,110 @@ policy) SHALL возвращать XLSX с теми же статьями, ко #### Scenario: Версия вне видимости - **WHEN** пользователь филиала выгружает таблицу версии чужой ГК - **THEN** ответ 404 с тем же телом, что у несуществующей версии + +### Requirement: Правила элиминации зафиксированной версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) +SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version` +(с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` — +правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL +содержать `id`, `rule_no`, `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), +`company_1`, `company_2`; каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии +счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. + +#### Scenario: Правило с исключением +- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5 +- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded` + +#### Scenario: Версия правил зафиксирована +- **WHEN** у версии консолидации `elimination_rules_version = 1` +- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1` + +#### Scenario: Опубликована новая версия правил +- **WHEN** после создания версии консолидации с `elimination_rules_version = 1` открыта версия правил 2 +- **THEN** ответ по-прежнему содержит `version_no = 1` и правила версии 1; пресет правила версии 2 для этой версии — 404 + +### Requirement: Пары ВГО тройки +`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs?rule_id=&company_a_id=&company_b_id=` SHALL требовать +`CONSOLIDATION_VIEW` и видимость версии по ГК; три параметра обязательны. Компании SHALL быть разными участниками +периметра версии (иначе 422 `INVALID_COMPANY_PAIR`), правило — правилом ББ версии правил версии консолидации с +`measure = closing` (иначе 404 `NOT_FOUND` или 422 `INVALID_RULE`). Отбор счетов, раскрытие счетов-предков и исключения +SHALL совпадать с шахматкой PRESET. Расчёт — по `05-domain-rules.md` §5, на лету, без сохранения. + +Итог тройки: `amount_a` — сумма строк A с контрагентом B по счетам стороны 1, `amount_b` — строк B с контрагентом A по +счетам стороны 2, **со знаком** (как в шахматке); `deviation_amount = |amount_a − amount_b|`; `deviation_percent` — к +`min(amount_a, amount_b)`, `null`, если он ≤ 0; `exceeds_threshold` — процент > 5 % или меньшая сторона ≤ 0 при ненулевом +расхождении; `eliminated_amount = min(amount_a, amount_b)` при расхождении ≤ 5 %, иначе, при `elimination_mode = manual` и +при меньшей стороне ≤ 0 — 0. Упаковка итог не меняет. Каждое правило считается отдельно: строка общего счёта может войти +в тройки нескольких правил (каскад отменён, AURORA-1751). + +Упаковка: строка свода неделима; в группы идут только строки с суммой > 0; группы 1:1, 1:N, N:1 при N ≤ 5, N:M нет; +сначала меньшее число строк, внутри размера — точные, затем с расхождением ≤ 5 % к меньшей сумме по возрастанию +**процента** расхождения; строка — не более чем в одной группе правила; группы одного размера с одинаковым процентом, +делящие строку, не сопоставляются. Ответ SHALL содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, +`company_b_id`, итог тройки, `matched[]` (точные группы), `with_discrepancy[]` (расхождение ≤ 5 %) и `unpaired[]`. Группа — +`{rule_id, company_a_id, company_b_id, amount_a, amount_b, deviation_amount, deviation_percent, osv_entry_ids_a[], +osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`: `NO_MATCH`, `AMBIGUOUS` (ничья), `ZERO_AMOUNT`, +`NEGATIVE_AMOUNT` (сумма < 0, в упаковку не идёт). Суммы и проценты в JSON SHALL быть строками с двумя знаками. + +#### Scenario: Точная строка среди несопоставленных остатков +- **WHEN** правило № 6 для Счастливочной и АО «Русский бисквит» даёт суммы 110 429 113,15 и 106 311 116,46 руб., + а точная группа 62.01 ↔ 60.01 составляет 106 311 116,46 руб. +- **THEN** точная группа в `matched`, три остальные строки стороны A — в `unpaired`, `eliminated_amount = 106311116.46`, + `deviation_percent = 3.87` + +#### Scenario: Отрицательная строка +- **WHEN** у A по B строки 150 и −50, у B по A строка 100 +- **THEN** `amount_a = 100.00`, `amount_b = 100.00`, `eliminated_amount = 100.00`; строка −50 — в `unpaired` с причиной + `NEGATIVE_AMOUNT` + +#### Scenario: Две строки свода упакованы с одной встречной +- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100 +- **THEN** `matched[]` содержит группу с суммами 100.00 и 100.00, `deviation_amount = 0.00` и исходными `osv_entry_ids_a/b` + +#### Scenario: Ручное правило и превышение порога +- **WHEN** правило ручное либо расхождение больше 5 % +- **THEN** `eliminated_amount = 0.00` независимо от упаковки + +#### Scenario: Исключённый счёт не входит в пары +- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5 +- **THEN** строки счёта 76.07.5 и его потомков не входят ни в группы, ни в `unpaired` + +#### Scenario: Другая пара компаний не смешивается с выбранной +- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б` +- **THEN** ответ содержит только строки A→Б и Б→A + +#### Scenario: Расхождение сравнивается в процентах +- **WHEN** со строкой 100 стороны A могут сойтись строки B 95,50 (4,50 руб., 4,71 %) и 104,60 (4,60 руб., 4,60 %) +- **THEN** группа — 100 ↔ 104,60; строка 95,50 — в `unpaired` с причиной `NO_MATCH` + +#### Scenario: Правило по оборотам +- **WHEN** запрошена тройка по правилу ББ с `measure = turnover` +- **THEN** 422 `INVALID_RULE` + +### Requirement: Пары ВГО версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL +возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил × +упорядоченные пары разных компаний периметра, у которых есть строки хотя бы на одной стороне. Сводная ОСВ SHALL строиться +один раз на запрос. Элемент — `{rule_id, rule_no, elimination_mode, company_a_id, company_b_id, amount_a, amount_b, +deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}` +с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из +сумм нулевая; `AUTO` — `eliminated_amount > 0`; иначе `DISCREPANCY` (расхождение > 5 % или меньшая сторона < 0). Порядок — `rule_no`, затем порядок компаний периметра. +Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`. + +#### Scenario: Список совпадает с тройкой +- **WHEN** по правилу № 6 у A на B 300, у B на A 100, у B на A дебиторка 70 и у A на B кредиторка 70 +- **THEN** в `items` тройка (6, A, B) — `DISCREPANCY`, `eliminated_amount = 0.00`; тройка (6, B, A) — `AUTO`, + `eliminated_amount = 70.00`; те же суммы возвращает `elimination-pairs` для каждой тройки + +#### Scenario: Пересечение правил +- **WHEN** дебиторка A на B по счетам № 6 есть, а займов B у A (67) нет +- **THEN** в `items` есть и тройка (6, A, B), и тройка (7, A, B) со статусом `ONE_SIDED` — двойной учёт до разнесения + строк (AURORA-1751) + +#### Scenario: Правило без строк +- **WHEN** по счетам правила № 3 строк ни у A, ни у B нет +- **THEN** тройки (3, A, B) в `items` нет + +#### Scenario: Обе стороны отрицательные +- **WHEN** у A по B −50, у B по A −50 +- **THEN** тройка `DISCREPANCY`, `deviation_percent = null`, `eliminated_amount = 0.00` diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index 388d7e9..c99a01e 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -102,11 +102,11 @@ enum'ы, таблицы, ограничения, индексы и послед - **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) ### Requirement: Версия DBML зафиксирована в коде -Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.23"`); скрипт сверки SHALL сравнивать её с версией в шапке файла DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. #### Scenario: Новая версия DBML без обновления ORM -- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21` +- **WHEN** в `docs/model/` появляется `data-model-v5.24.dbml`, а константа в ORM — `5.23` - **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц ### Requirement: Деньги и проценты — точные типы @@ -196,3 +196,18 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут #### Scenario: Независимый откат enum - **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20 - **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены + +### Requirement: Миграция DBML v5.23 — версия правил элиминации обязательна +Миграция `0010_dbml_v5_23` после `0009_dbml_v5_21` SHALL заполнить пустой `consolidation_version.elimination_rules_version` +номером версии правил, действовавшей на московскую дату `created_at` версии (пустой `created_at` — текущая дата), а если +такой нет — последней открытой версии (`valid_to IS NULL`), затем сделать колонку NOT NULL. Если после заполнения +остаются пустые значения (правил нет вовсе), миграция SHALL завершиться ошибкой с числом таких версий до изменения схемы. +Откат SHALL снимать только NOT NULL, значения сохраняются. v5.22 структуру БД не меняла, отдельной миграции у неё нет. + +#### Scenario: Заполнение по дате создания +- **WHEN** правила v1 действуют до 30.06.2026, v2 — с 01.07.2026, версии созданы 01.03.2026, 30.06.2026 21:30 UTC и без даты +- **THEN** после `upgrade head` у них 1, 2 и 2; колонка NOT NULL; `downgrade 0009_dbml_v5_21` снимает NOT NULL, значения остаются + +#### Scenario: Версии без правил +- **WHEN** в БД есть версия консолидации и нет ни одного правила элиминации +- **THEN** `upgrade head` падает с ошибкой «миграция v5.23 невозможна…», ревизия остаётся `0009_dbml_v5_21`