diff --git a/README.md b/README.md index f04b359..455084c 100644 --- a/README.md +++ b/README.md @@ -940,8 +940,9 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). -`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной -версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, +`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии +`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату +создания, OQ-174) и с DBML v5.23 есть всегда, `is_version_fixed = true`. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на `_rule-balance-.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель», audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический @@ -951,6 +952,17 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. +**Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки +(ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1 +(`matched`, `with_discrepancy`, `unpaired` с причиной); `GET …/elimination-totals` — итоги всех троек версии (правила ББ × +упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз. +Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия, +правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`. Решения — +`openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2. +Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и +делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён +раньше. Откат снимает только NOT NULL. + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -1031,6 +1043,7 @@ email без учёта регистра проверяет только сер | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | +| `ELIMINATION_RULES_MISSING` | 409 | `POST /consolidations`: нет правил элиминации, действующих на дату создания (DBML v5.23: версия правил обязательна) | | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | | `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) | | `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` | @@ -1041,6 +1054,7 @@ email без учёта регистра проверяет только сер | `ACCOUNT_KIND_MISMATCH` | 422 | MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне | | `ACCOUNTS_REQUIRED` | 422 | сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела | | `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей | +| `INVALID_COMPANY_PAIR` | 422 | пары ВГО тройки: компании совпадают или не входят в периметр версии | | `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки | | `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | | `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» | diff --git a/openspec/changes/elimination-pairs-rule-totals/README.md b/openspec/changes/elimination-pairs-rule-totals/README.md index cfc19ce..46de9a6 100644 --- a/openspec/changes/elimination-pairs-rule-totals/README.md +++ b/openspec/changes/elimination-pairs-rule-totals/README.md @@ -1,3 +1,4 @@ # elimination-pairs-rule-totals -Тесты упаковки и итог элиминации для тройки правила и компаний +Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL), +итог и упаковка тройки «правило × A × B», список троек версии. diff --git a/openspec/changes/elimination-pairs-rule-totals/design.md b/openspec/changes/elimination-pairs-rule-totals/design.md index d9c793e..4fc01b3 100644 --- a/openspec/changes/elimination-pairs-rule-totals/design.md +++ b/openspec/changes/elimination-pairs-rule-totals/design.md @@ -1,10 +1,33 @@ ## Решение -Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция. -`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount` -содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`. -Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию. +Итог тройки `(rule_id, A, B)` считается чистой функцией по строкам обеих сторон до упаковки: суммы сторон со знаком, +`deviation_amount = |ΣA − ΣB|`, процент к меньшей стороне, `eliminated_amount = min(ΣA, ΣB)` при расхождении ≤ 5 %, +иначе и для `manual` — 0. Упаковка итог не меняет. Каждое правило считается отдельно (каскад по остатку отменён +владельцем 21.10.2026): строка общего счёта входит в несколько правил, двойной учёт снимает AURORA-1751. + +Сбор встречных строк тройки — одна функция `_collect_rule_sides` для итога, упаковки и списка версии: список версии +(`build_totals`) строит сводную ОСВ один раз и проходит правила ББ × упорядоченные пары периметра без упаковки. + +Версия правил фиксируется при создании версии консолидации (`latest_effective_version_no` на московскую дату) и дальше +не меняется; ветки «правила не зафиксированы» в чтении правил, пресете шахматки и парах убраны. -Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`. Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны, -поэтому его общий итог не равен системному правилу минимума. +поэтому его общий итог не равен системному правилу минимума (OQ-155). + +## Решения владельца 23.10.2026 (ревью PR #175) + +1. Суммы сторон — со знаком, как в шахматке; строки с отрицательной суммой в упаковку не идут (`NEGATIVE_AMOUNT`). +2. Группы одного размера сортируются по проценту расхождения, а не по сумме. +3. Кроме тройки — список пар всей версии одним запросом (делаем сами в ветке PR). +4. `elimination_rules_version` — NOT NULL с миграцией существующих версий (DBML v5.23). + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #1) | 23.10.2026 | При `min(ΣA, ΣB) ≤ 0` процент `null`, элиминация 0, тройка подсвечивается при любом расхождении | Минимум со знаком дал бы элиминацию отрицательной суммы или отрицательный процент, который не пробивает порог; аналитик должен разобрать такой случай | Поменять одну функцию `calculate_rule_totals` и §5 | +| R-2 (design #2) | 23.10.2026 | Список версии — `GET …/elimination-totals`, статусы `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`; тройки без строк не возвращаются; только правила ББ с `measure = closing` | Повторяет три категории задачи («сопоставленные, с расхождением, без пары») плюс ручные правила; ОФР в §4 не формализован | Переименование маршрута и статусов — правка фронта до его разработки | +| R-3 (design #3) | 23.10.2026 | Создание версии без правил, действующих на дату, — 409 `ELIMINATION_RULES_MISSING` | Поле NOT NULL; подставлять «последние открытые» значило бы взять правила, ещё не вступившие в силу | При пустом справочнике правил консолидацию не создать до seed — на практике seed всегда заводит v1 | +| R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации | +| R-5 (design #5) | 23.10.2026 | `is_version_fixed` в ответе `elimination-rules` оставлен и всегда `true` | Фронт шахматки уже читает поле (PR #141, #158) | Удалить поле позже вместе с фронтом | +| R-6 (design #6) | 23.10.2026 | Группа упаковки — `deviation_amount` вместо `elimination_amount`; отдельной суммы элиминации у группы нет | В КФО идёт итог тройки; упаковка его не меняет (§5), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится | diff --git a/openspec/changes/elimination-pairs-rule-totals/proposal.md b/openspec/changes/elimination-pairs-rule-totals/proposal.md index c2817c2..514fbf7 100644 --- a/openspec/changes/elimination-pairs-rule-totals/proposal.md +++ b/openspec/changes/elimination-pairs-rule-totals/proposal.md @@ -1,16 +1,21 @@ ## Why -Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки, -включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным -контрактом для фронта. +Срез 5, AURORA-1536: автоматическое сопоставление ВГО по `05-domain-rules.md` §5. Нужны итог элиминации по тройке +«правило, компания A, компания B» (идёт в КФО), детализация строк ОСВ по группам и список всех троек версии для +таблицы корректировок (AURORA-1540, AURORA-1541) и разнесения строк (AURORA-1751). Версия правил, по которой считается +версия консолидации, должна быть зафиксирована всегда, иначе после публикации новых правил старые консолидации молча +пересчитаются по ним. ## What Changes -- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму - элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю. -- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках. -- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel. -- Описать новый ответ в гайде фронта. +- При создании версии консолидации фиксировать `elimination_rules_version` — версию правил, действующую на дату + создания (OQ-174); DBML v5.23 делает поле NOT NULL, миграция `0010_dbml_v5_23` заполняет существующие версии; + без действующих правил версия не создаётся (409 `ELIMINATION_RULES_MISSING`). +- `GET …/elimination-pairs` — итог тройки (суммы сторон со знаком, расхождение, процент, сумма элиминации по правилу 5 %) + и упаковка 1:1 / 1:N / N:1 с причинами строк без пары. +- `GET …/elimination-totals` — итоги всех троек версии одним запросом со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / + `MANUAL`. +- `GET …/elimination-rules` — всегда правила зафиксированной версии. ## Capabilities @@ -20,15 +25,17 @@ ### Modified Capabilities -- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк. +- `consolidations-api`: создание консолидации фиксирует версию правил; правила зафиксированной версии; пары ВГО тройки и + версии. +- `data-model`: DBML v5.23, миграция `0010_dbml_v5_23`. ## Impact -`arm-core`: чистый расчёт, сервис и схема ответа, тесты, OpenSpec. `arm-ka-docs`: гайд фронта. -Читаются таблицы `consolidation_version`, `consolidation_member`, `consolidation_document`, `osv_entry`, -`elimination_rule`, `elimination_rule_account`, `chart_of_account`; DBML и хранимые данные не меняются. -Основание — `context/05-domain-rules.md` §4–§5, решение владельца 08.10.2026 о минимуме пары. +`arm-core`: миграция, ORM, чистый расчёт, сервис, схемы, маршрут, тесты, OpenSpec. `arm-ka-docs` (#164): DBML v5.23, +§4–§5 без каскада, OQ-173/174/175, гайд фронта §5.7.2. Основание — `context/05-domain-rules.md` §4–§5, решения +владельца 08.10, 19.10, 21.10 и 23.10.2026. ## Non-goals -Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР. +Сохранение пар в БД (DECISION-5), разнесение строк по правилам и снятие двойного учёта (AURORA-1751), корректировки +(AURORA-1540), правила и пары ОФР. diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md index 4eef515..10b65a3 100644 --- a/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md +++ b/openspec/changes/elimination-pairs-rule-totals/specs/consolidations-api/spec.md @@ -1,21 +1,168 @@ +## MODIFIED Requirements + +### Requirement: Создание консолидации +`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter, +legal_entity_ids?}` SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year, +Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`, +`approach = BASIC`, `goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя, +`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись +`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см. +`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого +`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без +`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после +схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации +(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке +запроса; состав ГСК при явном списке не проверяется и не меняется. ГСК SHALL быть обязательной, принадлежать ГК и иметь +`kind = gsk`, иначе 422 `RELATED_GROUP_MISMATCH`. Периметр меньше двух ЮЛ (членов ГСК без списка либо выбранных ЮЛ после +схлопывания) → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть → 409 `CONSOLIDATION_EXISTS` (в том числе при +гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); `year` вне 2000..2100 → 422. Порядок проверок: ГК +видима (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, без + `legal_entity_ids` +- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью, + `audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет + +#### Scenario: Выбранный периметр шире ГСК +- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК +- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился + +#### Scenario: ГСК из одного ЮЛ, второе отмечено вручную +- **WHEN** в ГСК один текущий член, в `legal_entity_ids` он и ещё одно ЮЛ той же ГК +- **THEN** 201; в `members` два ЮЛ + +#### Scenario: ГСК из одного ЮЛ +- **WHEN** в ГСК один текущий член, `legal_entity_ids` не передан +- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы + +#### Scenario: ЮЛ другой ГК в списке +- **WHEN** в `legal_entity_ids` попало ЮЛ, `company_group_id` которого не равен ГК консолидации +- **THEN** 422 `ENTITY_NOT_IN_GROUP` с этим ЮЛ в `legal_entity_ids`; `consolidation` не создана + +#### Scenario: Дубли схлопываются до одного ЮЛ +- **WHEN** `legal_entity_ids` — один и тот же идентификатор дважды +- **THEN** 422 `PERIMETER_TOO_SMALL` + +#### Scenario: Повтор периода +- **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` и версия не созданы + ## ADDED Requirements -### Requirement: Итог элиминации тройки независимо от упаковки +### 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`. -Для `(rule_id, company_a_id, company_b_id)` API SHALL возвращать суммы всех выбранных строк ОСВ обеих сторон, -абсолютное расхождение `elimination_amount`, процент расхождения к меньшей сумме, `exceeds_threshold` и отдельное -`eliminated_amount`. При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; -при превышении порога, нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк -не меняет этот итог. +#### 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`, расхождение округляется до 3,87 %. +- **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`, независимо от результата упаковки. +- **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 + +### 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` — одна из +сумм нулевая; `DISCREPANCY` — `exceeds_threshold`; иначе `AUTO`. Порядок — `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` нет + +## REMOVED Requirements + +### Requirement: Правила элиминации версии +**Reason**: с DBML v5.23 `elimination_rules_version` задан у каждой версии консолидации; ветка «версия правил не +зафиксирована — последние открытые правила» больше не существует. +**Migration**: требование «Правила элиминации зафиксированной версии»; клиенту `is_version_fixed` всегда `true`. diff --git a/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md b/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md new file mode 100644 index 0000000..4b5d9e7 --- /dev/null +++ b/openspec/changes/elimination-pairs-rule-totals/specs/data-model/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.23"`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.24.dbml`, а константа в ORM — `5.23` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц + +## ADDED Requirements + +### 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` diff --git a/openspec/changes/elimination-pairs-rule-totals/tasks.md b/openspec/changes/elimination-pairs-rule-totals/tasks.md index 5c0205a..32ba09b 100644 --- a/openspec/changes/elimination-pairs-rule-totals/tasks.md +++ b/openspec/changes/elimination-pairs-rule-totals/tasks.md @@ -1,7 +1,12 @@ ## Реализация -- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны. -- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ. -- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил. -- [x] Дополнить основной OpenSpec и гайд фронта. -- [ ] Запустить `make check-full` и заархивировать change. +- [x] Чистый расчёт итога тройки и тесты порога, ручного режима и нулевой стороны. +- [x] Реальные строки правила № 6 (точная пара и строки без пары) по golden ОСВ; 42 млн по правилу № 4; ничья упаковки; + обратная пара. +- [x] Фиксация версии правил при создании версии; DBML v5.23, миграция `0010_dbml_v5_23` и её тесты; 409 + `ELIMINATION_RULES_MISSING`. +- [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка. +- [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164). +- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы + с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор). +- [ ] `make check-full` и архив change. diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 660e7e9..abd8832 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -15,13 +15,7 @@ Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_versi `is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись `consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см. `documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого -`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. -При создании версии система SHALL автоматически записать в `consolidation_version.elimination_rules_version` номер -самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату -создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146). -Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера. -Если на дату создания нет действующих правил, поле остаётся NULL. -Периметр SHALL быть: без +`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без `legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации (`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке @@ -39,10 +33,6 @@ SHALL оставаться NULL (фиксируется при верифика - **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью, `audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет -#### Scenario: Правила действуют на дату создания версии -- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде -- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил - #### Scenario: Выбранный периметр шире ГСК - **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК - **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился @@ -261,56 +251,6 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`. - **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no` - **THEN** ответ содержит только правила версии 2 и `version_no = 2` -### Requirement: Сопоставленные строки сводной ОСВ по правилу и паре компаний -`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs` SHALL требовать `CONSOLIDATION_VIEW` и проверять -видимость версии по ГК. Запрос SHALL принимать обязательные query-параметры `rule_id`, `company_a_id`, `company_b_id`. -Компании SHALL быть разными участниками периметра версии, а правило — пресетом ББ её версии правил. Для этой тройки -SHALL возвращаться только её результат; строки других правил и направленных пар компаний в расчёт не входят. -Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL -содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы), -`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного -перечня допустимых пар счетов. На уровне тройки ответ SHALL содержать `amount_a`, `amount_b` (суммы всех выбранных -строк, включая оставшиеся без пары), `elimination_amount` (абсолютное расхождение), `deviation_percent` (от меньшей -суммы, `null` при нуле), `eliminated_amount` (сумма фактической элиминации) и `exceeds_threshold`. -При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; при превышении порога, -нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк SHALL не менять этот итог. -Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`, -`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`. -Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться -`abs(amount_a - amount_b)`. -Невидимая и несуществующая версия или отсутствующее правило SHALL отвечать 404 `NOT_FOUND`; -недопустимая пара компаний — 422 `INVALID_COMPANY_PAIR`. Пары и группы не сохраняются. - -Строка сводной ОСВ неделима, даже если содержит несколько `entry_ids`. Внутри выбранной тройки строки с распознанным -встречным ЮЛ и допустимыми счетами группируются как 1:1, 1:N или N:1 при N ≤ 5; N:M не допускается. Комбинации -строятся из всех строк выбранных счетов соответствующей стороны правила, без дополнительного требования совпадения -конкретных счетов внутри группы. -Сравниваются модули остатков по соответствующей стороне. Нулевые строки остаются в `unpaired` с причиной -`ZERO_AMOUNT`. Сначала выбираются группы с наименьшим общим числом строк: 1:1, затем 1:2 и 2:1, и так до 1:5 и 5:1. -Внутри размера сначала точные, затем с расхождением не более 5 % относительно меньшей из двух сумм, по возрастанию -расхождения. Строка входит не более чем в одну группу данного правила. Если группы одного размера с одинаковым -расхождением пересекаются, все строки конкурирующих групп исключаются из автоматического поиска и возвращаются в -`unpaired` с причиной `AMBIGUOUS` для ручного разбора. Остальные строки без группы возвращаются в `unpaired` с -причиной `NO_MATCH` независимо от того, отсутствуют ли встречные суммы, превышен ли порог или подходящие строки уже -заняты другой группой. -Упаковка не меняет сумму элиминации. - -#### Scenario: Исключённый счёт не входит в пары -- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5 -- **THEN** строки счёта 76.07.5 и его потомков не входят в сопоставленные группы и `unpaired` - -#### Scenario: Две строки свода упакованы с одной встречной -- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100 -- **THEN** `matched[]` содержит пару с суммами 100 и 100, нулевым расхождением и исходными `osv_entry_ids_a/b` - -#### Scenario: Пара с допустимым расхождением -- **WHEN** группа имеет суммы 103 и 100 при пороге 5 % -- **THEN** она входит в `with_discrepancy[]`, а не в `matched[]` - -#### Scenario: Другая пара компаний не смешивается с выбранной -- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б` -- **THEN** ответ содержит только строки A→Б и Б→A - ### Requirement: Результирующая таблица ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра