fix(audit): сохранять номер и версию правила в экспорте пресета

This commit is contained in:
PotapovaA 2026-10-21 18:54:58 +03:00
parent 3eb5362e58
commit b078933428
9 changed files with 39 additions and 10 deletions

View File

@ -876,7 +876,8 @@ API: список счетов `GET /directories/accounts`, правила ве
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки 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`), для PRESET — `rule_id`, `rule_no` и фактический
`rule_version_no`. Открытые вопросы по пресетам —
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — `openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях `../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.

View File

@ -52,6 +52,8 @@ async def _export_response(
*, *,
selection: IntercompanyMatrixManualIn | None = None, selection: IntercompanyMatrixManualIn | None = None,
) -> Response: ) -> Response:
if info.mode == "PRESET" and (info.rule_id is None or info.rule_no is None or info.rule_version_no is None):
raise ValueError("Для аудита PRESET нужны ID, номер и версия правила")
response = await _xlsx_response(matrix, info) response = await _xlsx_response(matrix, info)
await audit.record( await audit.record(
action=AuditAction.EXPORT, action=AuditAction.EXPORT,
@ -64,7 +66,15 @@ async def _export_response(
"mode": info.mode, "mode": info.mode,
"debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [], "debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [],
"credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [], "credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [],
**({"rule_id": str(info.rule_id)} if info.rule_id is not None else {}), **(
{
"rule_id": str(info.rule_id),
"rule_no": info.rule_no,
"rule_version_no": info.rule_version_no,
}
if info.mode == "PRESET"
else {}
),
"cells": len(matrix.cells), "cells": len(matrix.cells),
}, },
) )

View File

@ -87,6 +87,7 @@ class MatrixBuildResult:
accounts: list[ChartOfAccount] accounts: list[ChartOfAccount]
selection: MatrixSelection selection: MatrixSelection
rows: tuple[ConsolidatedRow, ...] rows: tuple[ConsolidatedRow, ...]
rule: EliminationRule | None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -100,6 +101,8 @@ class MatrixExportInfo:
credit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...]
measure: Measure = "closing" measure: Measure = "closing"
rule_id: uuid.UUID | None = None rule_id: uuid.UUID | None = None
rule_no: int | None = None
rule_version_no: int | None = None
@property @property
def filename(self) -> str: def filename(self) -> str:
@ -394,7 +397,9 @@ class IntercompanyMatrixService:
debit_account_codes=debit_codes, debit_account_codes=debit_codes,
credit_account_codes=credit_codes, credit_account_codes=credit_codes,
measure=result.selection.measure, measure=result.selection.measure,
rule_id=rule_id, rule_id=result.rule.id if result.rule is not None else None,
rule_no=result.rule.rule_no if result.rule is not None else None,
rule_version_no=result.rule.version_no if result.rule is not None else None,
) )
return result.matrix, info return result.matrix, info
@ -462,6 +467,7 @@ class IntercompanyMatrixService:
rows = osv_result.consolidated.rows rows = osv_result.consolidated.rows
accounts = await directory_repo.list_accounts(self._session) accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts) by_id, children = _make_account_tree(accounts)
rule: EliminationRule | None = None
if mode == "ALL": if mode == "ALL":
if debit_account_ids or credit_account_ids: if debit_account_ids or credit_account_ids:
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета")
@ -475,7 +481,7 @@ class IntercompanyMatrixService:
credit = _select_accounts(credit_account_ids, (), "K", by_id, children) credit = _select_accounts(credit_account_ids, (), "K", by_id, children)
selection = MatrixSelection(debit, credit, "closing") selection = MatrixSelection(debit, credit, "closing")
elif mode == "PRESET": elif mode == "PRESET":
selection, _ = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children)
else: else:
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
matrix = self._calculate(rows, osv_result.members, selection) matrix = self._calculate(rows, osv_result.members, selection)
@ -486,4 +492,5 @@ class IntercompanyMatrixService:
accounts=accounts, accounts=accounts,
selection=selection, selection=selection,
rows=rows, rows=rows,
rule=rule,
) )

View File

@ -177,6 +177,8 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["mode"] == "PRESET" assert audit.after_value["mode"] == "PRESET"
assert audit.after_value["rule_id"] == str(rule.id) assert audit.after_value["rule_id"] == str(rule.id)
assert audit.after_value["rule_no"] == rule.rule_no
assert audit.after_value["rule_version_no"] == rule.version_no
@pytest.mark.parametrize("suffix", ["", "/export"]) @pytest.mark.parametrize("suffix", ["", "/export"])
@ -241,6 +243,9 @@ async def test_preset_uses_current_rules_when_version_unfixed(
response = await client.get(url) response = await client.get(url)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK 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 is None
world.version.elimination_rules_version = rule.version_no + 1 world.version.elimination_rules_version = rule.version_no + 1
await seeded.flush() await seeded.flush()

View File

@ -129,6 +129,7 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child])) monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child]))
rule = SimpleNamespace( rule = SimpleNamespace(
id=uuid.uuid4(), id=uuid.uuid4(),
rule_no=6,
statement_type=StatementType.balance, statement_type=StatementType.balance,
version_no=1, version_no=1,
side_1="D", side_1="D",
@ -165,6 +166,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert info.debit_account_codes == ("76",) assert info.debit_account_codes == ("76",)
assert info.credit_account_codes == ("76", "76.01") assert info.credit_account_codes == ("76", "76.01")
assert info.measure == "turnover" assert info.measure == "turnover"
assert info.rule_no == rule.rule_no
assert info.rule_version_no == rule.version_no
async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None: async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -37,6 +37,8 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI
credit_account_codes=("60.01",) if mode != "ALL" else (), credit_account_codes=("60.01",) if mode != "ALL" else (),
measure="turnover" if mode == "PRESET" else "closing", measure="turnover" if mode == "PRESET" else "closing",
rule_id=rule_id, rule_id=rule_id,
rule_no=6 if mode == "PRESET" else None,
rule_version_no=3 if mode == "PRESET" else None,
) )
@ -135,7 +137,6 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
) )
async def test_export_routes_use_matrix_calculation() -> None: async def test_export_routes_use_matrix_calculation() -> None:
version_id = UUID("11111111-1111-1111-1111-111111111111") version_id = UUID("11111111-1111-1111-1111-111111111111")
account_id = UUID("22222222-2222-2222-2222-222222222222") account_id = UUID("22222222-2222-2222-2222-222222222222")
@ -234,6 +235,8 @@ async def test_export_routes_use_matrix_calculation() -> None:
"debit_account_ids": [], "debit_account_ids": [],
"credit_account_ids": [], "credit_account_ids": [],
"rule_id": str(account_id), "rule_id": str(account_id),
"rule_no": 6,
"rule_version_no": 3,
"cells": 1, "cells": 1,
}, },
}, },

View File

@ -51,8 +51,7 @@ change не входит. Шахматка — чистая функция от
## Known issues ## Known issues
1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию; audit EXPORT не пишет номер и 1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию.
версию правил.
## Decisions made without discussion ## Decisions made without discussion

View File

@ -15,7 +15,7 @@
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`) `id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`)
с максимальным `version_no`; ответ содержит этот номер. с максимальным `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`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`.
## Capabilities ## Capabilities

View File

@ -6,7 +6,8 @@
SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`,
`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, `object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора,
`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, `before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids,
credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`. `mode` — `ALL`, credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка `MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
сводной ОСВ продолжает писать собственное событие `EXPORT`. сводной ОСВ продолжает писать собственное событие `EXPORT`.
@ -16,4 +17,4 @@ credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть д
#### Scenario: Выгрузка пресета #### Scenario: Выгрузка пресета
- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export` - **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export`
- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id` правила, пустые массивы счетов и число ячеек - **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек