fix(matrix): выбирать последнюю открытую версию правил

This commit is contained in:
PotapovaA 2026-10-21 18:51:06 +03:00
parent 38c8eefa18
commit 3eb5362e58
12 changed files with 74 additions and 22 deletions

View File

@ -874,7 +874,7 @@ API: список счетов `GET /directories/accounts`, правила ве
`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары `GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары
`…/preset/{rule_id}/unpaired-osv`. `…/preset/{rule_id}/unpaired-osv`.
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной `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 — строка «Показатель», `ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам — audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`) и `rule_id`. Открытые вопросы по пресетам —
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — `openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —

View File

@ -3,7 +3,7 @@
import uuid import uuid
from collections.abc import Collection from collections.abc import Collection
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount 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( async def list_rules_with_accounts(
session: AsyncSession, version_no: int | None session: AsyncSession, version_no: int | None
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила заданной версии либо все правила без `valid_to`.""" """Правила заданной версии либо открытые правила последней версии."""
condition = ( 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 = ( stmt = (
select(EliminationRule, EliminationRuleAccount) 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()] 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: def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
"""Проверить номер версии либо отсутствие `valid_to`.""" """Проверить номер версии либо отсутствие `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) return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)

View File

@ -28,6 +28,8 @@ class EliminationRuleService:
is_version_fixed = version.elimination_rules_version is not None is_version_fixed = version.elimination_rules_version is not None
version_no = version.elimination_rules_version version_no = version.elimination_rules_version
rows = await rules_repo.list_rules_with_accounts(self._session, version_no) 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] = {} by_id: dict[uuid.UUID, EliminationRuleOut] = {}
for rule, account in rows: for rule, account in rows:
result = by_id.get(rule.id) result = by_id.get(rule.id)

View File

@ -419,6 +419,10 @@ class IntercompanyMatrixService:
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) 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): if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
raise NotFoundError("правило-пресет не найдено для версии консолидации") 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: if rule.statement_type != StatementType.balance:
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ") raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"): if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):

View File

@ -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") listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False 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 "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
response = await client.get(url) 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 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( async def test_unmapped_account_source_is_visible_in_cell_count(
auth_client: AuthClientFactory, seeded: AsyncSession auth_client: AuthClientFactory, seeded: AsyncSession
) -> None: ) -> None:

View File

@ -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: []))) session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
await rules_repo.list_rules_with_accounts(session, None) 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) 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 "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) result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
assert result.is_version_fixed is False 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] assert [item.id for item in result.rules] == [rule.id]
listed.assert_awaited_once_with(session, None) listed.assert_awaited_once_with(session, None)

View File

@ -157,6 +157,9 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert captured[0].measure == "turnover" assert captured[0].measure == "turnover"
version.elimination_rules_version = None 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()) _, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET" assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",) assert info.debit_account_codes == ("76",)

View File

@ -10,7 +10,8 @@ change не входит. Шахматка — чистая функция от
## Decisions ## Decisions
1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном 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`; кредит — вторая сторона. Счета и исключения 2. Сторона дебета — `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона. Счета и исключения
раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422 раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422
`ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422 `ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422
@ -50,11 +51,7 @@ change не входит. Шахматка — чистая функция от
## Known issues ## Known issues
1. Если открытыми остаются несколько `version_no`, список правил даёт дубли `rule_no` (обязательно в PR: одна версия 1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и
или явная ошибка, тест).
2. При незафиксированной версии ответ `elimination-rules` отдаёт `version_no = null` — фронт не видит, по какой версии
посчитано (желательно отдавать фактический номер открытых правил).
3. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и
версию правил. версию правил.
## Decisions made without discussion ## Decisions made without discussion
@ -65,9 +62,8 @@ change не входит. Шахматка — чистая функция от
автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой. автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой.
- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main - 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main
его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть. его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть.
- 2026-10-21. Интеграционный тест `test_preset_uses_current_rules_when_version_unfixed` приведён к фактическому - 2026-10-21. При незаданной версии правил выбирается максимальный открытый `version_no`; он возвращается в ответе
поведению (`version_no is None`, `is_version_fixed is False`), а не к старому ожиданию `version_no == rule.version_no`; `elimination-rules`, чтобы фронт видел фактически применённую версию.
логика вычисления `version_no` не добавлялась. Цена ошибки: если автор решит возвращать номер версии, тест вернуть.
- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ - 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ
(`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая. (`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая.
- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование - 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование

View File

@ -12,7 +12,8 @@
строк без пары `…/preset/{rule_id}/unpaired-osv`. строк без пары `…/preset/{rule_id}/unpaired-osv`.
- Показатель пресета (`closing` / `turnover`) берётся из правила. - Показатель пресета (`closing` / `turnover`) берётся из правила.
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть - `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` - xlsx: для PRESET имя файла с `_preset-<rule_id>`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode`
`ALL`/`MANUAL`/`PRESET` и `rule_id`. `ALL`/`MANUAL`/`PRESET` и `rule_id`.

View File

@ -8,7 +8,7 @@
раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила: раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила:
`closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`). `closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`).
Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же Правило 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` или сторона счёта правила, `RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила,
`ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`. `ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`.

View File

@ -4,8 +4,9 @@
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) `GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан
`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а `consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а
`rules` — правилами этой версии. Если он не задан, `version_no = null`, `is_version_fixed = false`, а `rules` — `rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` —
открытые правила (`valid_to IS NULL`); при их отсутствии `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`, открытые правила (`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`; `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов. каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
@ -20,4 +21,8 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`.
#### Scenario: Версия правил не зафиксирована #### Scenario: Версия правил не зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version` пуст - **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`

View File

@ -10,5 +10,5 @@
- [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами; - [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
код сохранён в `wip/shahmatka-corrections-unpaired`. код сохранён в `wip/shahmatka-corrections-unpaired`.
- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест. - [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. - [ ] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README.