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, )