Compare commits

...

9 Commits

Author SHA1 Message Date
aep
393bd4c484 Merge pull request 'AURORA-1536 пары ВГО §5, DBML v5.23' (#175) 2026-10-23 22:01:58 +03:00
Andrey Epifancev
8940f32ae3 fix: пары ВГО — суммы со знаком, процент в упаковке, deviation_amount (ревью #175, AURORA-1536)
- суммы сторон со знаком, как в шахматке; строки с суммой < 0 в упаковку не идут, причина NEGATIVE_AMOUNT;
  при min(ΣA, ΣB) ≤ 0 процент null и элиминация 0, статус списка AUTO — только при элиминации > 0
- группы одного размера выбираются по проценту расхождения к меньшей сумме (точной дробью), ничья — по равному проценту
- elimination_amount группы и тройки переименован в deviation_amount; version_no тройки обязателен
- суммы и проценты пар в JSON — с двумя знаками
- elimination-pairs по правилу с measure ≠ closing — 422 INVALID_RULE
- дельта спеки, design.md (R-7, R-8), tasks, README

make check-full: exit 0 (1896 passed; integration 990 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:47:47 +04:00
Andrey Epifancev
fdb1f179cc docs(openspec): elimination-pairs-rule-totals — дельта вместо правки основной спеки, решения, README (ревью #175)
Основная спека consolidations-api возвращена к main: фиксация версии правил,
пары тройки и список версии — в дельте change (MODIFIED «Создание
консолидации», ADDED «Пары ВГО тройки», «Пары ВГО версии», «Правила
элиминации зафиксированной версии», REMOVED старое требование правил);
data-model — DBML 5.23 и миграция 0010. design.md — решения владельца 23.10
и «Decisions made without discussion» R-1…R-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:44:31 +04:00
Andrey Epifancev
b4522addc0 feat: DBML v5.23 — версия правил обязательна; список пар ВГО версии (ревью #175, AURORA-1536)
- consolidation_version.elimination_rules_version NOT NULL: миграция 0010
  проставляет существующим версиям номер правил, действовавших на дату
  создания (по Москве), иначе последних открытых; без правил — отказ;
  создание версии без действующих правил — 409 ELIMINATION_RULES_MISSING;
  ветки «правила не зафиксированы» убраны (rules, preset, is_version_fixed
  всегда true);
- GET …/elimination-totals: все тройки «правило ББ × упорядоченная пара
  периметра» с ΣA, ΣB, расхождением, суммой элиминации, статусом
  AUTO/DISCREPANCY/ONE_SIDED/MANUAL и osv_entry_ids; сводная ОСВ строится
  один раз; сбор строк сторон вынесен в _collect_rule_sides — общий
  с упаковкой тройки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:10 +04:00
Andrey Epifancev
812e530fe5 style: ruff format elimination_packing (ревью #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:10 +04:00
Andrey Epifancev
888ad4b401 Merge origin/main в AURORA-1536 (ревью #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:34:35 +04:00
PotapovaA
827b490137 тесты + немного поправлен формат выходных данных 2026-10-23 16:20:51 +03:00
PotapovaA
010c15386b добавлен группировка строк осв для правила пресета 2026-10-23 15:55:54 +03:00
PotapovaA
232fecec39 автоматическое проставление версии правил при создании консолидация, если таких создается версия консолидации создается с NULL 2026-10-23 12:10:35 +03:00
34 changed files with 1806 additions and 125 deletions

View File

@ -175,7 +175,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.22.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- ORM 1:1 с `../docs/model/data-model-v5.23.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
DBML ?= ../../docs/model/data-model-v5.22.dbml
DBML ?= ../../docs/model/data-model-v5.23.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev

View File

@ -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-<rule_no>.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический
@ -951,6 +952,18 @@ 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` с причиной `NO_MATCH` / `AMBIGUOUS` / `ZERO_AMOUNT` / `NEGATIVE_AMOUNT`;
группы сортируются по проценту расхождения, отрицательные строки в упаковку не идут); `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 +1044,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 +1055,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` при «взять в работу» |
@ -1127,7 +1142,7 @@ email без учёта регистра проверяет только сер
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.22.dbml` |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.23.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1179,7 +1194,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.22.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1202,7 +1217,7 @@ make migration-cycle # проверить цикл upgr
```
api/
run_server.py точка входа (uvicorn; хост и порт — из настроек)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21, 0010_dbml_v5_23.py — v5.23; v5.22 структуру не меняла)
rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям
@ -1286,7 +1301,7 @@ Dockerfile только для локального compose
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
[`../docs/model/data-model-v5.22.dbml`](../docs/model/data-model-v5.22.dbml) — модель данных;
[`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных;
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;

View File

@ -0,0 +1,28 @@
"""DBML v5.23: consolidation_version.elimination_rules_version NOT NULL; пустые — по дате создания (§4, OQ-174)."""
from alembic import op
from src.models.base import SCHEMA
revision = "0010_dbml_v5_23"
down_revision = "0009_dbml_v5_21"
branch_labels = None
depends_on = None
def upgrade() -> None:
# действующая на дату создания (по Москве), иначе последняя открытая; без правил версия не заполнится — отказ ниже
created = "(COALESCE(v.created_at, now()) AT TIME ZONE 'Europe/Moscow')::date"
op.execute(f"""UPDATE {SCHEMA}.consolidation_version v SET elimination_rules_version = COALESCE(
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r
WHERE r.valid_from <= {created} AND (r.valid_to IS NULL OR r.valid_to >= {created})),
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r WHERE r.valid_to IS NULL))
WHERE v.elimination_rules_version IS NULL""")
op.execute(f"""DO $$ DECLARE n bigint; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.consolidation_version WHERE elimination_rules_version IS NULL;
IF n > 0 THEN RAISE EXCEPTION 'миграция v5.23 невозможна: нет правил элиминации для версий консолидации: %', n;
END IF; END $$""")
op.alter_column("consolidation_version", "elimination_rules_version", nullable=False, schema=SCHEMA)
def downgrade() -> None:
op.alter_column("consolidation_version", "elimination_rules_version", nullable=True, schema=SCHEMA)

View File

@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.22.dbml"
DBML_FILE_NAME = "data-model-v5.23.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"

View File

@ -6,9 +6,9 @@ from typing import Any
from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import EliminationRuleServiceDep
from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.consolidations import EliminationRulesOut
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut, EliminationTotalsOut
router = APIRouter(prefix="/consolidations/versions")
@ -16,6 +16,10 @@ VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
NOT_FOUND: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
}
PAIRS_RESPONSES: dict[int | str, dict[str, Any]] = {
**NOT_FOUND,
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Ошибка в составе правила или счетов"},
}
@router.get(
@ -31,3 +35,36 @@ async def list_rules(
) -> EliminationRulesOut:
"""Правила §4 зафиксированной либо последней открытой версии с основанием выбора."""
return await service.list_rules(version_id)
@router.get(
"/{version_id}/elimination-totals",
response_model=EliminationTotalsOut,
dependencies=VIEW,
responses=PAIRS_RESPONSES,
summary="Пары ВГО версии: итоги всех троек",
)
async def build_elimination_totals(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> EliminationTotalsOut:
"""Правила ББ × упорядоченные пары компаний периметра: суммы сторон, расхождение, элиминация, статус (§5)."""
return await service.build_totals(version_id)
@router.get(
"/{version_id}/elimination-pairs",
response_model=EliminationPairsOut,
dependencies=VIEW,
responses=PAIRS_RESPONSES,
summary="Сопоставление строк ОСВ по правилу и паре компаний",
)
async def build_elimination_pairs(
version_id: uuid.UUID,
rule_id: uuid.UUID,
company_a_id: uuid.UUID,
company_b_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> EliminationPairsOut:
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
return await service.build_pairs(version_id, rule_id, company_a_id, company_b_id)

View File

@ -0,0 +1,204 @@
"""Упаковка встречных строк сводной ОСВ внутри одного правила элиминации (§5).
Счета уже отобраны пресетом. Функция не читает БД и не меняет сумму элиминации: она только группирует строки.
"""
import uuid
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import TypeAlias
RowKey: TypeAlias = tuple[uuid.UUID, ...]
MAX_SIDE_ROWS = 5
MIN_GROUP_SIZE = 2
MAX_DEVIATION_PERCENT = Decimal("5")
ZERO = Decimal("0")
@dataclass(frozen=True)
class PackRow:
"""Одна неделимая строка свода на дебетовой или кредитовой стороне."""
key: RowKey
owner_id: uuid.UUID
counterparty_id: uuid.UUID
amount: Decimal # сумма стороны со знаком; в упаковку идут только строки с суммой > 0
@dataclass(frozen=True)
class KnapsackChoice:
"""Лучшие подмножества заданного размера; при ничьей возвращаем ключи всех затронутых строк."""
ratio: Fraction # расхождение к меньшей сумме, точной дробью: ничьи по проценту не зависят от округления
unique_rows: tuple[PackRow, ...] | None
involved_keys: frozenset[RowKey]
@dataclass(frozen=True)
class PackedGroup:
debit_rows: tuple[PackRow, ...]
credit_rows: tuple[PackRow, ...]
debit_amount: Decimal
credit_amount: Decimal
deviation_amount: Decimal
deviation_percent: Decimal
@dataclass(frozen=True)
class PackingResult:
groups: tuple[PackedGroup, ...]
used_keys: frozenset[RowKey]
ambiguous_keys: frozenset[RowKey]
@dataclass(frozen=True)
class RuleTotals:
"""Итог тройки до упаковки строк; упаковка его не меняет."""
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
def calculate_rule_totals(amount_a: Decimal, amount_b: Decimal, *, manual: bool) -> RuleTotals:
"""Правило 5 % для всей тройки, независимо от сопоставления строк; меньшая сторона ≤ 0 — элиминации нет."""
deviation = abs(amount_a - amount_b)
base = min(amount_a, amount_b)
percent = deviation / base * Decimal("100") if base > ZERO else None
exceeds_threshold = deviation > ZERO if percent is None else percent > MAX_DEVIATION_PERCENT
eliminated = base if base > ZERO and not manual and not exceeds_threshold else ZERO
return RuleTotals(amount_a, amount_b, deviation, percent, eliminated, exceeds_threshold)
def _ratio(first: Decimal, second: Decimal) -> Fraction:
"""Расхождение к меньшей из двух положительных сумм."""
return Fraction(abs(first - second)) / Fraction(min(first, second))
@dataclass(frozen=True)
class _Candidate:
ratio: Fraction
involved_keys: frozenset[RowKey]
group: PackedGroup | None # None — несколько одинаково лучших подмножеств у одного якоря
def best_subsets(rows: list[PackRow], *, size: int, capacity: Decimal) -> KnapsackChoice | None:
"""Перебрать группы заданного размера; битовая маска отмечает выбранные строки и ничьи."""
if not 1 <= size <= MAX_SIDE_ROWS:
raise ValueError("размер стороны должен быть от 1 до 5")
candidates = sorted((row for row in rows if row.amount > ZERO), key=lambda row: row.amount)
if capacity <= ZERO or len(candidates) < size:
return None
hundred_percent = Decimal("100")
limit = hundred_percent + MAX_DEVIATION_PERCENT
upper = capacity * limit / hundred_percent
combinations: list[tuple[int, Decimal]] = [] # (битовая маска выбранных строк, точная сумма)
def search(start: int, selected: int, total: Decimal, mask: int) -> None:
remaining = size - selected
if remaining == 0:
deviation = abs(capacity - total)
if deviation * hundred_percent <= MAX_DEVIATION_PERCENT * min(capacity, total):
combinations.append((mask, total))
return
if len(candidates) - start < remaining:
return
# Промежуточную сумму сравниваем с нижней границей лишь с учётом оставшихся строк.
largest_total = total + sum((row.amount for row in candidates[-remaining:]), ZERO)
if largest_total * limit < capacity * hundred_percent:
return
smallest_total = total + sum((row.amount for row in candidates[start : start + remaining]), ZERO)
if smallest_total > upper:
return
for index in range(start, len(candidates) - remaining + 1):
next_total = total + candidates[index].amount
if next_total > upper:
break # суммы отсортированы: следующие строки только больше
search(index + 1, selected + 1, next_total, mask | (1 << index))
search(0, 0, ZERO, 0)
if not combinations:
return None
# точные первыми, затем по возрастанию процента расхождения к меньшей сумме
ratios = [(mask, _ratio(capacity, total)) for mask, total in combinations]
best_ratio = min(ratio for _, ratio in ratios)
best_masks = [mask for mask, ratio in ratios if ratio == best_ratio]
involved_mask = 0
for mask in best_masks:
involved_mask |= mask
involved_keys = frozenset(row.key for index, row in enumerate(candidates) if involved_mask & (1 << index))
unique_rows = (
tuple(row for index, row in enumerate(candidates) if best_masks[0] & (1 << index))
if len(best_masks) == 1
else None
)
return KnapsackChoice(best_ratio, unique_rows, involved_keys)
def _candidate(anchor: PackRow, choice: KnapsackChoice, *, anchor_is_debit: bool) -> _Candidate:
keys = choice.involved_keys | {anchor.key}
if choice.unique_rows is None:
return _Candidate(choice.ratio, keys, None)
debit = (anchor,) if anchor_is_debit else choice.unique_rows
credit = choice.unique_rows if anchor_is_debit else (anchor,)
debit_amount = sum((row.amount for row in debit), ZERO)
credit_amount = sum((row.amount for row in credit), ZERO)
group = PackedGroup(
debit_rows=debit,
credit_rows=credit,
debit_amount=debit_amount,
credit_amount=credit_amount,
deviation_amount=abs(debit_amount - credit_amount),
deviation_percent=abs(debit_amount - credit_amount) / min(debit_amount, credit_amount) * 100,
)
return _Candidate(choice.ratio, keys, group)
def _candidates(
debit_rows: list[PackRow], credit_rows: list[PackRow], unavailable: set[RowKey], total_size: int
) -> list[_Candidate]:
"""Входы уже ограничены одной тройкой: правило, компания А, компания Б; строки с суммой ≤ 0 не участвуют."""
available_debit = [row for row in debit_rows if row.key not in unavailable and row.amount > ZERO]
available_credit = [row for row in credit_rows if row.key not in unavailable and row.amount > ZERO]
candidates: list[_Candidate] = []
for anchor in available_debit:
choice = best_subsets(available_credit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=True))
if total_size > MIN_GROUP_SIZE:
for anchor in available_credit:
choice = best_subsets(available_debit, size=total_size - 1, capacity=anchor.amount)
if choice is not None:
candidates.append(_candidate(anchor, choice, anchor_is_debit=False))
return candidates
def pack_rule_rows(debit_rows: list[PackRow], credit_rows: list[PackRow]) -> PackingResult:
"""Жадно упаковать 1:1, 1:N, N:1; ничьи по размеру и проценту расхождения отправить на ручной разбор."""
used: set[RowKey] = set()
ambiguous: set[RowKey] = set()
groups: list[PackedGroup] = []
for total_size in range(MIN_GROUP_SIZE, MAX_SIDE_ROWS + MIN_GROUP_SIZE):
while candidates := _candidates(debit_rows, credit_rows, used | ambiguous, total_size):
best_ratio = min(candidate.ratio for candidate in candidates)
best = [candidate for candidate in candidates if candidate.ratio == best_ratio]
conflicted = {index for index, candidate in enumerate(best) if candidate.group is None}
for left in range(len(best)):
for right in range(left + 1, len(best)):
if best[left].involved_keys & best[right].involved_keys:
conflicted.update((left, right))
for index, candidate in enumerate(best):
if index in conflicted:
ambiguous.update(candidate.involved_keys)
else:
assert candidate.group is not None
groups.append(candidate.group)
used.update(candidate.involved_keys)
return PackingResult(tuple(groups), frozenset(used), frozenset(ambiguous))

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.22.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.22"
DBML_VERSION = "5.23"
__all__ = [
"DBML_VERSION",

View File

@ -67,7 +67,7 @@ class ConsolidationVersion(UUIDPrimaryMixin, Base):
uob_decision_no: Mapped[str | None] = mapped_column(String)
uob_decision_date: Mapped[datetime.date | None] = mapped_column(Date)
methodology_deviations: Mapped[str | None] = mapped_column(Text)
elimination_rules_version: Mapped[int | None] = mapped_column(Integer)
elimination_rules_version: Mapped[int] = mapped_column(Integer, nullable=False)
is_in_work: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
previous_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),

View File

@ -2,6 +2,7 @@
import uuid
from collections.abc import Collection
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -10,21 +11,13 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu
async def list_rules_with_accounts(
session: AsyncSession, version_no: int | None
session: AsyncSession, version_no: int
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила §4 заданной либо последней открытой версии."""
condition = (
EliminationRule.version_no == version_no
if version_no is not None
else (
EliminationRule.valid_to.is_(None)
& (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery())
)
)
"""Правила §4 заданной версии."""
stmt = (
select(EliminationRule, EliminationRuleAccount)
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.where(condition)
.where(EliminationRule.version_no == version_no)
.order_by(
EliminationRule.statement_type,
EliminationRule.rule_no,
@ -34,19 +27,19 @@ async def list_rules_with_accounts(
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
def _current_open_version_stmt():
"""Запрос номера последней открытой версии правил §4."""
return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None))
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
"""Максимальный номер версии правил, действующей на дату создания"""
return await session.scalar(
select(func.max(EliminationRule.version_no)).where(
EliminationRule.valid_from <= as_of,
(EliminationRule.valid_to.is_(None) | (EliminationRule.valid_to >= as_of)),
)
)
async def current_open_version_no(session: AsyncSession) -> int | None:
"""Номер последней версии с открытыми правилами §4."""
return await session.scalar(_current_open_version_stmt())
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)
def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool:
"""Правило §4 из зафиксированной версии правил версии консолидации."""
return rule is not None and rule.version_no == version_no
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:

View File

@ -336,6 +336,114 @@ class EliminationRulesOut(BaseModel):
rules: list[EliminationRuleOut]
class EliminationPairRowOut(BaseModel):
"""Неделимая строка свода; `entry_ids` связывают её с исходными строками ОСВ."""
entry_ids: list[uuid.UUID]
legal_entity_id: uuid.UUID
counterparty_id: uuid.UUID
account_id: uuid.UUID
account_code: str
closing_debit: Decimal | None
closing_credit: Decimal | None
@field_validator("closing_debit", "closing_credit")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы в JSON всегда с двумя знаками; пустая сторона — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class EliminationMatchedPairOut(BaseModel):
"""Сопоставленная группа строк одного правила и пары компаний."""
rule_id: uuid.UUID
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal
osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: list[uuid.UUID]
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent")
@classmethod
def _two_decimals(cls, value: Decimal) -> Decimal:
"""Суммы и процент в JSON всегда с двумя знаками."""
return value.quantize(CENT, rounding=ROUND_HALF_UP)
UnpairedReason = Literal["ZERO_AMOUNT", "NEGATIVE_AMOUNT", "NO_MATCH", "AMBIGUOUS"]
class EliminationUnpairedRowOut(BaseModel):
"""Строка, не вошедшая в автоматическую группу правила."""
row: EliminationPairRowOut
reason: UnpairedReason
class EliminationPairsOut(BaseModel):
"""Точные пары, пары с расхождением и строки без пары для выбранной тройки."""
version_no: int
rule_id: uuid.UUID
rule_no: int
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
matched: list[EliminationMatchedPairOut]
with_discrepancy: list[EliminationMatchedPairOut]
unpaired: list[EliminationUnpairedRowOut]
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]
class EliminationTotalItemOut(BaseModel):
"""Итог тройки (правило, компания A, компания B) в списке версии (§5 п. 3)."""
rule_id: uuid.UUID
rule_no: int
elimination_mode: str
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
status: EliminationTotalStatus
osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: list[uuid.UUID]
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class EliminationTotalsOut(BaseModel):
"""Все тройки версии консолидации по правилам ББ зафиксированной версии правил."""
version_no: int
items: list[EliminationTotalItemOut]
class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -16,7 +16,7 @@ from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.clock import moscow_date, utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain import checklist as checklist_domain
from src.domain.consolidation_periods import quarters_through, reporting_date
@ -40,6 +40,7 @@ from src.models.enums import (
)
from src.repositories import consolidations as repo
from src.repositories import directories as directories_repo
from src.repositories import elimination_rules as elimination_rules_repo
from src.repositories.users import UserWithAccess
from src.schemas.consolidations import (
ChecklistCellOut,
@ -331,6 +332,10 @@ class ConsolidationService:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now()
user = self._principal.user
# версия правил §4 — действующая на дату создания (OQ-174), с v5.23 обязательна
rules_version = await elimination_rules_repo.latest_effective_version_no(self._session, moscow_date(now))
if rules_version is None:
raise ConflictError("ELIMINATION_RULES_MISSING", "Нет правил элиминации, действующих на дату создания")
consolidation = Consolidation(
company_group_id=group_id,
year=data.year,
@ -347,6 +352,7 @@ class ConsolidationService:
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
is_in_work=True,

View File

@ -26,11 +26,9 @@ class EliminationRuleService:
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
version, _ = found
is_version_fixed = version.elimination_rules_version is not None
# с v5.23 версия правил фиксируется при создании версии консолидации и не бывает пустой
version_no = version.elimination_rules_version
rows = await rules_repo.list_rules_with_accounts(self._session, version_no)
if not is_version_fixed and rows:
version_no = rows[0][0].version_no
by_id: dict[uuid.UUID, EliminationRuleOut] = {}
for rule, account in rows:
result = by_id.get(rule.id)
@ -56,6 +54,6 @@ class EliminationRuleService:
return EliminationRulesOut(
version_no=version_no,
is_version_fixed=is_version_fixed,
is_version_fixed=True,
rules=list(by_id.values()),
)

View File

@ -12,6 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import DomainValidationError, NotFoundError
from src.domain.consolidated_osv import ConsolidatedRow, Entity
from src.domain.elimination_packing import (
PackedGroup,
PackRow,
RowKey,
RuleTotals,
calculate_rule_totals,
pack_rule_rows,
)
from src.domain.policy import VisibilityScope
from src.models.accounting import ChartOfAccount, EliminationRule
from src.models.consolidation import Consolidation, ConsolidationVersion
@ -20,12 +28,20 @@ from src.models.enums import AccountKind, PeriodQuarter, StatementType
from src.repositories import directories as directory_repo
from src.repositories import elimination_rules as elimination_rules_repo
from src.schemas.consolidations import (
EliminationMatchedPairOut,
EliminationPairRowOut,
EliminationPairsOut,
EliminationTotalItemOut,
EliminationTotalsOut,
EliminationTotalStatus,
EliminationUnpairedRowOut,
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut,
IntercompanyResultAmountOut,
IntercompanyResultArticleOut,
IntercompanyResultTableOut,
UnpairedReason,
)
from src.services.consolidated_osv import ConsolidatedOsvService
@ -154,6 +170,21 @@ class ResultTableBuild:
consolidation: Consolidation
@dataclass(frozen=True)
class PairContext:
rule: EliminationRule
company_a_id: uuid.UUID
company_b_id: uuid.UUID
@dataclass(frozen=True)
class RulePacking:
matched: list[EliminationMatchedPairOut]
with_discrepancy: list[EliminationMatchedPairOut]
unpaired: list[EliminationUnpairedRowOut]
totals: RuleTotals
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
if measure == "closing":
value = row.closing_debit if side == "D" else row.closing_credit
@ -339,6 +370,181 @@ def calculate_result_table(
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
"""Строка свода, уже отфильтрованная по счёту и встречному ЮЛ периметра."""
assert row.counterparty_id is not None and row.account_id is not None and row.account_code is not None
return EliminationPairRowOut(
entry_ids=list(row.entry_ids),
legal_entity_id=row.legal_entity_id,
counterparty_id=row.counterparty_id,
account_id=row.account_id,
account_code=row.account_code,
closing_debit=row.closing_debit,
closing_credit=row.closing_credit,
)
def _matched_pair_out(
group: PackedGroup,
context: PairContext,
) -> EliminationMatchedPairOut:
rule = context.rule
rows_a = group.debit_rows if rule.side_1 == "D" else group.credit_rows
rows_b = group.credit_rows if rule.side_1 == "D" else group.debit_rows
amount_a = group.debit_amount if rule.side_1 == "D" else group.credit_amount
amount_b = group.credit_amount if rule.side_1 == "D" else group.debit_amount
return EliminationMatchedPairOut(
rule_id=rule.id,
company_a_id=context.company_a_id,
company_b_id=context.company_b_id,
amount_a=amount_a,
amount_b=amount_b,
deviation_amount=group.deviation_amount,
deviation_percent=group.deviation_percent,
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
)
@dataclass(frozen=True)
class RuleSides:
"""Встречные строки тройки: исходные строки свода и строки сторон с ненулевой суммой со знаком."""
sources: dict[RowKey, ConsolidatedRow]
debit_rows: list[PackRow]
credit_rows: list[PackRow]
def a_b(self, rule: EliminationRule) -> tuple[list[PackRow], list[PackRow]]:
"""Строки стороны A (side_1 правила) и стороны B."""
if rule.side_1 == "D":
return self.debit_rows, self.credit_rows
return self.credit_rows, self.debit_rows
def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides:
"""Встречные строки направленной пары компаний по одному правилу; суммы со знаком, как в шахматке (§5)."""
rule = context.rule
company_a_id = context.company_a_id
company_b_id = context.company_b_id
debit_company_id = company_a_id if rule.side_1 == "D" else company_b_id
credit_company_id = company_b_id if debit_company_id == company_a_id else company_a_id
sources: dict[RowKey, ConsolidatedRow] = {}
debit_rows: list[PackRow] = []
credit_rows: list[PackRow] = []
for row in rows:
if row.account_id is None:
continue
in_debit = (
row.legal_entity_id == debit_company_id
and row.counterparty_id == credit_company_id
and row.account_id in selection.company_1_account_ids
)
in_credit = (
row.legal_entity_id == credit_company_id
and row.counterparty_id == debit_company_id
and row.account_id in selection.company_2_account_ids
)
if not in_debit and not in_credit:
continue
sources[row.entry_ids] = row
assert row.counterparty_id is not None
amount = _get_amount(row, "closing", "D" if in_debit else "K")
if amount != ZERO:
pack_row = PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, amount)
(debit_rows if in_debit else credit_rows).append(pack_row)
return RuleSides(sources, debit_rows, credit_rows)
def _rule_totals(sides: RuleSides, rule: EliminationRule) -> RuleTotals:
"""Итог тройки по строкам сторон: ΣA, ΣB, расхождение и сумма элиминации (§5 п. 1)."""
rows_a, rows_b = sides.a_b(rule)
amount_a = sum((row.amount for row in rows_a), ZERO)
amount_b = sum((row.amount for row in rows_b), ZERO)
return calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotalStatus:
"""Статус тройки в списке версии (§5 п. 3)."""
if rule.elimination_mode == "manual":
return "MANUAL"
if ZERO in (totals.amount_a, totals.amount_b):
return "ONE_SIDED"
# без автоматической элиминации — расхождение: превышен порог либо меньшая сторона отрицательна
return "AUTO" if totals.eliminated_amount > ZERO else "DISCREPANCY"
def calculate_rule_total_items(
rows: Sequence[ConsolidatedRow],
rules: Sequence[tuple[EliminationRule, MatrixSelection]],
company_ids: Sequence[uuid.UUID],
) -> list[EliminationTotalItemOut]:
"""Итоги всех троек версии: правило × упорядоченная пара компаний; тройки без строк пропускаются (§5 п. 3)."""
items: list[EliminationTotalItemOut] = []
for rule, selection in rules:
for company_a_id in company_ids:
for company_b_id in company_ids:
if company_a_id == company_b_id:
continue
sides = _collect_rule_sides(rows, selection, PairContext(rule, company_a_id, company_b_id))
if not sides.debit_rows and not sides.credit_rows:
continue
totals = _rule_totals(sides, rule)
rows_a, rows_b = sides.a_b(rule)
items.append(
EliminationTotalItemOut(
rule_id=rule.id,
rule_no=rule.rule_no,
elimination_mode=rule.elimination_mode,
company_a_id=company_a_id,
company_b_id=company_b_id,
amount_a=totals.amount_a,
amount_b=totals.amount_b,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
eliminated_amount=totals.eliminated_amount,
exceeds_threshold=totals.exceeds_threshold,
status=_total_status(totals, rule),
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
)
)
return items
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Итог тройки и упаковка её встречных строк (§5 п. 1–2)."""
sides = _collect_rule_sides(rows, selection, context)
sources, debit_rows, credit_rows = sides.sources, sides.debit_rows, sides.credit_rows
totals = _rule_totals(sides, context.rule)
packed = pack_rule_rows(debit_rows, credit_rows)
matched: list[EliminationMatchedPairOut] = []
with_discrepancy: list[EliminationMatchedPairOut] = []
for group in packed.groups:
pair = _matched_pair_out(group, context)
(matched if group.deviation_amount == ZERO else with_discrepancy).append(pair)
unpaired: list[EliminationUnpairedRowOut] = []
amounts = {item.key: item.amount for item in [*debit_rows, *credit_rows]}
for key, row in sources.items():
if key in packed.used_keys:
continue
amount = amounts.get(key, ZERO)
reason: UnpairedReason
if key in packed.ambiguous_keys:
reason = "AMBIGUOUS"
elif amount > ZERO:
reason = "NO_MATCH"
elif amount < ZERO:
reason = "NEGATIVE_AMOUNT"
else:
reason = "ZERO_AMOUNT"
unpaired.append(EliminationUnpairedRowOut(row=_pair_row_out(row), reason=reason))
return RulePacking(matched, with_discrepancy, unpaired, totals)
class IntercompanyMatrixService:
"""Получает свод и периметр версии, затем вызывает чистый расчёт матрицы."""
@ -347,6 +553,62 @@ class IntercompanyMatrixService:
self._scope = scope
self._osv_service = service_osv
async def build_pairs(
self, version_id: uuid.UUID, rule_id: uuid.UUID, company_a_id: uuid.UUID, company_b_id: uuid.UUID
) -> EliminationPairsOut:
"""Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5)."""
osv_result = await self._osv_service.build_with_members(version_id)
version = osv_result.version
member_ids = set(osv_result.members)
if company_a_id == company_b_id or company_a_id not in member_ids or company_b_id not in member_ids:
raise DomainValidationError("INVALID_COMPANY_PAIR", "Выберите две разные компании периметра версии")
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
selection, rule = await self._resolve_preset_selection(rule_id, version, by_id, children)
if rule.measure != "closing":
# шахматка по такому правилу считает обороты; пары §5 — только по сальдо
raise DomainValidationError("INVALID_RULE", "Пары ВГО строятся только по правилам сальдо")
# TODO(упаковка ОСВ): занятость строки отслеживать отдельно для каждого rule_id — одна строка может попасть
# в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели
# данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами.
packing = _pack_preset_rows(
osv_result.consolidated.rows,
selection,
PairContext(rule, company_a_id, company_b_id),
)
return EliminationPairsOut(
version_no=rule.version_no,
rule_id=rule.id,
rule_no=rule.rule_no,
company_a_id=company_a_id,
company_b_id=company_b_id,
amount_a=packing.totals.amount_a,
amount_b=packing.totals.amount_b,
deviation_amount=packing.totals.deviation_amount,
deviation_percent=packing.totals.deviation_percent,
eliminated_amount=packing.totals.eliminated_amount,
exceeds_threshold=packing.totals.exceeds_threshold,
matched=packing.matched,
with_discrepancy=packing.with_discrepancy,
unpaired=packing.unpaired,
)
async def build_totals(self, version_id: uuid.UUID) -> EliminationTotalsOut:
"""Итоги всех троек версии по правилам ББ её версии правил; сводная ОСВ строится один раз (§5 п. 3)."""
osv_result = await self._osv_service.build_with_members(version_id)
version = osv_result.version
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
rules: list[tuple[EliminationRule, MatrixSelection]] = []
for rule in await elimination_rules_repo.list_rules(self._session, version.elimination_rules_version):
# пары §5 — только правила ББ по сальдо; правила ОФР ещё не формализованы (§4)
if rule.statement_type != StatementType.balance or rule.measure != "closing":
continue
selection, _ = await self._resolve_preset_selection(rule.id, version, by_id, children)
rules.append((rule, selection))
items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members))
return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items)
async def build(
self,
version_id: uuid.UUID,
@ -473,10 +735,6 @@ class IntercompanyMatrixService:
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id)
if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if version.elimination_rules_version is None:
open_version_no = await elimination_rules_repo.current_open_version_no(self._session)
if rule.version_no != open_version_no:
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if rule.statement_type != StatementType.balance:
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):

View File

@ -415,6 +415,7 @@ async def make_version( # noqa: PLR0913 - сигнатура фабрики ф
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=1, # seed правил §4 — версия v1; с DBML v5.23 поле обязательно
initiator_id=initiator.id,
initiator_office_id=initiator.regional_office_id,
is_in_work=status not in FINISHED_STATUSES,

View File

@ -4,13 +4,16 @@
import uuid
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import moscow_date, utc_now
from src.models.access import AppUser
from src.models.accounting import EliminationRule
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
Consolidation,
@ -139,6 +142,7 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
assert str(consolidation.id) == card["consolidation_id"]
version = (await seeded.execute(select(ConsolidationVersion))).scalar_one()
assert (version.initiator_id, version.created_by, version.is_in_work) == (user.id, user.id, True)
assert version.elimination_rules_version == 1
assert await _count(seeded, ConsolidationLock) == 0
assert await _count(seeded, ConsolidationMember) == len(entities)
assert await _count(seeded, ConsolidationStatusHistory) == 1
@ -162,6 +166,76 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
}
@pytest.mark.integration
async def test_create_without_effective_rules_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""DBML v5.23: версия правил обязательна — без правил, действующих на дату создания, консолидация не создаётся."""
group, gsk, _ = await _scenario(seeded)
tomorrow = moscow_date(utc_now()) + timedelta(days=1)
for rule in (await seeded.scalars(select(EliminationRule))).all():
rule.valid_from = tomorrow
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ELIMINATION_RULES_MISSING"
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
group, gsk, entities = await _scenario(seeded)
today = moscow_date(utc_now())
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
next_rule = EliminationRule(
statement_type=source.statement_type,
rule_no=source.rule_no,
article_1_line=source.article_1_line,
article_2_line=source.article_2_line,
side_1=source.side_1,
measure=source.measure,
elimination_mode=source.elimination_mode,
excise_adjust=source.excise_adjust,
version_no=2,
valid_from=today + timedelta(days=1),
)
seeded.add(next_rule)
await seeded.flush()
client = await auth_client(KA_RF)
first = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
assert first.status_code == HTTP_CREATED, first.text
first_version = await seeded.get(ConsolidationVersion, uuid.UUID(first.json()["id"]))
assert first_version is not None
assert first_version.elimination_rules_version == 1
next_rule.valid_from = today
second = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2023))
assert second.status_code == HTTP_CREATED, second.text
second_version = await seeded.get(ConsolidationVersion, uuid.UUID(second.json()["id"]))
assert second_version is not None
assert second_version.elimination_rules_version == next_rule.version_no
assert first_version.elimination_rules_version == 1
pairs_url = f"/consolidations/versions/{first_version.id}/elimination-pairs"
params = {
"rule_id": str(source.id),
"company_a_id": str(entities[0].id),
"company_b_id": str(entities[1].id),
}
for _ in range(2):
pairs = await client.get(pairs_url, params=params)
assert pairs.status_code == HTTP_OK, pairs.text
assert pairs.json()["version_no"] == 1
assert pairs.json()["matched"] == []
assert (await client.get(pairs_url, params={**params, "rule_id": str(next_rule.id)})).status_code == HTTP_NOT_FOUND
await seeded.refresh(first_version)
assert first_version.elimination_rules_version == 1
@pytest.mark.integration
async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, _ = await _scenario(seeded)

View File

@ -202,50 +202,73 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded
assert response.json()["detail"]["code"] == "INVALID_RULE"
async def test_preset_uses_current_rules_when_version_unfixed(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
async def test_elimination_pairs_reject_turnover_rule(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Пары §5 — только по сальдо: шахматка по правилу оборотов считает другие суммы."""
world = await _world(seeded)
rule = (
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
).one()
rule.measure = "turnover"
await seeded.flush()
client = await auth_client(KA_RF)
company_a, company_b = world.members[:2]
response = await client.get(
f"/consolidations/versions/{world.version.id}/elimination-pairs",
params={"rule_id": str(rule.id), "company_a_id": str(company_a.id), "company_b_id": str(company_b.id)},
)
assert response.status_code == UNPROCESSABLE, response.text
assert response.json()["detail"]["code"] == "INVALID_RULE"
async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
rule = (
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
).one()
assert world.version.elimination_rules_version == rule.version_no
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}"
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False
assert listed.json()["is_version_fixed"] is True
assert listed.json()["version_no"] == rule.version_no
assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
pairs = await client.get(
f"/consolidations/versions/{world.version.id}/elimination-pairs",
params={
"rule_id": str(rule.id),
"company_a_id": str(world.members[0].id),
"company_b_id": str(world.members[1].id),
},
)
assert pairs.status_code == OK, pairs.text
response = await client.get(url)
assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["rule_no"] == rule.rule_no
assert audit.after_value["rule_version_no"] == rule.version_no
assert world.version.elimination_rules_version is None
assert world.version.elimination_rules_version == rule.version_no # GET номер правил не меняет
world.version.elimination_rules_version = rule.version_no + 1
await seeded.flush()
assert (await client.get(url)).status_code == NOT_FOUND
assert (await client.get(f"{url}/export")).status_code == NOT_FOUND
world.version.elimination_rules_version = rule.version_no
await seeded.flush()
fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert fixed.status_code == OK, fixed.text
assert fixed.json()["is_version_fixed"] is True
assert fixed.json()["version_no"] == rule.version_no
missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
missing = await client.get(missing_url)
assert missing.status_code == NOT_FOUND
assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND
async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_fixed_rules_version_ignores_newer_rules(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Новая версия правил после создания версии консолидации на неё не влияет (§4, DBML v5.23)."""
world = await _world(seeded)
old_rule, new_rule = (
await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2))
).all()
assert old_rule.valid_to is None and new_rule.valid_to is None
new_rule.rule_no = old_rule.rule_no
new_rule.version_no = old_rule.version_no + 1
await seeded.flush()
@ -255,13 +278,289 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
response = await client.get(f"{base}/elimination-rules")
assert response.status_code == OK, response.text
payload = response.json()
assert payload["is_version_fixed"] is False
assert payload["version_no"] == new_rule.version_no
assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)]
assert payload["rules"][0]["rule_no"] == old_rule.rule_no
assert payload["is_version_fixed"] is True
assert payload["version_no"] == old_rule.version_no
assert str(new_rule.id) not in {item["id"] for item in payload["rules"]}
assert str(old_rule.id) in {item["id"] for item in payload["rules"]}
old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}")
assert old_preset.status_code == NOT_FOUND
new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}")
assert new_preset.status_code == NOT_FOUND
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded, members=3)
rule_ids = {
rule.rule_no: rule.id
for rule in (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no.in_([1, 3, 6])))).all()
}
debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="62.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": member.name,
"counterparty_id": member.id,
"account_id": world.accounts["62.01"].id,
"closing_debit": Decimal(amount),
}
for member, amount in [
*((world.members[1], amount) for amount in ("40", "0", "200")),
(world.members[2], "103"),
]
],
)
second_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="60.02",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["60.02"].id,
"closing_debit": Decimal("60"),
},
{
"name": world.members[2].name,
"counterparty_id": world.members[2].id,
"account_id": world.accounts["60.02"].id,
"closing_debit": Decimal("20"),
},
],
)
reverse_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="62.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["62.01"].id,
"closing_debit": Decimal("70"),
}
],
)
reverse_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("70"),
}
],
)
manual_debit_document = await make_confirmed_osv(
seeded,
entity=world.members[0],
account="03",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[1].name,
"counterparty_id": world.members[1].id,
"account_id": world.accounts["03"].id,
"closing_debit": Decimal("100"),
}
],
)
manual_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="76.07.1",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["76.07.1"].id,
"closing_credit": Decimal("100"),
}
],
)
credit_document = await make_confirmed_osv(
seeded,
entity=world.members[1],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("100"),
}
],
)
other_credit_document = await make_confirmed_osv(
seeded,
entity=world.members[2],
account="60.01",
year=2024,
quarter=PeriodQuarter.Q1,
is_cumulative=True,
rows=[
{
"name": world.members[0].name,
"counterparty_id": world.members[0].id,
"account_id": world.accounts["60.01"].id,
"closing_credit": Decimal("100"),
}
],
)
seeded.add_all(
[
ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)
for document in (
debit_document,
second_debit_document,
credit_document,
other_credit_document,
reverse_debit_document,
reverse_credit_document,
manual_debit_document,
manual_credit_document,
)
]
)
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/elimination-pairs"
company_a_id, company_b_id, company_c_id = (member.id for member in world.members)
params = {"rule_id": str(rule_ids[6]), "company_a_id": str(company_a_id), "company_b_id": str(company_b_id)}
response = await client.get(url, params=params)
assert response.status_code == OK, response.text
payload = response.json()
assert payload["version_no"] == 1
assert (payload["rule_id"], payload["rule_no"], payload["company_a_id"], payload["company_b_id"]) == (
str(rule_ids[6]),
6,
str(company_a_id),
str(company_b_id),
)
assert "account_pairs" not in payload
assert payload["with_discrepancy"] == []
assert (Decimal(payload["amount_a"]), Decimal(payload["amount_b"])) == (Decimal("300"), Decimal("100"))
assert (payload["amount_a"], payload["amount_b"], payload["deviation_amount"]) == ("300.00", "100.00", "200.00")
assert payload["deviation_percent"] == "200.00"
assert payload["exceeds_threshold"] is True
assert Decimal(payload["eliminated_amount"]) == 0
assert len(payload["matched"]) == 1
pair = payload["matched"][0]
assert len(pair["osv_entry_ids_a"]) == len((debit_document, second_debit_document))
assert len(pair["osv_entry_ids_b"]) == 1
assert pair["rule_id"] == str(rule_ids[6])
assert tuple(pair[field] for field in ("amount_a", "amount_b", "deviation_amount", "deviation_percent")) == (
"100.00",
"100.00",
"0.00",
"0.00",
)
assert "article_ids_a" not in pair and "article_ids_b" not in pair
assert "elimination_amount" not in pair and "elimination_amount" not in payload
assert [item["reason"] for item in payload["unpaired"]] == ["ZERO_AMOUNT", "NO_MATCH"]
assert [item["row"]["closing_debit"] for item in payload["unpaired"]] == ["0.00", "200.00"]
loans = await client.get(url, params={**params, "rule_id": str(rule_ids[3])})
assert loans.status_code == OK, loans.text
assert loans.json()["matched"] == []
manual = await client.get(url, params={**params, "rule_id": str(rule_ids[1])})
assert manual.status_code == OK, manual.text
assert len(manual.json()["matched"]) == 1
assert Decimal(manual.json()["eliminated_amount"]) == 0
other_pair = await client.get(url, params={**params, "company_b_id": str(company_c_id)})
assert other_pair.status_code == OK, other_pair.text
assert other_pair.json()["matched"] == []
assert len(other_pair.json()["with_discrepancy"]) == 1
discrepant = other_pair.json()["with_discrepancy"][0]
assert (Decimal(discrepant["amount_a"]), Decimal(discrepant["amount_b"])) == (Decimal("103"), Decimal("100"))
assert (discrepant["deviation_amount"], discrepant["deviation_percent"]) == ("3.00", "3.00")
assert "article_ids_a" not in discrepant and "article_ids_b" not in discrepant
assert (Decimal(other_pair.json()["amount_a"]), Decimal(other_pair.json()["amount_b"])) == (
Decimal("123"),
Decimal("100"),
)
assert other_pair.json()["exceeds_threshold"] is True
assert Decimal(other_pair.json()["eliminated_amount"]) == 0
reversed_pair = await client.get(
url, params={**params, "company_a_id": str(company_b_id), "company_b_id": str(company_a_id)}
)
assert reversed_pair.status_code == OK, reversed_pair.text
assert len(reversed_pair.json()["matched"]) == 1
assert reversed_pair.json()["with_discrepancy"] == []
assert reversed_pair.json()["unpaired"] == []
assert reversed_pair.json()["matched"][0]["osv_entry_ids_a"]
assert reversed_pair.json()["matched"][0]["osv_entry_ids_b"]
assert (Decimal(reversed_pair.json()["amount_a"]), Decimal(reversed_pair.json()["amount_b"])) == (
Decimal("70"),
Decimal("70"),
)
assert Decimal(reversed_pair.json()["eliminated_amount"]) == Decimal("70")
invalid_pair = await client.get(url, params={**params, "company_b_id": str(uuid.uuid4())})
assert invalid_pair.status_code == UNPROCESSABLE
same_company = await client.get(url, params={**params, "company_b_id": str(company_a_id)})
assert same_company.status_code == UNPROCESSABLE
missing_rule = await client.get(url, params={**params, "rule_id": str(uuid.uuid4())})
assert missing_rule.status_code == NOT_FOUND
missing_parameters = await client.get(url)
assert missing_parameters.status_code == UNPROCESSABLE
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
assert missing.status_code == NOT_FOUND
# список версии — те же итоги, что у тройки, одним запросом (§5 п. 3)
totals = await client.get(f"/consolidations/versions/{world.version.id}/elimination-totals")
assert totals.status_code == OK, totals.text
assert totals.json()["version_no"] == 1
by_triple = {(item["rule_no"], item["company_a_id"], item["company_b_id"]): item for item in totals.json()["items"]}
a, b, c = str(company_a_id), str(company_b_id), str(company_c_id)
main = by_triple[(6, a, b)]
assert (main["amount_a"], main["amount_b"], main["deviation_amount"]) == ("300.00", "100.00", "200.00")
assert (main["deviation_percent"], main["eliminated_amount"]) == ("200.00", "0.00")
assert (main["status"], main["exceeds_threshold"], main["elimination_mode"]) == ("DISCREPANCY", True, "min")
# у A по B три ненулевые строки: 62.01 40 и 200, 60.02 60; строка 0 в сумму не входит
assert (len(main["osv_entry_ids_a"]), len(main["osv_entry_ids_b"])) == (len(("40", "200", "60")), 1)
mirrored = by_triple[(6, b, a)]
assert (mirrored["status"], mirrored["eliminated_amount"]) == ("AUTO", "70.00")
assert by_triple[(6, a, c)]["status"] == "DISCREPANCY"
assert (by_triple[(1, a, b)]["status"], by_triple[(1, a, b)]["eliminated_amount"]) == ("MANUAL", "0.00")
# дебиторка № 6 входит и в № 7 (займы 67), встречной стороны нет — двойной учёт до AURORA-1751
one_sided = by_triple[(7, a, b)]
assert (one_sided["status"], one_sided["amount_b"], one_sided["deviation_percent"]) == ("ONE_SIDED", "0.00", None)
assert (3, a, b) not in by_triple # строк по счетам правила нет — тройки нет
assert [item["rule_no"] for item in totals.json()["items"]] == sorted(
item["rule_no"] for item in totals.json()["items"]
)
missing_totals = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-totals")
assert missing_totals.status_code == NOT_FOUND
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(

View File

@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.23.dbml"
# шапка 5.22 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.22", "v5.23") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели"])
newer = tmp_path / "data-model-v5.24.dbml"
# шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.23.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.22", "v5.23", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.24.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели\n"
assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -0,0 +1,152 @@
"""Упаковка встречных строк по одному правилу без БД (§5)."""
import uuid
from decimal import Decimal
from fractions import Fraction
from src.domain.elimination_packing import MAX_SIDE_ROWS, PackRow, best_subsets, calculate_rule_totals, pack_rule_rows
A = uuid.uuid4()
B = uuid.uuid4()
def _row(amount: str, *, debit: bool) -> PackRow:
return PackRow((uuid.uuid4(),), A if debit else B, B if debit else A, Decimal(amount))
def test_knapsack_finds_exact_subset_with_fixed_size_and_keeps_ties() -> None:
first, second, third = (_row(amount, debit=False) for amount in ("40", "60", "60"))
choice = best_subsets([first, second, third], size=2, capacity=Decimal("100"))
assert choice is not None
assert choice.ratio == 0
assert choice.unique_rows is None
assert choice.involved_keys == {first.key, second.key, third.key}
def test_knapsack_ranks_by_percent_of_smaller_sum_and_keeps_equal_percent_ties() -> None:
below, above = (_row(amount, debit=False) for amount in ("99.00", "101.00"))
# расхождение одно и то же, 1 руб., но к меньшей сумме: 1/99 против 1/100
nearest = best_subsets([below, above], size=1, capacity=Decimal("100.00"))
assert nearest is not None
assert nearest.ratio == Fraction(1, 100)
assert nearest.unique_rows == (above,)
# 105 против 100 и 110,25 — по 5 % к меньшей сумме, хотя в рублях 5 и 5,25
lower, upper = (_row(amount, debit=False) for amount in ("100.00", "110.25"))
tie = best_subsets([lower, upper], size=1, capacity=Decimal("105.00"))
assert tie is not None
assert tie.ratio == Fraction(1, 20)
assert tie.unique_rows is None
assert tie.involved_keys == {lower.key, upper.key}
def test_groups_are_chosen_by_percent_not_by_rubles() -> None:
debit = [_row("100", debit=True)]
rubles, percent = _row("95.5", debit=False), _row("104.6", debit=False) # 4,5 руб. = 4,71 %; 4,6 руб. = 4,6 %
result = pack_rule_rows(debit, [rubles, percent])
assert [group.credit_rows for group in result.groups] == [(percent,)]
assert result.groups[0].deviation_amount == Decimal("4.6")
assert result.groups[0].deviation_percent == Decimal("4.6")
assert rubles.key not in result.used_keys
def test_non_positive_rows_are_not_packed() -> None:
debit = [_row("150", debit=True), _row("-50", debit=True)]
credit = [_row("150", debit=False), _row("-50", debit=False)]
result = pack_rule_rows(debit, credit)
assert len(result.groups) == 1
assert result.used_keys == {debit[0].key, credit[0].key}
assert result.ambiguous_keys == frozenset()
def test_exact_one_to_one_is_reserved_before_one_to_many() -> None:
debit = [_row("100", debit=True), _row("50", debit=True)]
credit = [_row("100", debit=False), _row("30", debit=False), _row("20", debit=False)]
result = pack_rule_rows(debit, credit)
assert [(len(group.debit_rows), len(group.credit_rows)) for group in result.groups] == [(1, 1), (1, 2)]
assert result.used_keys == {row.key for row in [*debit, *credit]}
assert result.ambiguous_keys == frozenset()
def test_equal_exact_groups_sharing_a_row_go_to_manual() -> None:
debit = [_row("100", debit=True)]
credit = [_row("100", debit=False), _row("100", debit=False)]
result = pack_rule_rows(debit, credit)
assert result.groups == ()
assert result.ambiguous_keys == {row.key for row in [*debit, *credit]}
def test_five_percent_boundary_and_no_many_to_many() -> None:
debit = [_row("100", debit=True)]
credit = [_row("21", debit=False) for _ in range(5)]
result = pack_rule_rows(debit, credit)
assert len(result.groups) == 1
assert len(result.groups[0].credit_rows) == MAX_SIDE_ROWS
assert result.groups[0].deviation_percent == Decimal("5")
many_to_many = pack_rule_rows(
[_row("40", debit=True), _row("60", debit=True)],
[_row("30", debit=False), _row("70", debit=False)],
)
assert many_to_many.groups == () # суммы сторон равны, но допустимой группы 1:N или N:1 нет
over_threshold = pack_rule_rows([_row("100", debit=True)], [_row("106", debit=False)])
assert over_threshold.groups == ()
def test_rule_totals_eliminate_minimum_within_five_percent() -> None:
totals = calculate_rule_totals(Decimal("110429113.15"), Decimal("106311116.46"), manual=False)
assert totals.amount_a == Decimal("110429113.15")
assert totals.amount_b == Decimal("106311116.46")
assert totals.deviation_amount == Decimal("4117996.69")
assert totals.deviation_percent is not None
assert totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
assert totals.eliminated_amount == Decimal("106311116.46")
assert totals.exceeds_threshold is False
def test_rule_totals_over_five_percent_and_manual_eliminate_zero() -> None:
over = calculate_rule_totals(Decimal("106"), Decimal("100"), manual=False)
assert over.deviation_amount == Decimal("6")
assert over.deviation_percent == Decimal("6")
assert over.exceeds_threshold is True
assert over.eliminated_amount == 0
manual = calculate_rule_totals(Decimal("100"), Decimal("100"), manual=True)
assert manual.exceeds_threshold is False
assert manual.eliminated_amount == 0
boundary = calculate_rule_totals(Decimal("105"), Decimal("100"), manual=False)
assert boundary.exceeds_threshold is False
assert boundary.eliminated_amount == Decimal("100")
one_sided = calculate_rule_totals(Decimal("100"), Decimal("0"), manual=False)
assert one_sided.deviation_percent is None
assert one_sided.exceeds_threshold is True
assert one_sided.eliminated_amount == 0
def test_rule_totals_with_negative_smaller_side_eliminate_zero() -> None:
negative = calculate_rule_totals(Decimal("100"), Decimal("-20"), manual=False)
assert (negative.deviation_amount, negative.deviation_percent) == (Decimal("120"), None)
assert negative.exceeds_threshold is True
assert negative.eliminated_amount == 0
equal_negative = calculate_rule_totals(Decimal("-50"), Decimal("-50"), manual=False)
assert equal_negative.deviation_percent is None
assert equal_negative.exceeds_threshold is False
assert equal_negative.eliminated_amount == 0

View File

@ -26,27 +26,19 @@ def _rule() -> SimpleNamespace:
("rule", "version_no", "expected"),
[
(None, 3, False),
(SimpleNamespace(version_no=3, valid_to=None), None, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False),
(SimpleNamespace(version_no=3), 2, False),
(SimpleNamespace(version_no=3), 3, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
],
)
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int, expected: bool) -> None:
assert rules_repo.is_valid_for_version(rule, version_no) is expected
async def test_rules_filter_by_version_or_open_valid_to() -> None:
async def test_rules_filter_by_fixed_version() -> None:
version_no = 3
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
await rules_repo.list_rules_with_accounts(session, None)
query = str(session.execute.await_args.args[0])
assert "valid_to IS NULL" in query
assert "max(" in query
assert "version_no = (SELECT" in query
await rules_repo.list_rules_with_accounts(session, version_no)
assert "valid_to IS NULL" not in str(session.execute.await_args.args[0])
assert "version_no =" in str(session.execute.await_args.args[0])
@ -73,41 +65,3 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -
assert [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id]
listed.assert_awaited_once_with(session, rule.version_no)
async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
version_id = uuid.uuid4()
rule = _rule()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[(rule, None)])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
assert result.is_version_fixed is False
assert result.version_no == rule.version_no
assert [item.id for item in result.rules] == [rule.id]
listed.assert_awaited_once_with(session, None)
async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4())
assert result.is_version_fixed is False
assert result.version_no is None
assert result.rules == []
listed.assert_awaited_once_with(session, None)

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.22: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД)."""
import enum
import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.22"
assert DBML_VERSION == "5.23"
assert f"v{DBML_VERSION}" in dbml_path().name

View File

@ -136,10 +136,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert captured[0].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover"
version.elimination_rules_version = None
monkeypatch.setattr(
matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no)
)
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",)
@ -389,3 +385,53 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
assert isinstance(result, IntercompanyResultTableOut)
response = result
assert response.articles[0].amounts[0].company_id == company_a
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:
"""§5: у A по B строки 150 и −50, у B по A 100 — суммы со знаком, как в шахматке; −50 в упаковку не идёт."""
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
positive = _row(company_a, company_b, debit_account, debit="150")
negative = _row(company_a, company_b, debit_account, debit="-50")
counter = _row(company_b, company_a, credit_account, credit="100")
for row in (positive, negative, counter):
row.entry_ids = (uuid.uuid4(),)
row.account_code = "62.01"
rows = [positive, negative, counter]
rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min")
packed = matrix._pack_preset_rows(rows, selection, matrix.PairContext(rule, company_a, company_b))
assert (packed.totals.amount_a, packed.totals.amount_b) == (Decimal("100"), Decimal("100"))
assert packed.totals.eliminated_amount == Decimal("100")
assert packed.matched == packed.with_discrepancy == [] # 150 против 100 — больше 5 %
reasons = {item.row.entry_ids[0]: item.reason for item in packed.unpaired}
assert reasons == {
positive.entry_ids[0]: "NO_MATCH",
negative.entry_ids[0]: "NEGATIVE_AMOUNT",
counter.entry_ids[0]: "NO_MATCH",
}
[item] = matrix.calculate_rule_total_items(rows, [(rule, selection)], [company_a, company_b])
assert (item.amount_a, item.amount_b, item.eliminated_amount, item.status) == (
Decimal("100.00"),
Decimal("100.00"),
Decimal("100.00"),
"AUTO",
)
assert set(item.osv_entry_ids_a) == {positive.entry_ids[0], negative.entry_ids[0]}
def test_negative_smaller_side_is_discrepancy_without_elimination() -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
debit = _row(company_a, company_b, debit_account, debit="-50")
credit = _row(company_b, company_a, credit_account, credit="-50")
debit.entry_ids, credit.entry_ids = (uuid.uuid4(),), (uuid.uuid4(),)
rule = SimpleNamespace(id=uuid.uuid4(), rule_no=6, side_1="D", elimination_mode="min")
[item] = matrix.calculate_rule_total_items([debit, credit], [(rule, selection)], [company_a, company_b])
assert (item.deviation_amount, item.deviation_percent, item.eliminated_amount) == (Decimal("0.00"), None, 0)
assert item.status == "DISCREPANCY"

View File

@ -19,6 +19,7 @@ import pytest
from openpyxl import load_workbook
from src.schemas.consolidations import IntercompanyMatrixCompanyOut
from src.services import intercompany_matrix as matrix
from src.services.intercompany_matrix import MatrixSelection, calculate_matrix
pytestmark = pytest.mark.fixtures
@ -116,6 +117,7 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
legal_entity_id=RB,
counterparty_id=BAKERY,
account_id=credit_account,
account_code="66.03",
closing_debit=Decimal(0),
closing_credit=amount,
entry_ids=(uuid.uuid4(),),
@ -129,12 +131,18 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
assert one_sided.amount_a == 0
assert one_sided.amount_b == amount
assert one_sided.one_sided is True
context = matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB)
unmatched = matrix._pack_preset_rows([credit_row], selection, context)
assert unmatched.matched == []
assert [item.reason for item in unmatched.unpaired] == ["NO_MATCH"]
assert unmatched.totals.eliminated_amount == 0
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
debit_row = SimpleNamespace(
legal_entity_id=BAKERY,
counterparty_id=RB,
account_id=debit_account,
account_code="58.03",
closing_debit=amount,
closing_credit=Decimal(0),
entry_ids=(uuid.uuid4(),),
@ -143,3 +151,76 @@ def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) ->
assert paired.amount_a == paired.amount_b == amount
assert paired.variance_amount == 0
assert paired.one_sided is False
packed = matrix._pack_preset_rows([debit_row, credit_row], selection, context)
assert len(packed.matched) == 1
assert packed.matched[0].amount_a == packed.matched[0].amount_b == amount
assert packed.totals.eliminated_amount == amount
def test_rule_6_bakery_and_russian_biscuit_packs_only_exact_production_row(fixtures_dir: Path) -> None:
"""Четыре реальные дебетовые строки дают итог тройки; упаковка берёт лишь точную 62.01 ↔ 60.01."""
source_rows = [
_source(fixtures_dir, "schastl", root, number)
for root, number in (("62", 21), ("62", 17), ("76", 20), ("76", 29))
]
credit_source = _source(fixtures_dir, "rb", "60", 409)
assert [(row["account_code_raw"], Decimal(row["closing_debit"])) for row in source_rows] == [
("62.01", Decimal("106311116.46")),
("62.01", Decimal("1401330.00")),
("76.06", Decimal("350000.00")),
("76.АВ", Decimal("2366666.69")),
]
assert (credit_source["account_code_raw"], Decimal(credit_source["closing_credit"])) == (
"60.01",
Decimal("106311116.46"),
)
account_ids = {code: uuid.uuid4() for code in ("62.01", "76.06", "76.АВ", "60.01")}
source_ids = [uuid.uuid4() for _ in source_rows]
credit_id = uuid.uuid4()
rows = [
SimpleNamespace(
legal_entity_id=BAKERY,
counterparty_id=RB,
account_id=account_ids[source["account_code_raw"]],
account_code=source["account_code_raw"],
closing_debit=Decimal(source["closing_debit"]),
closing_credit=Decimal(source["closing_credit"] or "0"),
entry_ids=(entry_id,),
)
for source, entry_id in zip(source_rows, source_ids, strict=True)
]
rows.append(
SimpleNamespace(
legal_entity_id=RB,
counterparty_id=BAKERY,
account_id=account_ids["60.01"],
account_code="60.01",
closing_debit=Decimal(credit_source["closing_debit"] or "0"),
closing_credit=Decimal(credit_source["closing_credit"]),
entry_ids=(credit_id,),
)
)
selection = MatrixSelection(
frozenset(account_ids[code] for code in ("62.01", "76.06", "76.АВ")),
frozenset({account_ids["60.01"]}),
"closing",
)
packed = matrix._pack_preset_rows(
rows,
selection,
matrix.PairContext(SimpleNamespace(id=uuid.uuid4(), side_1="D", elimination_mode="min"), BAKERY, RB),
)
assert (packed.totals.amount_a, packed.totals.amount_b) == (
Decimal("110429113.15"),
Decimal("106311116.46"),
)
assert packed.totals.eliminated_amount == Decimal("106311116.46")
assert packed.totals.deviation_percent is not None
assert packed.totals.deviation_percent.quantize(Decimal("0.01")) == Decimal("3.87")
assert packed.with_discrepancy == []
assert len(packed.matched) == 1
assert packed.matched[0].osv_entry_ids_a == [source_ids[0]]
assert packed.matched[0].osv_entry_ids_b == [credit_id]
assert packed.matched[0].amount_a == packed.matched[0].amount_b == Decimal("106311116.46")
assert {item.row.entry_ids[0] for item in packed.unpaired} == set(source_ids[1:])
assert {item.reason for item in packed.unpaired} == {"NO_MATCH"}

View File

@ -39,7 +39,8 @@ from tests.test_models_part_07_09 import (
V5_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20"
HEAD = "0009_dbml_v5_21"
V5_21 = "0009_dbml_v5_21"
HEAD = "0010_dbml_v5_23"
V5_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16"
@ -586,6 +587,9 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
),
ids,
)
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1)
) # v5.23: версиям нужна версия правил
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
@ -1299,7 +1303,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
(V5_18, V5_17),
(V5_19, V5_18),
(V5_20, V5_19),
(HEAD, V5_20),
(V5_21, V5_20),
(HEAD, V5_21),
):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
@ -1366,3 +1371,121 @@ async def test_v5_20_downgrade_refuses_modified_document(scratch_db_url: str) ->
assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified"))
finally:
await engine.dispose()
async def _insert_rule(
conn: AsyncConnection, *, version_no: int, valid_from: datetime.date, valid_to: datetime.date | None = None
) -> None:
await conn.execute(
text(
"INSERT INTO dkb.elimination_rule (id, statement_type, rule_no, article_1_line, article_2_line, side_1, "
"measure, elimination_mode, excise_adjust, version_no, valid_from, valid_to) "
"VALUES (gen_random_uuid(), 'balance', 6, '1230', '1520', 'D', 'closing', 'min', false, :no, "
":start, :end)"
),
{"no": version_no, "start": valid_from, "end": valid_to},
)
async def _insert_versions(conn: AsyncConnection, created: dict[str, datetime.datetime | None]) -> dict[str, uuid.UUID]:
"""Версии консолидации v5.21 без номера правил; ключ — метка, значение — `created_at` (None — пусто)."""
ids = {k: uuid.uuid4() for k in ("office", "group", "user", "c")}
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids)
await conn.execute(
text(
"INSERT INTO dkb.company_group (id, code, name, base_regional_office_id, valid_from) "
"VALUES (:group, 'G-1', 'ГК', :office, now())"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:user, 'm@x.ru', 'Т', :office)"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) "
"VALUES (:c, :group, 2026, 'Q1', '2026-04-01')"
),
ids,
)
versions: dict[str, uuid.UUID] = {}
for no, (label, created_at) in enumerate(created.items(), start=1):
versions[label] = uuid.uuid4()
await conn.execute(
text(
"INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, approach, "
"goodwill_enabled, initiator_id, initiator_office_id, is_in_work, created_at) "
"VALUES (:id, :c, :no, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true, "
":created)"
),
ids | {"id": versions[label], "no": no, "created": created_at},
)
return versions
@pytest.mark.integration
async def test_upgrade_0010_fills_rules_version_by_creation_date(scratch_db_url: str) -> None:
"""v5.23: номер правил — действующих на дату создания версии (по Москве), без даты — на сегодня; затем NOT NULL."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1), valid_to=datetime.date(2026, 6, 30)
)
await _insert_rule(conn, version_no=2, valid_from=datetime.date(2026, 7, 1))
versions = await _insert_versions(
conn,
{
"spring": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC),
# 30.06 21:30 UTC — уже 01.07 по Москве
"edge": datetime.datetime(2026, 6, 30, 21, 30, tzinfo=datetime.UTC),
"undated": None,
},
)
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
rows = dict(
(await conn.execute(text("SELECT id, elimination_rules_version FROM dkb.consolidation_version"))).all()
)
assert rows == {versions["spring"]: 1, versions["edge"]: 2, versions["undated"]: 2}
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["NO"]
await alembic(scratch_db_url, "downgrade", V5_21)
async with engine.connect() as conn:
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["YES"]
assert (
await conn.scalar(
text("SELECT count(*) FROM dkb.consolidation_version WHERE elimination_rules_version IS NULL")
)
== 0
)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0010_refuses_versions_without_rules(scratch_db_url: str) -> None:
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_versions(conn, {"lonely": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC)})
with pytest.raises(AssertionError, match="миграция v5.23 невозможна"):
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_21]
finally:
await engine.dispose()

View File

@ -210,6 +210,7 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None:
version_no=1,
status=ConsolidationStatus.INITIATED,
scenario=ConsolidationScenario.CREATE,
elimination_rules_version=1,
initiator_id=user.id,
initiator_office_id=office.id,
)

View File

@ -78,6 +78,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},

View File

@ -73,6 +73,7 @@ async def test_list_periods_sorts_periods_and_versions_descending(db_session: As
version_no=2,
status=ConsolidationStatus.INITIATED,
scenario=first.scenario,
elimination_rules_version=first.elimination_rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
)

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23

View File

@ -0,0 +1,4 @@
# elimination-pairs-rule-totals
Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL),
итог и упаковка тройки «правило × A × B», список троек версии.

View File

@ -0,0 +1,35 @@
## Решение
Итог тройки `(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` на московскую дату) и дальше
не меняется; ветки «правила не зафиксированы» в чтении правил, пресете шахматки и парах убраны.
Эталонный 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), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится |
| R-7 (design #7) | 23.10.2026 | Статус тройки `AUTO` только при `eliminated_amount > 0`; обе стороны отрицательные и равные — `DISCREPANCY` | Иначе тройка без элиминации выглядела бы сопоставленной автоматически; совпадает с гайдом фронта §5.7.2 | Одна строка в `_total_status` |
| R-8 (design #8) | 23.10.2026 | Процент расхождения при упаковке сравнивается точной дробью (`Fraction`), не округлённым `Decimal` | Ничья по проценту (100 и 110,25 против 105 — по 5 %) не должна зависеть от точности деления | Нет: на выход процент идёт `Decimal` с двумя знаками |

View File

@ -0,0 +1,41 @@
## Why
Срез 5, AURORA-1536: автоматическое сопоставление ВГО по `05-domain-rules.md` §5. Нужны итог элиминации по тройке
«правило, компания A, компания B» (идёт в КФО), детализация строк ОСВ по группам и список всех троек версии для
таблицы корректировок (AURORA-1540, AURORA-1541) и разнесения строк (AURORA-1751). Версия правил, по которой считается
версия консолидации, должна быть зафиксирована всегда, иначе после публикации новых правил старые консолидации молча
пересчитаются по ним.
## What Changes
- При создании версии консолидации фиксировать `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
### New Capabilities
Нет.
### Modified Capabilities
- `consolidations-api`: создание консолидации фиксирует версию правил; правила зафиксированной версии; пары ВГО тройки и
версии.
- `data-model`: DBML v5.23, миграция `0010_dbml_v5_23`.
## Impact
`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), правила и пары ОФР.

View File

@ -0,0 +1,180 @@
## 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: Правила элиминации зафиксированной версии
`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`
## REMOVED Requirements
### Requirement: Правила элиминации версии
**Reason**: с DBML v5.23 `elimination_rules_version` задан у каждой версии консолидации; ветка «версия правил не
зафиксирована — последние открытые правила» больше не существует.
**Migration**: требование «Правила элиминации зафиксированной версии»; клиенту `is_version_fixed` всегда `true`.

View File

@ -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`

View File

@ -0,0 +1,12 @@
## Реализация
- [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).
- [x] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы
с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1; доделано в ветке PR сами).
- [ ] `make check-full` и архив change.