387 lines
15 KiB
Python
387 lines
15 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import inspect, text
|
||
|
|
from sqlalchemy.engine import Engine
|
||
|
|
from sqlalchemy.exc import DBAPIError, IntegrityError
|
||
|
|
|
||
|
|
from app.models.savings import (
|
||
|
|
ProfileBaselineSnapshot,
|
||
|
|
SavingsEvent,
|
||
|
|
SavingsEvidenceLink,
|
||
|
|
SavingsOpportunity,
|
||
|
|
SavingsRealization,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_savings_head_schema(engine: Engine) -> None:
|
||
|
|
inspector = inspect(engine)
|
||
|
|
for model in (
|
||
|
|
ProfileBaselineSnapshot,
|
||
|
|
SavingsOpportunity,
|
||
|
|
SavingsRealization,
|
||
|
|
SavingsEvidenceLink,
|
||
|
|
SavingsEvent,
|
||
|
|
):
|
||
|
|
table = model.__table__
|
||
|
|
live_columns = {
|
||
|
|
str(column["name"]): bool(column["nullable"])
|
||
|
|
for column in inspector.get_columns(table.name, schema="public")
|
||
|
|
}
|
||
|
|
declared_columns = {
|
||
|
|
column.name: bool(column.nullable) for column in table.columns
|
||
|
|
}
|
||
|
|
assert live_columns == declared_columns
|
||
|
|
|
||
|
|
live_constraint_names = {
|
||
|
|
str(item["name"])
|
||
|
|
for loader in (
|
||
|
|
inspector.get_unique_constraints,
|
||
|
|
inspector.get_check_constraints,
|
||
|
|
inspector.get_foreign_keys,
|
||
|
|
)
|
||
|
|
for item in loader(table.name, schema="public")
|
||
|
|
if item.get("name")
|
||
|
|
}
|
||
|
|
declared_constraint_names = {
|
||
|
|
str(constraint.name)
|
||
|
|
for constraint in table.constraints
|
||
|
|
if constraint.name is not None
|
||
|
|
}
|
||
|
|
assert live_constraint_names == declared_constraint_names
|
||
|
|
|
||
|
|
live_index_names = {
|
||
|
|
str(item["name"])
|
||
|
|
for item in inspector.get_indexes(table.name, schema="public")
|
||
|
|
if not item.get("duplicates_constraint")
|
||
|
|
}
|
||
|
|
declared_index_names = {str(index.name) for index in table.indexes}
|
||
|
|
assert live_index_names == declared_index_names
|
||
|
|
|
||
|
|
for table_name in ("savings_opportunities", "savings_realizations"):
|
||
|
|
constrained_columns = {
|
||
|
|
str(column)
|
||
|
|
for foreign_key in inspector.get_foreign_keys(table_name, schema="public")
|
||
|
|
for column in foreign_key["constrained_columns"]
|
||
|
|
}
|
||
|
|
assert "claim_id" not in constrained_columns
|
||
|
|
assert "claim_item_id" not in constrained_columns
|
||
|
|
|
||
|
|
with engine.connect() as connection:
|
||
|
|
canonical_index = str(
|
||
|
|
connection.scalar(
|
||
|
|
text(
|
||
|
|
"SELECT indexdef FROM pg_indexes "
|
||
|
|
"WHERE schemaname = 'public' "
|
||
|
|
"AND tablename = 'savings_realizations' "
|
||
|
|
"AND indexname = "
|
||
|
|
"'uq_savings_realizations_actual_canonical_benefit'"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
or ""
|
||
|
|
).lower()
|
||
|
|
trigger_count = int(
|
||
|
|
connection.scalar(
|
||
|
|
text(
|
||
|
|
"SELECT COUNT(*) FROM pg_trigger trigger "
|
||
|
|
"JOIN pg_class relation ON relation.oid = trigger.tgrelid "
|
||
|
|
"WHERE relation.relname = 'savings_events' "
|
||
|
|
"AND trigger.tgname = 'trg_savings_events_append_only' "
|
||
|
|
"AND NOT trigger.tgisinternal"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
or 0
|
||
|
|
)
|
||
|
|
assert "unique index" in canonical_index
|
||
|
|
assert "realization_type" in canonical_index
|
||
|
|
assert "actual" in canonical_index
|
||
|
|
assert "dedupe_status" in canonical_index
|
||
|
|
assert "canonical" in canonical_index
|
||
|
|
assert trigger_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_savings_runtime_invariants(engine: Engine) -> None:
|
||
|
|
opportunity_insert = text(
|
||
|
|
"""
|
||
|
|
INSERT INTO savings_opportunities (
|
||
|
|
id, tenant_id, opportunity_key, benefit_key, expense_case_id,
|
||
|
|
claim_id, claim_no_snapshot, source_type, source_id, category,
|
||
|
|
value_kind, title, description, exposure_amount,
|
||
|
|
baseline_snapshot_id, baseline_amount, target_amount,
|
||
|
|
estimated_gross, estimated_cost, estimated_net,
|
||
|
|
estimated_low, estimated_high, confidence, currency,
|
||
|
|
reporting_currency, attribution_method, suggested_action,
|
||
|
|
owner_id, owner_name, owner_role
|
||
|
|
) VALUES (
|
||
|
|
:id, :tenant_id, :opportunity_key, :benefit_key, :expense_case_id,
|
||
|
|
'claim-soft-ref', 'CLM-SNAPSHOT', 'policy_adjustment', :id, 'lodging',
|
||
|
|
'cash', '住宿标准优化', '服务器政策反事实', 20,
|
||
|
|
:baseline_snapshot_id, 100, 80, 20, 0, 20, 15, 25, 1,
|
||
|
|
'CNY', 'CNY', 'policy_counterfactual', '按职级标准重算',
|
||
|
|
'finance-owner', '财务负责人', 'finance'
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
realization_insert = text(
|
||
|
|
"""
|
||
|
|
INSERT INTO savings_realizations (
|
||
|
|
id, tenant_id, realization_key, opportunity_id, expense_case_id,
|
||
|
|
claim_id, realization_type, reversal_of_realization_id, realized_at,
|
||
|
|
recorded_by_id, recorded_by_name, actual_gross, incremental_cost,
|
||
|
|
actual_net, original_currency, reporting_amount, reporting_currency,
|
||
|
|
fx_rate, fx_source, fx_date, fx_version, attribution_method,
|
||
|
|
attribution_ratio, benefit_key, dedupe_status,
|
||
|
|
canonical_realization_id, status
|
||
|
|
) VALUES (
|
||
|
|
:id, 'tenant-a', :id, :opportunity_id, 'case-a', 'claim-soft-ref',
|
||
|
|
:realization_type, :reversal_of_realization_id, now(),
|
||
|
|
'recorder-a', '填报人', :actual_gross, 0, :actual_net,
|
||
|
|
'CNY', :reporting_amount, 'CNY', 1, 'system-fixed', current_date,
|
||
|
|
'fx-cny-v1', 'direct', 1, :benefit_key, :dedupe_status,
|
||
|
|
:canonical_realization_id, 'pending_confirmation'
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
|
||
|
|
def execute_rejected(
|
||
|
|
connection: Any,
|
||
|
|
statement: Any,
|
||
|
|
parameters: dict[str, Any],
|
||
|
|
error_type: type[DBAPIError] = IntegrityError,
|
||
|
|
) -> None:
|
||
|
|
savepoint = connection.begin_nested()
|
||
|
|
try:
|
||
|
|
with pytest.raises(error_type):
|
||
|
|
connection.execute(statement, parameters)
|
||
|
|
finally:
|
||
|
|
if savepoint.is_active:
|
||
|
|
savepoint.rollback()
|
||
|
|
|
||
|
|
with engine.connect() as connection:
|
||
|
|
transaction = connection.begin()
|
||
|
|
try:
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
INSERT INTO expense_cases (
|
||
|
|
id, tenant_id, case_no, scene_code, title, current_stage, status
|
||
|
|
) VALUES (
|
||
|
|
'case-a', 'tenant-a', 'CASE-A', 'expense_reimbursement',
|
||
|
|
'迁移约束探针', 'payment', 'active'
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
INSERT INTO profile_baseline_snapshots (
|
||
|
|
id, tenant_id, baseline_key, baseline_type, dimension_type,
|
||
|
|
dimension_id, metric_key, unit, original_currency,
|
||
|
|
baseline_value, sample_count, method, query_fingerprint,
|
||
|
|
data_quality_status, data_quality_score, algorithm_version,
|
||
|
|
policy_version, policy_effective_from, target_resource_type,
|
||
|
|
target_resource_id, frozen_at, frozen_by
|
||
|
|
) VALUES (
|
||
|
|
'baseline-a', 'tenant-a', 'baseline-a',
|
||
|
|
'policy_counterfactual', 'claim_item', 'item-soft-ref',
|
||
|
|
'approved_amount', 'currency', 'CNY', 100, 0,
|
||
|
|
'server_policy', 'query-fingerprint-a', 'complete', 1,
|
||
|
|
'policy-calculator-v1', 'policy-v1', current_date,
|
||
|
|
'claim_item', 'item-soft-ref', now(), 'system'
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
opportunity_insert,
|
||
|
|
{
|
||
|
|
"id": "opportunity-a",
|
||
|
|
"tenant_id": "tenant-a",
|
||
|
|
"opportunity_key": "opportunity-key-a",
|
||
|
|
"benefit_key": "benefit-a",
|
||
|
|
"expense_case_id": "case-a",
|
||
|
|
"baseline_snapshot_id": "baseline-a",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
opportunity_insert,
|
||
|
|
{
|
||
|
|
"id": "opportunity-b",
|
||
|
|
"tenant_id": "tenant-a",
|
||
|
|
"opportunity_key": "opportunity-key-b",
|
||
|
|
"benefit_key": "benefit-b",
|
||
|
|
"expense_case_id": "case-a",
|
||
|
|
"baseline_snapshot_id": "baseline-a",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
opportunity_insert,
|
||
|
|
{
|
||
|
|
"id": "cross-tenant-opportunity",
|
||
|
|
"tenant_id": "tenant-b",
|
||
|
|
"opportunity_key": "cross-tenant-opportunity",
|
||
|
|
"benefit_key": "cross-tenant-benefit",
|
||
|
|
"expense_case_id": "case-a",
|
||
|
|
"baseline_snapshot_id": "baseline-a",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
common_actual = {
|
||
|
|
"opportunity_id": "opportunity-a",
|
||
|
|
"realization_type": "actual",
|
||
|
|
"reversal_of_realization_id": None,
|
||
|
|
"actual_gross": 20,
|
||
|
|
"actual_net": 20,
|
||
|
|
"reporting_amount": 20,
|
||
|
|
"canonical_realization_id": None,
|
||
|
|
}
|
||
|
|
connection.execute(
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
**common_actual,
|
||
|
|
"id": "realization-a",
|
||
|
|
"benefit_key": "benefit-a",
|
||
|
|
"dedupe_status": "pending_review",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
**common_actual,
|
||
|
|
"id": "realization-canonical",
|
||
|
|
"benefit_key": "benefit-canonical",
|
||
|
|
"dedupe_status": "canonical",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
**common_actual,
|
||
|
|
"id": "realization-canonical-duplicate",
|
||
|
|
"benefit_key": "benefit-canonical",
|
||
|
|
"dedupe_status": "canonical",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
**common_actual,
|
||
|
|
"id": "realization-wrong-canonical",
|
||
|
|
"benefit_key": "other-benefit",
|
||
|
|
"dedupe_status": "duplicate",
|
||
|
|
"canonical_realization_id": "realization-a",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
"id": "cross-opportunity-reversal",
|
||
|
|
"opportunity_id": "opportunity-b",
|
||
|
|
"realization_type": "reversal",
|
||
|
|
"reversal_of_realization_id": "realization-a",
|
||
|
|
"actual_gross": -5,
|
||
|
|
"actual_net": -5,
|
||
|
|
"reporting_amount": -5,
|
||
|
|
"benefit_key": "benefit-a",
|
||
|
|
"dedupe_status": "pending_review",
|
||
|
|
"canonical_realization_id": None,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
UPDATE savings_realizations
|
||
|
|
SET status = 'finance_confirmed',
|
||
|
|
finance_confirmer_id = recorded_by_id,
|
||
|
|
finance_confirmer_name = '同一填报人',
|
||
|
|
confirmed_at = now(), confirmation_note = '不应通过'
|
||
|
|
WHERE id = 'realization-canonical'
|
||
|
|
"""
|
||
|
|
),
|
||
|
|
{},
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
UPDATE savings_realizations
|
||
|
|
SET status = 'finance_confirmed',
|
||
|
|
finance_confirmer_id = 'finance-b',
|
||
|
|
finance_confirmer_name = '独立财务',
|
||
|
|
confirmed_at = now(), confirmation_note = '证据完整'
|
||
|
|
WHERE id = 'realization-canonical'
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
)
|
||
|
|
assert connection.scalar(
|
||
|
|
text(
|
||
|
|
"SELECT status FROM savings_realizations "
|
||
|
|
"WHERE id = 'realization-canonical'"
|
||
|
|
)
|
||
|
|
) == "finance_confirmed"
|
||
|
|
connection.execute(
|
||
|
|
realization_insert,
|
||
|
|
{
|
||
|
|
"id": "valid-reversal",
|
||
|
|
"opportunity_id": "opportunity-a",
|
||
|
|
"realization_type": "reversal",
|
||
|
|
"reversal_of_realization_id": "realization-a",
|
||
|
|
"actual_gross": -5,
|
||
|
|
"actual_net": -5,
|
||
|
|
"reporting_amount": -5,
|
||
|
|
"benefit_key": "benefit-a",
|
||
|
|
"dedupe_status": "canonical",
|
||
|
|
"canonical_realization_id": None,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
UPDATE savings_realizations
|
||
|
|
SET status = 'finance_confirmed',
|
||
|
|
finance_confirmer_id = recorded_by_id,
|
||
|
|
finance_confirmer_name = '冲回财务',
|
||
|
|
confirmed_at = now(), confirmation_note = '全额冲回'
|
||
|
|
WHERE id = 'valid-reversal'
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
connection.execute(
|
||
|
|
text(
|
||
|
|
"""
|
||
|
|
INSERT INTO savings_events (
|
||
|
|
id, tenant_id, aggregate_type, aggregate_id,
|
||
|
|
opportunity_id, action, actor_id, actor_name, actor_type,
|
||
|
|
request_id, expected_version, result_version,
|
||
|
|
payload_fingerprint
|
||
|
|
) VALUES (
|
||
|
|
'event-a', 'tenant-a', 'opportunity', 'opportunity-a',
|
||
|
|
'opportunity-a', 'created', 'system', '系统', 'system',
|
||
|
|
'request-a', 0, 1, 'fingerprint-a'
|
||
|
|
)
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
text("UPDATE savings_events SET action = 'tampered' WHERE id = 'event-a'"),
|
||
|
|
{},
|
||
|
|
DBAPIError,
|
||
|
|
)
|
||
|
|
execute_rejected(
|
||
|
|
connection,
|
||
|
|
text("DELETE FROM savings_events WHERE id = 'event-a'"),
|
||
|
|
{},
|
||
|
|
DBAPIError,
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
transaction.rollback()
|