from __future__ import annotations import uuid from collections.abc import Generator from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from fastapi import FastAPI from fastapi.testclient import TestClient 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, get_current_user, get_db from app.api.v1.endpoints.savings import router from app.db.base_class import Base from app.models.budget import BudgetAllocation, BudgetTransaction from app.models.expense_case import BusinessEvent, ExpenseCaseLink from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.savings import ( ProfileBaselineSnapshot, SavingsEvent, SavingsEvidenceLink, SavingsOpportunity, ) from app.schemas.savings_insights import ( SavingsBaselineGenerateRequest, SavingsInsightAnalyzeRequest, ) from app.services.expense_cases import ExpenseCaseService from app.services.savings_access_policy import SavingsPermissionError from app.services.savings_baseline_generation import SavingsBaselineGenerationService from app.services.savings_insight_analysis import SavingsInsightAnalysisService @pytest.fixture() def db() -> Generator[Session, None, 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) with factory() as session: yield session Base.metadata.drop_all(engine) engine.dispose() def test_baselines_cover_six_dimensions_replay_and_exclude_other_tenant( db: Session, ) -> None: for index in range(5): _seed_claim( db, tenant_id="tenant-a", suffix=f"A-{index}", amount=Decimal("50.00"), occurred_on=date(2026, 6, index + 1), ) _seed_claim( db, tenant_id="tenant-b", suffix=f"B-{index}", amount=Decimal("900.00"), occurred_on=date(2026, 6, index + 1), workflow_elapsed_minutes=600, ) application = _seed_claim( db, tenant_id="tenant-a", suffix="APP-EXCLUDED", amount=Decimal("5000.00"), occurred_on=date(2026, 6, 10), ) application.claim_no = f"AP-{uuid.uuid4().hex[:8]}" application.expense_type = "application" application.approval_stage = "申请归档" application.status = "approved" db.commit() request = _baseline_request("baseline-request-tenant-a") user = _user("finance-a", tenant_id="tenant-a", roles=["finance"]) first = SavingsBaselineGenerationService(db).generate(request, user) snapshot_ids = {row.id for row in first.snapshots} assert first.replayed is False assert first.source_claim_count == 5 assert first.source_item_count == 5 assert first.source_workflow_cycle_count == 5 assert {row.dimension_type for row in first.snapshots} == { "employee", "department", "expense_type", "city", "project", "workflow", } amount_snapshots = [row for row in first.snapshots if row.dimension_type != "workflow"] workflow_snapshot = next(row for row in first.snapshots if row.dimension_type == "workflow") assert {row.baseline_value for row in amount_snapshots} == {Decimal("50.0000")} assert workflow_snapshot.baseline_value == Decimal("60.0000") assert workflow_snapshot.metric_key == "median_submission_to_payment_elapsed_minutes" assert workflow_snapshot.unit == "minutes" assert workflow_snapshot.original_currency is None assert all(row.sample_count == 5 for row in first.snapshots) assert all(row.data_quality_status == "complete" for row in first.snapshots) assert all( row.method == "median_archived_expense_facts_tenant_scope" for row in amount_snapshots ) assert workflow_snapshot.method == "median_completed_workflow_elapsed_tenant_scope" assert any(issue.code == "supplier_dimension_unavailable" for issue in first.quality_issues) assert any(issue.code == "workflow_active_labor_unavailable" for issue in first.quality_issues) assert db.scalar(select(func.count(SavingsEvidenceLink.id))) == 6 assert db.scalar(select(func.count(SavingsEvent.id))) == 6 replay = SavingsBaselineGenerationService(db).generate(request, user) assert replay.replayed is True assert {row.id for row in replay.snapshots} == snapshot_ids assert db.scalar(select(func.count(ProfileBaselineSnapshot.id))) == 6 assert db.scalar(select(func.count(SavingsEvent.id))) == 6 stricter = SavingsBaselineGenerationService(db).generate( request.model_copy( update={ "request_id": "baseline-request-stricter-threshold", "minimum_complete_samples": 10, } ), user, ) assert {row.id for row in stricter.snapshots}.isdisjoint(snapshot_ids) assert all(row.data_quality_status == "partial" for row in stricter.snapshots) def test_scoped_baseline_only_uses_authorized_department(db: Session) -> None: _seed_claim( db, tenant_id="tenant-a", suffix="SCOPE-A", amount=Decimal("80.00"), occurred_on=date(2026, 6, 1), department_name="研发部", ) _seed_claim( db, tenant_id="tenant-a", suffix="SCOPE-B", amount=Decimal("800.00"), occurred_on=date(2026, 6, 2), department_name="销售部", ) db.commit() result = SavingsBaselineGenerationService(db).generate( _baseline_request( "baseline-scoped-request", dimensions=["department", "expense_type"], ), _user( "budget-user", tenant_id="tenant-a", roles=["budget_monitor"], department_name="研发部", ), ) assert result.data_scope == "department" assert result.source_claim_count == 1 assert {row.baseline_value for row in result.snapshots} == {Decimal("80.0000")} assert all( row.method == "median_archived_expense_facts_department_scope" for row in result.snapshots ) assert all(row.data_quality_status == "insufficient" for row in result.snapshots) assert all( any(issue["code"] == "baseline_sample_insufficient" for issue in row.quality_issues_json) for row in result.snapshots ) assert db.scalar(select(func.count(SavingsOpportunity.id))) == 0 assert {row.dimension_id for row in result.snapshots if row.dimension_type == "department"} == { "name:研发部" } def test_workflow_baseline_is_department_scoped_and_excludes_future_completion( db: Session, ) -> None: _seed_claim( db, tenant_id="tenant-a", suffix="WORKFLOW-SCOPE-A", amount=Decimal("80.00"), occurred_on=date(2026, 6, 1), department_name="研发部", workflow_elapsed_minutes=90, ) _seed_claim( db, tenant_id="tenant-a", suffix="WORKFLOW-SCOPE-B", amount=Decimal("800.00"), occurred_on=date(2026, 6, 2), department_name="销售部", workflow_elapsed_minutes=900, ) _seed_claim( db, tenant_id="tenant-a", suffix="WORKFLOW-FUTURE", amount=Decimal("100.00"), occurred_on=date(2026, 6, 3), department_name="研发部", payment_completed_at=datetime(2026, 7, 2, tzinfo=UTC), ) db.commit() result = SavingsBaselineGenerationService(db).generate( _baseline_request( "workflow-scoped-request", dimensions=["workflow"], ), _user( "budget-user", tenant_id="tenant-a", roles=["budget_monitor"], department_name="研发部", ), ) assert result.data_scope == "department" assert result.source_claim_count == 2 assert result.source_workflow_cycle_count == 1 assert len(result.snapshots) == 1 assert result.snapshots[0].baseline_value == Decimal("90.0000") assert result.snapshots[0].sample_count == 1 assert result.snapshots[0].data_quality_status == "insufficient" assert all( evidence.metadata_json["metric_semantics"] == "elapsed_cycle_not_active_labor" for evidence in db.scalars(select(SavingsEvidenceLink)).all() ) def test_workflow_completion_event_must_belong_to_claim_case(db: Session) -> None: mismatched_claim = _seed_claim( db, tenant_id="tenant-a", suffix="WORKFLOW-WRONG-CASE", amount=Decimal("80.00"), occurred_on=date(2026, 6, 1), workflow_elapsed_minutes=30, ) valid_claim = _seed_claim( db, tenant_id="tenant-a", suffix="WORKFLOW-VALID-CASE", amount=Decimal("90.00"), occurred_on=date(2026, 6, 2), workflow_elapsed_minutes=120, ) valid_case_id = db.scalar( select(ExpenseCaseLink.expense_case_id).where( ExpenseCaseLink.resource_type == "expense_claim", ExpenseCaseLink.resource_id == valid_claim.id, ) ) mismatched_event = db.scalar( select(BusinessEvent).where(BusinessEvent.aggregate_id == mismatched_claim.id) ) assert valid_case_id and mismatched_event is not None mismatched_event.expense_case_id = valid_case_id db.commit() result = SavingsBaselineGenerationService(db).generate( _baseline_request("workflow-case-binding", dimensions=["workflow"]), _user("finance-a", tenant_id="tenant-a", roles=["finance"]), ) assert result.source_workflow_cycle_count == 1 assert result.snapshots[0].baseline_value == Decimal("120.0000") def test_analysis_returns_evidence_attribution_and_policy_candidates_without_monetizing( db: Session, ) -> None: for index in range(5): _seed_claim( db, tenant_id="default", suffix=f"BASE-{index}", amount=Decimal("50.00"), occurred_on=date(2026, 6, index + 1), ) db.commit() user = _user("finance", roles=["finance"]) baseline_result = SavingsBaselineGenerationService(db).generate( _baseline_request("baseline-for-insight"), user, ) assert baseline_result.snapshots for index in range(3): _seed_claim( db, tenant_id="default", suffix=f"OBS-{index}", amount=Decimal("100.00"), occurred_on=date(2026, 7, index + 2), ) _seed_overrun_budget(db) db.commit() before_count = db.scalar(select(func.count(SavingsOpportunity.id))) result = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="insight-analysis-request", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 1, tzinfo=UTC), small_amount_threshold=Decimal("200.00"), minimum_repeat_count=3, price_deviation_ratio=Decimal("1.2500"), ), user, ) insight_types = {candidate.insight_type for candidate in result.candidates} assert insight_types == { "budget_forecast_variance", "repeated_small_expense_pattern", "historical_price_deviation", "anomaly_driver_attribution", "policy_simulation_candidate", } assert result.source_claim_count == 3 assert all(candidate.evidence_sufficient_for_signal for candidate in result.candidates) assert all(candidate.evidence for candidate in result.candidates) assert all(candidate.estimated_savings is None for candidate in result.candidates) assert all( candidate.monetization_status == "withheld_no_counterfactual" for candidate in result.candidates ) budget_candidate = next( candidate for candidate in result.candidates if candidate.insight_type == "budget_forecast_variance" ) assert budget_candidate.currency is None assert any( issue.code == "budget_currency_unavailable" for issue in budget_candidate.quality_issues ) attribution = next( candidate for candidate in result.candidates if candidate.insight_type == "anomaly_driver_attribution" ) assert attribution.dimension_json["attribution_kind"] == ( "descriptive_concentration_not_causal" ) assert any( issue.code == "descriptive_attribution_not_causal" for issue in attribution.quality_issues ) policy_candidate = next( candidate for candidate in result.candidates if candidate.insight_type == "policy_simulation_candidate" ) assert policy_candidate.dimension_json["simulation_action"] == ( "run_versioned_policy_counterfactual" ) assert policy_candidate.dimension_json["write_mode"] == ("read_only_no_opportunity_creation") assert result.created_opportunity_ids == [] assert result.monetized_opportunity_count == 0 assert db.scalar(select(func.count(SavingsOpportunity.id))) == before_count assert any(issue.code == "supplier_price_drift_unavailable" for issue in result.quality_issues) replay = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="insight-analysis-request", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 1, tzinfo=UTC), small_amount_threshold=Decimal("200.00"), minimum_repeat_count=3, price_deviation_ratio=Decimal("1.2500"), ), user, ) assert replay.request_fingerprint == result.request_fingerprint assert [candidate.candidate_key for candidate in replay.candidates] == [ candidate.candidate_key for candidate in result.candidates ] assert db.scalar(select(func.count(SavingsOpportunity.id))) == before_count def test_budget_forecast_excludes_transactions_after_window_cutoff( db: Session, ) -> None: _seed_claim( db, tenant_id="default", suffix="BUDGET-CUTOFF", amount=Decimal("100.00"), occurred_on=date(2026, 7, 3), ) _seed_overrun_budget(db) transactions = list( db.scalars(select(BudgetTransaction).order_by(BudgetTransaction.created_at)).all() ) transactions[-1].created_at = datetime(2026, 8, 1, tzinfo=UTC) db.commit() result = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="budget-window-cutoff-request", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 2, tzinfo=UTC), ), _user("finance", roles=["finance"]), ) assert not any( candidate.insight_type == "budget_forecast_variance" for candidate in result.candidates ) assert any( issue.code == "budget_forecast_sample_insufficient" for issue in result.quality_issues ) def test_budget_forecast_excludes_allocation_modified_after_as_of( db: Session, ) -> None: _seed_overrun_budget(db) allocation = db.scalar(select(BudgetAllocation)) assert allocation is not None allocation.updated_at = datetime(2026, 8, 2, tzinfo=UTC) db.commit() result = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="budget-allocation-as-of-request", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 1, tzinfo=UTC), ), _user("finance", roles=["finance"]), ) assert not any( candidate.insight_type == "budget_forecast_variance" for candidate in result.candidates ) assert any(issue.code == "budget_allocation_unavailable" for issue in result.quality_issues) def test_non_default_tenant_never_reads_legacy_budget(db: Session) -> None: _seed_claim( db, tenant_id="tenant-a", suffix="NONDEFAULT", amount=Decimal("100.00"), occurred_on=date(2026, 7, 3), ) _seed_overrun_budget(db) db.commit() result = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="tenant-budget-boundary", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 1, tzinfo=UTC), ), _user("finance-a", tenant_id="tenant-a", roles=["finance"]), ) assert not any( candidate.insight_type == "budget_forecast_variance" for candidate in result.candidates ) assert any(issue.code == "tenant_budget_scope_unavailable" for issue in result.quality_issues) def test_analysis_does_not_time_travel_into_baseline_frozen_after_as_of( db: Session, ) -> None: for index in range(5): _seed_claim( db, tenant_id="tenant-a", suffix=f"TEMPORAL-BASE-{index}", amount=Decimal("50.00"), occurred_on=date(2026, 6, index + 1), ) db.commit() user = _user("finance-a", tenant_id="tenant-a", roles=["finance"]) generated = SavingsBaselineGenerationService(db).generate( _baseline_request("temporal-baseline-request"), user, ) assert generated.snapshots for snapshot in db.scalars(select(ProfileBaselineSnapshot)).all(): snapshot.frozen_at = datetime(2026, 8, 2, tzinfo=UTC) _seed_claim( db, tenant_id="tenant-a", suffix="TEMPORAL-OBSERVED", amount=Decimal("100.00"), occurred_on=date(2026, 7, 2), ) db.commit() result = SavingsInsightAnalysisService(db).analyze( SavingsInsightAnalyzeRequest( request_id="temporal-analysis-request", window_start=datetime(2026, 7, 1, tzinfo=UTC), window_end=datetime(2026, 7, 31, 23, 59, tzinfo=UTC), as_of=datetime(2026, 8, 1, tzinfo=UTC), ), user, ) assert not any( candidate.insight_type == "historical_price_deviation" for candidate in result.candidates ) assert any(issue.code == "historical_baseline_unavailable" for issue in result.quality_issues) def test_baseline_endpoint_enforces_savings_access_policy(db: Session) -> None: with pytest.raises(SavingsPermissionError): SavingsBaselineGenerationService(db).generate( _baseline_request("ordinary-user-request"), _user("ordinary"), ) def test_baseline_and_insight_http_contracts(db: Session) -> None: _seed_claim( db, tenant_id="default", suffix="HTTP", amount=Decimal("60.00"), occurred_on=date(2026, 6, 3), ) db.commit() app = FastAPI() app.include_router(router, prefix="/api/v1") user_box = {"current": _user("finance", roles=["finance"])} def override_db() -> Generator[Session, None, None]: yield db app.dependency_overrides[get_db] = override_db app.dependency_overrides[get_current_user] = lambda: user_box["current"] with TestClient(app) as client: baseline_response = client.post( "/api/v1/savings/baselines/generate", json=_baseline_request("http-baseline-request").model_dump(mode="json"), ) assert baseline_response.status_code == 200 assert baseline_response.json()["source_claim_count"] == 1 assert baseline_response.json()["snapshots"] insight_response = client.post( "/api/v1/savings/insights/analyze", json={ "request_id": "http-insight-request", "window_start": "2026-06-01T00:00:00Z", "window_end": "2026-06-30T23:59:00Z", "as_of": "2026-07-01T00:00:00Z", }, ) assert insight_response.status_code == 200 assert insight_response.json()["monetized_opportunity_count"] == 0 user_box["current"] = _user("ordinary") forbidden = client.post( "/api/v1/savings/baselines/generate", json=_baseline_request("http-forbidden-request").model_dump(mode="json"), ) assert forbidden.status_code == 403 def _seed_claim( db: Session, *, tenant_id: str, suffix: str, amount: Decimal, occurred_on: date, department_name: str = "研发部", workflow_elapsed_minutes: int = 60, payment_completed_at: datetime | None = None, ) -> ExpenseClaim: occurred_at = datetime.combine(occurred_on, datetime.min.time(), tzinfo=UTC) claim = ExpenseClaim( id=str(uuid.uuid4()), tenant_id=tenant_id, claim_no=f"BX-{suffix}-{uuid.uuid4().hex[:6]}", employee_name="张三", department_name=department_name, project_code="PROJECT-A", expense_type="taxi", reason="客户现场交通", location="上海", amount=amount, currency="CNY", invoice_count=1, occurred_at=occurred_at, submitted_at=occurred_at, status="paid", approval_stage="已付款", risk_flags_json=[], created_at=occurred_at, updated_at=occurred_at, ) item = ExpenseClaimItem( id=str(uuid.uuid4()), claim=claim, item_date=occurred_on, item_type="taxi", item_reason="客户现场交通", item_location="上海", item_note="", item_amount=amount, created_at=occurred_at, updated_at=occurred_at, ) db.add(claim) db.flush() expense_case = ExpenseCaseService(db).ensure_case_for_claim( claim, tenant_id=tenant_id, ) completion_time = payment_completed_at or ( occurred_at + timedelta(minutes=workflow_elapsed_minutes) ) expense_case.created_at = occurred_at expense_case.updated_at = completion_time event_id = str(uuid.uuid4()) db.add( BusinessEvent( id=event_id, tenant_id=tenant_id, expense_case_id=expense_case.id, aggregate_type="expense_claim", aggregate_id=claim.id, event_type="payment_completed", event_version=1, idempotency_key=f"payment:{event_id}", correlation_id=event_id, causation_id=None, actor_id="finance", actor_type="user", payload_json={"source": "test_business_fact"}, delivery_status="published", delivery_attempts=0, occurred_at=completion_time, published_at=completion_time, ) ) assert item.claim_id == claim.id return claim def _seed_overrun_budget(db: Session) -> None: allocation = BudgetAllocation( id=str(uuid.uuid4()), budget_no=f"BUD-{uuid.uuid4().hex[:8]}", fiscal_year=2026, period_type="quarter", period_key="2026Q3", department_name="研发部", cost_center="CC-100", project_code="PROJECT-A", subject_code="travel", subject_name="差旅费", original_amount=Decimal("500.00"), adjusted_amount=Decimal("0.00"), status="active", warning_threshold=Decimal("80.00"), control_action="warn", created_at=datetime(2026, 7, 1, tzinfo=UTC), updated_at=datetime(2026, 7, 1, tzinfo=UTC), ) db.add(allocation) db.flush() for index, created_at in enumerate( (datetime(2026, 7, 10, tzinfo=UTC), datetime(2026, 7, 20, tzinfo=UTC)) ): db.add( BudgetTransaction( id=str(uuid.uuid4()), transaction_no=f"BTX-{uuid.uuid4().hex[:8]}", allocation_id=allocation.id, source_type="claim", source_id=f"budget-source-{index}", source_no=f"BX-BUDGET-{index}", transaction_type="consume", amount=Decimal("150.00"), before_available_amount=Decimal("500.00") - Decimal("150.00") * index, after_available_amount=Decimal("350.00") - Decimal("150.00") * index, operator="finance", reason="已付款单据核销", context_json={}, created_at=created_at, ) ) def _baseline_request( request_id: str, *, dimensions: list[str] | None = None, ) -> SavingsBaselineGenerateRequest: return SavingsBaselineGenerateRequest( request_id=request_id, window_start=datetime(2026, 6, 1, tzinfo=UTC), window_end=datetime(2026, 6, 30, 23, 59, tzinfo=UTC), as_of=datetime(2026, 7, 1, tzinfo=UTC), dimensions=dimensions or [ "employee", "department", "expense_type", "city", "project", "workflow", "supplier", ], minimum_complete_samples=5, ) def _user( username: str, *, tenant_id: str = "default", 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=tenant_id, employee_id=username, department_name=department_name, )