Files
X-Financial/server/tests/test_savings_baseline_insights.py

751 lines
25 KiB
Python
Raw Normal View History

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