diff --git a/README.md b/README.md index 4626427..b775d9f 100644 --- a/README.md +++ b/README.md @@ -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`. Контракт для фронта — diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py index a7008e8..bcd12ba 100644 --- a/api/src/repositories/elimination_rules.py +++ b/api/src/repositories/elimination_rules.py @@ -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) diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py index d61b574..bf15ca1 100644 --- a/api/src/services/elimination_rules.py +++ b/api/src/services/elimination_rules.py @@ -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) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index ae8c6bf..5d6ba47 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -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"): diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 34607b3..77da4a4 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -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: diff --git a/api/tests/test_elimination_rules.py b/api/tests/test_elimination_rules.py index 87ab323..fd5615e 100644 --- a/api/tests/test_elimination_rules.py +++ b/api/tests/test_elimination_rules.py @@ -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) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 7aa7b1d..2b2936d 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -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",) diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index 8858ffb..d08da89 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -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-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 1e58dee..4c29410 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -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-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` `ALL`/`MANUAL`/`PRESET` и `rule_id`. diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 6c5d008..334b71c 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -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`. diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md index 1217e97..fe54402 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidations-api/spec.md @@ -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` diff --git a/openspec/changes/intercompany-matrix-presets/tasks.md b/openspec/changes/intercompany-matrix-presets/tasks.md index 5a60294..6db50ca 100644 --- a/openspec/changes/intercompany-matrix-presets/tasks.md +++ b/openspec/changes/intercompany-matrix-presets/tasks.md @@ -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.