from __future__ import annotations import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from sqlalchemy import create_engine 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.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.cfo_value import CfoValueFiltersRead from app.schemas.savings import ( SavingsEvidenceCreate, SavingsRealizationActionCreate, SavingsRealizationCreate, ) from app.services.cfo_value_analytics import CfoValueAnalyticsService from app.services.savings_access_policy import SavingsPermissionError from app.services.savings_discovery import SavingsDiscoveryService 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_cfo_value_counts_only_confirmed_canonical_and_replays_as_of(db: Session) -> None: operator = _user("finance-operator", roles=["finance"]) confirmer = _user("finance-confirmer", roles=["finance"]) cny_opportunity = _discover(db, operator, currency="CNY", saving=Decimal("200")) usd_opportunity = _discover(db, operator, currency="USD", saving=Decimal("50")) db.commit() cny_realization = ( SavingsRealizationService(db) .record( cny_opportunity, _record_payload("record-cny-001", Decimal("200"), "CNY"), operator, ) .response.realization ) usd_realization = ( SavingsRealizationService(db) .record( usd_opportunity, _record_payload("record-usd-001", Decimal("50"), "USD"), operator, ) .response.realization ) start = datetime.now(UTC) - timedelta(days=2) end = datetime.now(UTC) + timedelta(days=2) pending_dashboard = CfoValueAnalyticsService(db).build_dashboard( confirmer, start=start, end=end, as_of=datetime.now(UTC), filters=CfoValueFiltersRead(), ) assert pending_dashboard.kpis.verified_cash.status == "empty" assert pending_dashboard.data_quality.pending_confirmation_count == 2 assert _stage(pending_dashboard, "actual_pending").count == 2 confirmed_cny = ( SavingsRealizationService(db) .execute_action( cny_realization.id, SavingsRealizationActionCreate( action="confirm", request_id="confirm-cny-001", expected_version=1, comment="独立复核 CNY 结果与归因", ), confirmer, ) .response ) SavingsRealizationService(db).execute_action( usd_realization.id, SavingsRealizationActionCreate( action="confirm", request_id="confirm-usd-001", expected_version=1, comment="独立复核 USD 结果与归因", ), confirmer, ) confirmed_as_of = confirmed_cny.event.occurred_at current = CfoValueAnalyticsService(db).build_dashboard( confirmer, start=start, end=end, as_of=datetime.now(UTC), filters=CfoValueFiltersRead(), ) assert _money(current.kpis.verified_cash.values) == { "CNY": Decimal("200.0000"), "USD": Decimal("50.0000"), } assert current.kpis.releasable_labor.status == "collecting" assert current.kpis.safe_straight_through.status == "collecting" assert ( next( item for item in current.guardrails if item.key == "confirmed_high_risk_exposure" ).status == "unavailable" ) SavingsRealizationService(db).execute_action( cny_realization.id, SavingsRealizationActionCreate( action="reverse", request_id="reverse-cny-001", expected_version=2, comment="补付后全额冲回 CNY 节省", reversal_amount=Decimal("200"), ), confirmer, ) latest = CfoValueAnalyticsService(db).build_dashboard( confirmer, start=start, end=end, as_of=datetime.now(UTC), filters=CfoValueFiltersRead(), ) assert _money(latest.kpis.verified_cash.values) == { "CNY": Decimal("0.0000"), "USD": Decimal("50.0000"), } assert _stage(latest, "reversed").count == 1 historical = CfoValueAnalyticsService(db).build_dashboard( confirmer, start=start, end=end, as_of=confirmed_as_of, filters=CfoValueFiltersRead(), ) assert _money(historical.kpis.verified_cash.values)["CNY"] == Decimal("200.0000") with pytest.raises(SavingsPermissionError): CfoValueAnalyticsService(db).build_dashboard( _user("employee"), start=start, end=end, as_of=datetime.now(UTC), filters=CfoValueFiltersRead(), ) def test_cfo_value_budget_monitor_is_limited_to_own_department(db: Session) -> None: operator = _user("finance-operator", roles=["finance"]) _discover( db, operator, currency="CNY", saving=Decimal("100"), department_name="销售部", ) _discover( db, operator, currency="CNY", saving=Decimal("300"), department_name="研发部", ) db.commit() dashboard = CfoValueAnalyticsService(db).build_dashboard( _user("budget-sales", roles=["budget_monitor"], department_name="销售部"), start=datetime.now(UTC) - timedelta(days=1), end=datetime.now(UTC) + timedelta(days=1), as_of=datetime.now(UTC), filters=CfoValueFiltersRead(), ) assert dashboard.source.opportunity_count == 1 assert _money(_stage(dashboard, "estimated").values) == { "CNY": Decimal("100.0000") } def _discover( db: Session, current_user: CurrentUserContext, *, currency: str, saving: Decimal, department_name: str = "销售部", ) -> str: original = Decimal("1000") target = original - saving claim = ExpenseClaim( id=str(uuid.uuid4()), claim_no=f"BX-{uuid.uuid4().hex[:12]}", employee_name="测试员工", department_name=department_name, project_code="PROJECT-CFO", expense_type="hotel", reason="客户现场差旅", location="上海", amount=original, currency=currency, invoice_count=1, occurred_at=datetime.now(UTC), status="draft", 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=original, ) db.add(claim) db.flush() return ( SavingsDiscoveryService(db) .discover_standard_adjustments( claim=claim, items_by_id={item.id: item}, adjustment_flags=[ { "item_id": item.id, "message": "CFO 聚合测试政策差额", "original_amount": str(original), "reimbursable_amount": str(target), "employee_absorbed_amount": str(saving), "policy_rule_version": f"cfo-{currency.lower()}-v1", "policy_grade": "P6", "policy_matched_city": "上海", "calculation_fingerprint": "sha256:" + uuid.uuid4().hex * 2, } ], current_user=current_user, request_id=f"discover-{currency.lower()}-{uuid.uuid4().hex[:8]}", )[0] .id ) def _record_payload( request_id: str, amount: Decimal, currency: str, ) -> SavingsRealizationCreate: return SavingsRealizationCreate( request_id=request_id, expected_version=1, comment="登记待独立确认的实际结果", actual_gross=amount, incremental_cost=Decimal("0"), currency=currency, realized_at=datetime.now(UTC), attribution_method="server_policy_counterfactual", attribution_ratio=Decimal("1"), evidence_level="business_state", evidence=[ SavingsEvidenceCreate( evidence_key=f"evidence-{request_id}", evidence_role="payment_business_state", resource_type="business_event", resource_id=f"payment-{request_id}", source_system="x-financial", external_event_id=f"payment-{request_id}", content_hash="b" * 64, occurred_at=datetime.now(UTC), verification_status="unverified", metadata_json={"source": "cfo-test"}, ) ], ) def _user( username: str, *, roles: list[str] | None = None, department_name: str = "", ) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=list(roles or []), is_admin=False, tenant_id="default", employee_id=username, department_name=department_name, ) def _money(items) -> dict[str, Decimal]: return {item.currency: item.amount for item in items} def _stage(dashboard, key: str): return next(item for item in dashboard.funnel.stages if item.key == key)