from __future__ import annotations import uuid from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal import pytest from fastapi import FastAPI from fastapi.testclient import TestClient 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, get_current_user, get_db from app.api.v1.endpoints.cfo_value import router as cfo_value_router from app.api.v1.endpoints.savings import router from app.db.base_class import Base from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.services.savings_discovery import SavingsDiscoveryService @pytest.fixture() def http_context() -> Generator[ tuple[TestClient, sessionmaker[Session], dict[str, CurrentUserContext]], 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) app = FastAPI() app.include_router(router, prefix="/api/v1") app.include_router(cfo_value_router, prefix="/api/v1") user_box = {"current": _user("finance-a", roles=["finance"])} def override_db() -> Generator[Session, None, None]: with factory() as db: yield db app.dependency_overrides[get_db] = override_db app.dependency_overrides[get_current_user] = lambda: user_box["current"] client = TestClient(app) try: yield client, factory, user_box finally: client.close() app.dependency_overrides.clear() Base.metadata.drop_all(engine) engine.dispose() def test_savings_http_list_detail_record_and_tenant_boundary( http_context: tuple[ TestClient, sessionmaker[Session], dict[str, CurrentUserContext], ], ) -> None: client, factory, user_box = http_context with factory() as db: opportunity_id = _seed_discovered_opportunity(db, user_box["current"]) db.commit() list_response = client.get("/api/v1/savings/opportunities") assert list_response.status_code == 200 assert list_response.json()["total"] == 1 assert list_response.json()["items"][0]["id"] == opportunity_id detail_response = client.get(f"/api/v1/savings/opportunities/{opportunity_id}") assert detail_response.status_code == 200 assert detail_response.json()["baseline"]["policy_version"] == "endpoint-policy-v1" assert len(detail_response.json()["evidence"]) == 1 record_response = client.post( f"/api/v1/savings/opportunities/{opportunity_id}/realizations", json={ "request_id": "endpoint-record-001", "expected_version": 1, "comment": "付款状态完成,登记待确认结果", "actual_gross": "200.00", "incremental_cost": "0.00", "currency": "CNY", "realized_at": datetime.now(UTC).isoformat(), "attribution_method": "server_policy_counterfactual", "attribution_ratio": "1.0", "evidence_level": "business_state", "evidence": [ { "evidence_key": "endpoint-payment-state-001", "evidence_role": "payment_business_state", "resource_type": "business_event", "resource_id": "payment-event-endpoint-001", "source_system": "x-financial", "external_event_id": "payment-event-endpoint-001", "content_hash": "a" * 64, "occurred_at": datetime.now(UTC).isoformat(), "verification_status": "unverified", "metadata_json": {"source": "endpoint-test"}, } ], }, ) assert record_response.status_code == 200 assert record_response.json()["realization"]["status"] == "pending_confirmation" assert record_response.json()["opportunity"]["status"] == "realized" cfo_response = client.get("/api/v1/analytics/cfo-value") assert cfo_response.status_code == 200 assert cfo_response.json()["kpis"]["verified_cash"]["status"] == "empty" assert cfo_response.json()["data_quality"]["pending_confirmation_count"] == 1 user_box["current"] = _user("ordinary-user") assert client.get("/api/v1/analytics/cfo-value").status_code == 403 user_box["current"] = _user("finance-other", tenant_id="tenant-b", roles=["finance"]) cross_tenant = client.get(f"/api/v1/savings/opportunities/{opportunity_id}") assert cross_tenant.status_code == 404 assert client.get("/api/v1/savings/opportunities").json()["total"] == 0 def _seed_discovered_opportunity( db: Session, current_user: CurrentUserContext, ) -> str: claim = ExpenseClaim( id=str(uuid.uuid4()), claim_no=f"BX-{uuid.uuid4().hex[:10]}", employee_name="测试员工", department_name="销售部", project_code="PROJECT-1", expense_type="hotel", reason="客户现场差旅", location="深圳", amount=Decimal("1000.00"), currency="CNY", invoice_count=1, occurred_at=datetime.now(UTC), status="draft", risk_flags_json=[], ) item = ExpenseClaimItem( id=str(uuid.uuid4()), claim=claim, item_date=date(2026, 7, 15), item_type="hotel", item_reason="深圳住宿", item_location="深圳", item_note="", item_amount=Decimal("1000.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": "服务端政策重算", "original_amount": "1000.00", "reimbursable_amount": "800.00", "employee_absorbed_amount": "200.00", "policy_rule_version": "endpoint-policy-v1", "policy_grade": "P6", "policy_matched_city": "深圳", "calculation_fingerprint": "sha256:" + "c" * 64, } ], current_user=current_user, request_id="endpoint-discovery-001", )[0] return opportunity.id def _user( username: str, *, tenant_id: str = "default", roles: list[str] | None = None, ) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=list(roles or []), is_admin=False, tenant_id=tenant_id, employee_id=username, )