Files
X-Financial/server/tests/test_savings_ledger_services.py

467 lines
15 KiB
Python
Raw Normal View History

from __future__ import annotations
import uuid
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.models # noqa: F401 - 注册完整 metadata
from app.api.deps import CurrentUserContext
from app.db.base_class import Base
from app.models.expense_case import BusinessEvent, ExpenseCase
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.savings import (
ProfileBaselineSnapshot,
SavingsEvent,
SavingsOpportunity,
SavingsRealization,
)
from app.schemas.savings import (
SavingsEvidenceCreate,
SavingsOpportunityActionCreate,
SavingsRealizationActionCreate,
SavingsRealizationCreate,
)
from app.services.savings_access_policy import SavingsPermissionError
from app.services.savings_actions import SavingsActionService
from app.services.savings_discovery import SavingsDiscoveryService
from app.services.savings_protocol import SavingsIdempotencyConflictError
from app.services.savings_query import SavingsQueryService
from app.services.savings_realization import SavingsRealizationService
@pytest.fixture()
def db() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as session:
yield session
Base.metadata.drop_all(engine)
engine.dispose()
def test_query_is_tenant_scoped_and_owner_scoped(db: Session) -> None:
first = _seed_opportunity(db, tenant_id="tenant-a", owner_id="owner-a")
_seed_opportunity(
db,
tenant_id="tenant-a",
owner_id="owner-b",
department_id="D-2",
)
_seed_opportunity(db, tenant_id="tenant-b", owner_id="owner-a")
db.commit()
finance_result = SavingsQueryService(db).list_opportunities(
_user("finance-a", tenant_id="tenant-a", roles=["finance"])
)
assert finance_result.total == 2
assert first.id in {item.id for item in finance_result.items}
owner_result = SavingsQueryService(db).list_opportunities(
_user("owner-a", tenant_id="tenant-a", employee_id="owner-a")
)
assert owner_result.total == 1
scoped_result = SavingsQueryService(db).list_opportunities(
_user(
"budget-a",
tenant_id="tenant-a",
roles=["budget_monitor"],
department_id="D-1",
)
)
assert scoped_result.total == 1
assert scoped_result.items[0].id == first.id
assert (
SavingsQueryService(db).get_opportunity(
first.id,
_user("outsider", tenant_id="tenant-b", roles=["finance"]),
)
is None
)
def test_opportunity_action_is_versioned_and_idempotent(db: Session) -> None:
opportunity = _seed_opportunity(db, status="identified", owner_id="owner-a")
db.commit()
owner = _user("owner-a", employee_id="owner-a")
payload = SavingsOpportunityActionCreate(
action="accept",
request_id="accept-request-001",
expected_version=1,
comment="确认接受该节省机会",
)
first = SavingsActionService(db).execute(opportunity.id, payload, owner)
replay = SavingsActionService(db).execute(opportunity.id, payload, owner)
assert first.response.opportunity.status == "accepted"
assert first.response.opportunity.version == 2
assert replay.response.replayed is True
assert db.scalar(
select(func.count(SavingsEvent.id)).where(
SavingsEvent.actor_id == "owner-a",
SavingsEvent.request_id == payload.request_id,
)
) == 1
with pytest.raises(SavingsIdempotencyConflictError):
SavingsActionService(db).execute(
opportunity.id,
payload.model_copy(update={"comment": "使用同一请求号篡改内容"}),
owner,
)
def test_actual_requires_independent_finance_confirmation_and_appends_reversal(
db: Session,
) -> None:
opportunity = _seed_opportunity(
db,
status="in_progress",
owner_id="owner-a",
accepted_at=datetime.now(UTC),
started_at=datetime.now(UTC),
)
db.commit()
owner = _user("owner-a", employee_id="owner-a")
record_payload = SavingsRealizationCreate(
request_id="record-request-001",
expected_version=1,
comment="付款完成后登记实际结果",
actual_gross=Decimal("100.00"),
incremental_cost=Decimal("10.00"),
currency="CNY",
realized_at=datetime.now(UTC),
attribution_method="server_policy_counterfactual",
attribution_ratio=Decimal("1"),
evidence_level="business_state",
evidence=[_result_evidence("record-request-001")],
)
recorded = SavingsRealizationService(db).record(
opportunity.id,
record_payload,
owner,
)
realization_id = recorded.response.realization.id
assert recorded.response.realization.status == "pending_confirmation"
assert recorded.response.opportunity.status == "realized"
confirm_payload = SavingsRealizationActionCreate(
action="confirm",
request_id="confirm-request-001",
expected_version=1,
comment="已复核政策、付款业务状态及归因",
)
with pytest.raises(SavingsPermissionError):
SavingsRealizationService(db).execute_action(
realization_id,
confirm_payload,
owner,
)
with pytest.raises(SavingsPermissionError):
SavingsRealizationService(db).execute_action(
realization_id,
confirm_payload,
_user("platform-admin", is_admin=True),
)
confirmer = _user("finance-b", employee_id="finance-b", roles=["finance"])
confirmed = SavingsRealizationService(db).execute_action(
realization_id,
confirm_payload,
confirmer,
)
assert confirmed.response.realization.status == "finance_confirmed"
assert confirmed.response.realization.dedupe_status == "canonical"
assert confirmed.response.realization.evidence_json[0]["verification_status"] == "verified"
assert confirmed.response.opportunity.status == "verified"
reversed_result = SavingsRealizationService(db).execute_action(
realization_id,
SavingsRealizationActionCreate(
action="reverse",
request_id="reverse-request-001",
expected_version=2,
comment="员工申诉补付,全额冲回原节省",
reversal_amount=Decimal("90.00"),
),
confirmer,
)
original = db.get(SavingsRealization, realization_id)
reversal = db.get(SavingsRealization, reversed_result.response.realization.id)
assert original is not None and original.status == "finance_confirmed"
assert original.reversed_at is not None
assert reversal is not None and reversal.realization_type == "reversal"
assert reversal.actual_net == Decimal("-90.0000")
assert reversed_result.response.opportunity.status == "reversed"
def test_payment_realization_is_idempotent_and_stays_pending_confirmation(
db: Session,
) -> None:
opportunity = _seed_opportunity(
db,
status="in_progress",
owner_id="finance",
accepted_at=datetime.now(UTC),
started_at=datetime.now(UTC),
)
claim = _seed_claim(db, claim_id=opportunity.claim_id)
payment_event = BusinessEvent(
id=str(uuid.uuid4()),
tenant_id="default",
expense_case_id=opportunity.expense_case_id,
aggregate_type="expense_claim",
aggregate_id=claim.id,
event_type="payment_completed",
event_version=1,
idempotency_key="payment-event-001",
correlation_id="payment-event-001",
actor_id="payer",
actor_type="user",
payload_json={},
delivery_status="pending",
occurred_at=datetime.now(UTC),
)
db.add(payment_event)
db.commit()
payer = _user("payer", employee_id="payer", roles=["finance"])
first = SavingsRealizationService(db).realize_paid_claim(
claim,
payment_event,
payer,
)
second = SavingsRealizationService(db).realize_paid_claim(
claim,
payment_event,
payer,
)
db.commit()
assert len(first) == 1
assert second == []
assert first[0].status == "pending_confirmation"
assert first[0].dedupe_status == "pending_review"
assert db.scalar(
select(func.count(SavingsRealization.id)).where(
SavingsRealization.opportunity_id == opportunity.id
)
) == 1
def test_standard_adjustment_discovery_freezes_baseline_and_evidence(db: Session) -> None:
claim = _seed_claim(db)
item = ExpenseClaimItem(
id=str(uuid.uuid4()),
claim_id=claim.id,
item_date=date(2026, 7, 10),
item_type="hotel",
item_reason="上海住宿 2 晚",
item_location="上海",
item_note="",
item_amount=Decimal("1200.00"),
)
db.add(item)
db.flush()
flag = {
"item_id": item.id,
"message": "服务端按政策把 1200 元调整为 800 元",
"original_amount": "1200.00",
"reimbursable_amount": "800.00",
"employee_absorbed_amount": "400.00",
"policy_rule_version": "v1.2.0",
"policy_rule_version_source": "published",
"policy_grade": "P6",
"policy_matched_city": "上海",
"calculation_fingerprint": "sha256:" + "a" * 64,
}
opportunities = SavingsDiscoveryService(db).discover_standard_adjustments(
claim=claim,
items_by_id={item.id: item},
adjustment_flags=[flag],
current_user=_user("employee-a", employee_id="employee-a"),
request_id="standard-adjustment-001",
)
replay = SavingsDiscoveryService(db).discover_standard_adjustments(
claim=claim,
items_by_id={item.id: item},
adjustment_flags=[flag],
current_user=_user("employee-a", employee_id="employee-a"),
request_id="standard-adjustment-001",
)
db.commit()
assert len(opportunities) == 1
assert replay[0].id == opportunities[0].id
assert opportunities[0].status == "in_progress"
assert opportunities[0].estimated_net == Decimal("400.0000")
assert opportunities[0].baseline_snapshot.policy_version == "v1.2.0"
assert opportunities[0].baseline_snapshot.baseline_value == Decimal("1200.0000")
assert opportunities[0].baseline_snapshot.data_quality_status == "complete"
assert len(opportunities[0].evidence_links) == 1
def _seed_opportunity(
db: Session,
*,
tenant_id: str = "default",
status: str = "identified",
owner_id: str = "finance",
accepted_at: datetime | None = None,
started_at: datetime | None = None,
department_id: str = "D-1",
) -> SavingsOpportunity:
now = datetime.now(UTC)
case = ExpenseCase(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
case_no=f"CASE-{uuid.uuid4().hex[:12]}",
scene_code="travel",
title="节省测试费用事件",
current_stage="claiming",
status="active",
created_at=now,
updated_at=now,
)
baseline = ProfileBaselineSnapshot(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
baseline_key=f"baseline-{uuid.uuid4()}",
baseline_type="policy_counterfactual",
dimension_type="expense_claim_item",
dimension_id=str(uuid.uuid4()),
metric_key="pre_adjustment_reimbursable_amount",
unit="currency",
original_currency="CNY",
baseline_value=Decimal("100.00"),
sample_count=1,
method="test_policy",
query_fingerprint="sha256:" + uuid.uuid4().hex,
data_quality_status="complete",
data_quality_score=Decimal("1"),
quality_issues_json=[],
algorithm_version="test-v1",
policy_version="policy-v1",
policy_effective_from=date(2026, 1, 1),
target_resource_type="expense_claim_item",
target_resource_id=str(uuid.uuid4()),
frozen_at=now,
frozen_by="test",
version=1,
created_at=now,
)
claim_id = str(uuid.uuid4())
opportunity = SavingsOpportunity(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
opportunity_key=f"opportunity-{uuid.uuid4()}",
benefit_key=f"benefit-{uuid.uuid4()}",
expense_case_id=case.id,
claim_id=claim_id,
claim_no_snapshot=f"BX-{uuid.uuid4().hex[:8]}",
source_type="standard_adjustment",
source_id=str(uuid.uuid4()),
category="policy_compliance",
value_kind="cash",
title="住宿标准重算",
description="测试机会",
exposure_amount=Decimal("100.00"),
baseline_snapshot_id=baseline.id,
baseline_amount=Decimal("100.00"),
target_amount=Decimal("0.00"),
estimated_gross=Decimal("100.00"),
estimated_cost=Decimal("0.00"),
estimated_net=Decimal("100.00"),
estimated_low=Decimal("100.00"),
estimated_high=Decimal("100.00"),
confidence=Decimal("1"),
currency="CNY",
reporting_currency="CNY",
attribution_method="server_policy_counterfactual",
suggested_action="完成付款后登记实际结果",
owner_id=owner_id,
owner_name=owner_id,
owner_role="finance",
status=status,
version=1,
dimension_json={"department_id": department_id, "city": "上海"},
baseline_snapshot_json={"baseline_value": "100.00"},
evidence_json=[],
accepted_at=accepted_at,
started_at=started_at,
created_at=now,
updated_at=now,
)
db.add_all([case, baseline, opportunity])
db.flush()
return opportunity
def _seed_claim(db: Session, *, claim_id: str | None = None) -> ExpenseClaim:
now = datetime.now(UTC)
claim = ExpenseClaim(
id=claim_id or str(uuid.uuid4()),
claim_no=f"BX-{uuid.uuid4().hex[:10]}",
employee_name="测试员工",
department_name="财务部",
project_code="P-001",
expense_type="travel",
reason="差旅",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=now,
status="draft",
risk_flags_json=[],
)
db.add(claim)
db.flush()
return claim
def _result_evidence(key: str) -> SavingsEvidenceCreate:
return SavingsEvidenceCreate(
evidence_key=f"evidence-{key}",
evidence_role="payment_business_state",
resource_type="business_event",
resource_id=f"payment-{key}",
source_system="x-financial",
external_event_id=f"payment-{key}",
content_hash="c" * 64,
occurred_at=datetime.now(UTC),
verification_status="unverified",
metadata_json={"source": "ledger-service-test"},
)
def _user(
username: str,
*,
tenant_id: str = "default",
employee_id: str = "",
roles: list[str] | None = None,
is_admin: bool = False,
department_id: str = "",
) -> CurrentUserContext:
return CurrentUserContext(
username=username,
name=username,
role_codes=list(roles or []),
is_admin=is_admin,
tenant_id=tenant_id,
employee_id=employee_id,
department_id=department_id,
)