fix(matrix): выбирать последнюю открытую версию правил
This commit is contained in:
parent
38c8eefa18
commit
3eb5362e58
@ -874,7 +874,7 @@ API: список счетов `GET /directories/accounts`, правила ве
|
||||
`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары
|
||||
`…/preset/{rule_id}/unpaired-osv`.
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
|
||||
версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель»,
|
||||
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам —
|
||||
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import uuid
|
||||
from collections.abc import Collection
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
|
||||
@ -12,9 +12,14 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu
|
||||
async def list_rules_with_accounts(
|
||||
session: AsyncSession, version_no: int | None
|
||||
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
|
||||
"""Правила заданной версии либо все правила без `valid_to`."""
|
||||
"""Правила заданной версии либо открытые правила последней версии."""
|
||||
condition = (
|
||||
EliminationRule.version_no == version_no if version_no is not None else EliminationRule.valid_to.is_(None)
|
||||
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())
|
||||
)
|
||||
)
|
||||
stmt = (
|
||||
select(EliminationRule, EliminationRuleAccount)
|
||||
@ -29,6 +34,15 @@ async def list_rules_with_accounts(
|
||||
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
def _current_open_version_stmt():
|
||||
return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None))
|
||||
|
||||
|
||||
async def current_open_version_no(session: AsyncSession) -> int | None:
|
||||
"""Номер последней версии, в которой есть открытые правила."""
|
||||
return await session.scalar(_current_open_version_stmt())
|
||||
|
||||
|
||||
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
|
||||
"""Проверить номер версии либо отсутствие `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)
|
||||
|
||||
@ -28,6 +28,8 @@ class EliminationRuleService:
|
||||
is_version_fixed = version.elimination_rules_version is not None
|
||||
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)
|
||||
|
||||
@ -419,6 +419,10 @@ 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"):
|
||||
|
||||
@ -235,7 +235,7 @@ async def test_preset_uses_current_rules_when_version_unfixed(
|
||||
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()["version_no"] is None
|
||||
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"]}
|
||||
response = await client.get(url)
|
||||
@ -258,6 +258,30 @@ async def test_preset_uses_current_rules_when_version_unfixed(
|
||||
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:
|
||||
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()
|
||||
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
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
|
||||
|
||||
old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}")
|
||||
assert old_preset.status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_unmapped_account_source_is_visible_in_cell_count(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
|
||||
@ -42,7 +42,10 @@ async def test_rules_filter_by_version_or_open_valid_to() -> None:
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
|
||||
|
||||
await rules_repo.list_rules_with_accounts(session, None)
|
||||
assert "valid_to IS NULL" in str(session.execute.await_args.args[0])
|
||||
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])
|
||||
@ -87,7 +90,7 @@ async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch)
|
||||
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
|
||||
|
||||
assert result.is_version_fixed is False
|
||||
assert result.version_no is None
|
||||
assert result.version_no == rule.version_no
|
||||
assert [item.id for item in result.rules] == [rule.id]
|
||||
listed.assert_awaited_once_with(session, None)
|
||||
|
||||
|
||||
@ -157,6 +157,9 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
|
||||
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",)
|
||||
|
||||
@ -10,7 +10,8 @@ change не входит. Шахматка — чистая функция от
|
||||
## Decisions
|
||||
|
||||
1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном
|
||||
`elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL`; иначе 404.
|
||||
`elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL` и максимальный открытый
|
||||
`version_no`; иначе 404.
|
||||
2. Сторона дебета — `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона. Счета и исключения
|
||||
раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422
|
||||
`ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422
|
||||
@ -50,11 +51,7 @@ change не входит. Шахматка — чистая функция от
|
||||
|
||||
## Known issues
|
||||
|
||||
1. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no` (обязательно в PR: одна версия
|
||||
или явная ошибка, тест).
|
||||
2. При незафиксированной версии ответ `elimination-rules` отдаёт `version_no = null` — фронт не видит, по какой версии
|
||||
посчитано (желательно отдавать фактический номер открытых правил).
|
||||
3. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и
|
||||
1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и
|
||||
версию правил.
|
||||
|
||||
## Decisions made without discussion
|
||||
@ -65,9 +62,8 @@ change не входит. Шахматка — чистая функция от
|
||||
автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой.
|
||||
- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main
|
||||
его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть.
|
||||
- 2026-10-21. Интеграционный тест `test_preset_uses_current_rules_when_version_unfixed` приведён к фактическому
|
||||
поведению (`version_no is None`, `is_version_fixed is False`), а не к старому ожиданию `version_no == rule.version_no`;
|
||||
логика вычисления `version_no` не добавлялась. Цена ошибки: если автор решит возвращать номер версии, тест вернуть.
|
||||
- 2026-10-21. При незаданной версии правил выбирается максимальный открытый `version_no`; он возвращается в ответе
|
||||
`elimination-rules`, чтобы фронт видел фактически применённую версию.
|
||||
- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ
|
||||
(`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая.
|
||||
- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
строк без пары `…/preset/{rule_id}/unpaired-osv`.
|
||||
- Показатель пресета (`closing` / `turnover`) берётся из правила.
|
||||
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть
|
||||
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`).
|
||||
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`)
|
||||
с максимальным `version_no`; ответ содержит этот номер.
|
||||
- xlsx: для PRESET имя файла с `_preset-<rule_id>`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode`
|
||||
`ALL`/`MANUAL`/`PRESET` и `rule_id`.
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила:
|
||||
`closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`).
|
||||
Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же
|
||||
`version_no`, иначе — без `valid_to`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422:
|
||||
`version_no`, иначе — без `valid_to` и с максимальным открытым `version_no`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422:
|
||||
`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила,
|
||||
`ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`.
|
||||
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
|
||||
SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан
|
||||
`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а
|
||||
`rules` — правилами этой версии. Если он не задан, `version_no = null`, `is_version_fixed = false`, а `rules` —
|
||||
открытые правила (`valid_to IS NULL`); при их отсутствии `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`,
|
||||
`rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` —
|
||||
открытые правила (`valid_to IS NULL`) только с максимальным `version_no`; ответ SHALL содержать этот фактический
|
||||
`version_no`. При отсутствии открытых правил `version_no = null`, `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`,
|
||||
`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
|
||||
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
|
||||
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
||||
@ -20,4 +21,8 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`.
|
||||
|
||||
#### Scenario: Версия правил не зафиксирована
|
||||
- **WHEN** у версии консолидации `elimination_rules_version` пуст
|
||||
- **THEN** ответ содержит `is_version_fixed = false`, `version_no = null` и открытые правила с `valid_to IS NULL`
|
||||
- **THEN** ответ содержит `is_version_fixed = false`, фактический `version_no` и открытые правила только этой версии
|
||||
|
||||
#### Scenario: Открыты две версии правил
|
||||
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
|
||||
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
|
||||
|
||||
@ -10,5 +10,5 @@
|
||||
- [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
|
||||
код сохранён в `wip/shahmatka-corrections-unpaired`.
|
||||
- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест.
|
||||
- [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест.
|
||||
- [x] 2.3 Несколько открытых `version_no` — только максимальная версия; вернуть её номер, тест.
|
||||
- [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user