from __future__ import annotations import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal from sqlalchemy import create_engine, 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 from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.savings import SavingsEvidenceLink, SavingsRealization from app.schemas.cfo_value import CfoValueFiltersRead from app.schemas.savings import SavingsRealizationActionCreate from app.services.cfo_value_analytics import CfoValueAnalyticsService from app.services.expense_claims import ExpenseClaimService from app.services.savings_discovery import SavingsDiscoveryService from app.services.savings_realization import SavingsRealizationService def test_standard_adjustment_payment_confirmation_and_reversal_value_e2e() -> None: 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) try: with factory() as db: _run_value_e2e(db) finally: Base.metadata.drop_all(engine) engine.dispose() def _run_value_e2e(db: Session) -> None: payer = _finance_user("finance-payer") confirmer = _finance_user("finance-confirmer") claim = ExpenseClaim( id=str(uuid.uuid4()), claim_no="BX-SAVINGS-E2E-001", employee_name="测试员工", department_name="销售部", project_code="PROJECT-E2E", expense_type="hotel", reason="上海客户现场差旅", location="上海", amount=Decimal("66.00"), currency="CNY", invoice_count=1, occurred_at=datetime.now(UTC), submitted_at=datetime.now(UTC), status="pending_payment", approval_stage="待付款", risk_flags_json=[], ) item = ExpenseClaimItem( id=str(uuid.uuid4()), claim=claim, item_date=date.today(), item_type="hotel", item_reason="上海住宿", item_location="上海", item_note="", item_amount=Decimal("100.00"), ) db.add(claim) db.flush() opportunity = SavingsDiscoveryService(db).discover_standard_adjustments( claim=claim, items_by_id={item.id: item}, adjustment_flags=[ { "item_id": item.id, "message": "服务端按已发布住宿政策从 100 元调整为 66 元", "original_amount": "100.00", "reimbursable_amount": "66.00", "employee_absorbed_amount": "34.00", "policy_rule_version": "hotel-policy-e2e-v1", "policy_rule_version_source": "published", "policy_grade": "P6", "policy_matched_city": "上海", "calculation_fingerprint": "sha256:" + "d" * 64, } ], current_user=payer, request_id="e2e-discovery-001", )[0] db.commit() ExpenseClaimService(db).mark_claim_paid( claim.id, payer, request_id="e2e-payment-001", expected_status="pending_payment", expected_approval_stage="待付款", ) realization = db.scalar( select(SavingsRealization).where( SavingsRealization.opportunity_id == opportunity.id, SavingsRealization.realization_type == "actual", ) ) assert realization is not None assert realization.status == "pending_confirmation" assert realization.actual_net == Decimal("34.0000") assert _verified_cash(db, confirmer) == {} confirmed = SavingsRealizationService(db).execute_action( realization.id, SavingsRealizationActionCreate( action="confirm", request_id="e2e-confirm-001", expected_version=1, comment="独立复核政策基线、付款事件、归因和去重键。", ), confirmer, ) confirmed_as_of = confirmed.response.event.occurred_at assert _verified_cash(db, confirmer) == {"CNY": Decimal("34.0000")} evidence = list( db.scalars( select(SavingsEvidenceLink).where( SavingsEvidenceLink.realization_id == realization.id ) ).all() ) assert evidence and all(item.verification_status == "verified" for item in evidence) SavingsRealizationService(db).execute_action( realization.id, SavingsRealizationActionCreate( action="reverse", request_id="e2e-reverse-001", expected_version=2, comment="员工申诉后完成补付,全额冲回原节省。", reversal_amount=Decimal("34.00"), ), confirmer, ) assert _verified_cash(db, confirmer) == {"CNY": Decimal("0.0000")} assert _verified_cash(db, confirmer, as_of=confirmed_as_of) == { "CNY": Decimal("34.0000") } event_types = set( db.scalars( select(BusinessEvent.event_type).where( BusinessEvent.expense_case_id == opportunity.expense_case_id ) ).all() ) assert { "saving_opportunity_created", "payment_completed", "saving_action_completed", "saving_confirmed", "saving_reversed", } <= event_types def _verified_cash( db: Session, current_user: CurrentUserContext, *, as_of: datetime | None = None, ) -> dict[str, Decimal]: now = datetime.now(UTC) dashboard = CfoValueAnalyticsService(db).build_dashboard( current_user, start=now - timedelta(days=1), end=now + timedelta(days=1), as_of=as_of or now, filters=CfoValueFiltersRead(), ) return { item.currency: item.amount for item in dashboard.kpis.verified_cash.values } def _finance_user(username: str) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=["finance"], is_admin=False, tenant_id="default", employee_id=username, )