Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
751 lines
25 KiB
Python
751 lines
25 KiB
Python
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,
|
|
)
|