467 lines
15 KiB
Python
467 lines
15 KiB
Python
|
|
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,
|
||
|
|
)
|