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