Compare commits
9 Commits
1b3f85c88c
...
393bd4c484
| Author | SHA1 | Date | |
|---|---|---|---|
| 393bd4c484 | |||
|
|
8940f32ae3 | ||
|
|
fdb1f179cc | ||
|
|
b4522addc0 | ||
|
|
812e530fe5 | ||
|
|
888ad4b401 | ||
|
|
827b490137 | ||
|
|
010c15386b | ||
|
|
232fecec39 |
@ -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`, как и
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
||||
27
README.md
27
README.md
@ -940,8 +940,9 @@ API: список счетов `GET /directories/accounts`, правила ве
|
||||
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
|
||||
Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма
|
||||
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
|
||||
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии
|
||||
`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату
|
||||
создания, OQ-174) и с DBML v5.23 есть всегда, `is_version_fixed = true`. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
|
||||
`_rule-balance-<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) — план срезов;
|
||||
|
||||
28
api/alembic/versions/0010_dbml_v5_23.py
Normal file
28
api/alembic/versions/0010_dbml_v5_23.py
Normal 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)
|
||||
@ -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 = "нет"
|
||||
|
||||
@ -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)
|
||||
|
||||
204
api/src/domain/elimination_packing.py
Normal file
204
api/src/domain/elimination_packing.py
Normal 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))
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()),
|
||||
)
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
152
api/tests/test_elimination_packing.py
Normal file
152
api/tests/test_elimination_packing.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-23
|
||||
4
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
4
openspec/changes/elimination-pairs-rule-totals/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# elimination-pairs-rule-totals
|
||||
|
||||
Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL),
|
||||
итог и упаковка тройки «правило × A × B», список троек версии.
|
||||
35
openspec/changes/elimination-pairs-rule-totals/design.md
Normal file
35
openspec/changes/elimination-pairs-rule-totals/design.md
Normal 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` с двумя знаками |
|
||||
41
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal file
41
openspec/changes/elimination-pairs-rule-totals/proposal.md
Normal 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), правила и пары ОФР.
|
||||
@ -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`.
|
||||
@ -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`
|
||||
12
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal file
12
openspec/changes/elimination-pairs-rule-totals/tasks.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user