feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -0,0 +1,530 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.db.base import Base
from app.models.agent_run import AgentRun, AgentToolCall
from app.models.budget import BudgetAllocation
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.services.finance_dashboard import FinanceDashboardService
from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy
from app.services.finance_dashboard_scope import resolve_finance_dashboard_data_scope
from app.services.finance_dashboard_snapshot import (
FINANCE_DASHBOARD_TASK_TYPE,
FinanceDashboardSnapshotService,
)
def _session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _claim(
*,
claim_id: str,
claim_no: str,
amount: str,
tenant_id: str = "default",
) -> ExpenseClaim:
now = datetime.now(UTC)
return ExpenseClaim(
id=claim_id,
tenant_id=tenant_id,
claim_no=claim_no,
employee_name=f"employee-{claim_id}",
department_name="财务部",
expense_type="travel",
reason="tenant scope test",
location="上海",
amount=Decimal(amount),
invoice_count=1,
occurred_at=now - timedelta(hours=2),
submitted_at=now - timedelta(hours=1),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(minutes=30),
)
def _link_claim(db: Session, claim: ExpenseClaim, *, tenant_id: str) -> None:
expense_case = ExpenseCase(
id=f"case-{claim.id}",
tenant_id=tenant_id,
case_no=f"CASE-{claim.claim_no}",
scene_code="travel",
title=f"{claim.claim_no} case",
current_stage="completed",
status="completed",
)
db.add(expense_case)
db.flush()
db.add(
ExpenseCaseLink(
tenant_id=tenant_id,
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim.id,
relation_type="reimbursement",
)
)
def _seed_tenant_claim(
db: Session,
*,
tenant_id: str,
claim_id: str,
claim_no: str,
amount: str,
) -> ExpenseClaim:
claim = _claim(
claim_id=claim_id,
claim_no=claim_no,
amount=amount,
tenant_id=tenant_id,
)
db.add(claim)
db.flush()
_link_claim(db, claim, tenant_id=tenant_id)
return claim
def _seed_finance_snapshot_run(
db: Session,
*,
run_id: str,
tenant_id: str | None,
amount: str,
route_data_scope: str | None = None,
) -> None:
route_json = {
"task_type": FINANCE_DASHBOARD_TASK_TYPE,
"snapshot_key": f"snapshot-{run_id}",
"snapshot_payload": {
"tenant_marker": tenant_id or "legacy-unscoped",
"amount": amount,
},
}
ontology_json = {"scenario": "finance_dashboard"}
if tenant_id is not None:
expected_scope = resolve_finance_dashboard_data_scope(tenant_id)
route_json["tenant_id"] = tenant_id
route_json["data_scope"] = route_data_scope or expected_scope
ontology_json["tenant_id"] = tenant_id
ontology_json["data_scope"] = expected_scope
db.add(
AgentRun(
run_id=run_id,
agent="hermes",
source="system_event",
user_id="digital_employee",
ontology_json=ontology_json,
route_json=route_json,
permission_level="read",
status="succeeded",
result_summary=f"sensitive amount {amount}",
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
)
)
db.flush()
db.add(
AgentToolCall(
run_id=run_id,
tool_type="database",
tool_name="digital_employee.finance_dashboard.snapshot",
request_json={"tenant_id": tenant_id, "amount": amount},
response_json={"secret_amount": amount},
status="succeeded",
duration_ms=1,
)
)
def test_finance_dashboard_isolates_claims_and_hides_legacy_budget_outside_default() -> None:
now = datetime.now(UTC)
session_factory = _session_factory()
with session_factory() as db:
db.add(_claim(claim_id="legacy-default", claim_no="CLM-LEGACY-001", amount="100.00"))
_seed_tenant_claim(
db,
tenant_id="tenant-a",
claim_id="claim-a",
claim_no="CLM-TENANT-A-001",
amount="200.00",
)
_seed_tenant_claim(
db,
tenant_id="tenant-b",
claim_id="claim-b",
claim_no="CLM-TENANT-B-001",
amount="300.00",
)
db.add(
BudgetAllocation(
budget_no="BUD-LEGACY-DEFAULT-001",
fiscal_year=now.year,
period_type="year",
period_key=str(now.year),
department_name="财务部",
subject_code="travel",
subject_name="差旅费",
original_amount=Decimal("10000.00"),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="warn",
)
)
db.commit()
default_dashboard = FinanceDashboardService(db).build_dashboard()
tenant_a_dashboard = FinanceDashboardService(
db,
tenant_id="tenant-a",
).build_dashboard()
tenant_b_dashboard = FinanceDashboardService(
db,
tenant_id="tenant-b",
).build_dashboard()
assert default_dashboard.totals["reimbursementCount"] == 1
assert default_dashboard.totals["reimbursementAmount"] == 100.0
assert default_dashboard.budget_summary["included"] is True
assert default_dashboard.budget_summary["total"] == "¥10,000"
assert tenant_a_dashboard.totals["reimbursementCount"] == 1
assert tenant_a_dashboard.totals["reimbursementAmount"] == 200.0
assert "CLM-TENANT-B-001" not in str(tenant_a_dashboard.top_claims)
assert tenant_a_dashboard.budget_summary == {
"ratio": 0.0,
"total": "¥0",
"used": "¥0",
"left": "¥0",
"included": False,
"scope": "unavailable",
"reason": "当前租户尚未接入独立预算池,预算指标未纳入统计。",
}
assert tenant_a_dashboard.totals["budgetUsageRate"] == 0.0
assert all(
metric["detail"] == "当前租户未接入独立预算池" and metric["tone"] == "neutral"
for metric in tenant_a_dashboard.budget_metrics
)
assert "预算超支" not in {item["name"] for item in tenant_a_dashboard.bottlenecks}
assert tenant_b_dashboard.totals["reimbursementCount"] == 1
assert tenant_b_dashboard.totals["reimbursementAmount"] == 300.0
assert tenant_b_dashboard.budget_summary["included"] is False
def test_finance_dashboard_uses_structured_claim_tenant_when_legacy_link_disagrees() -> None:
session_factory = _session_factory()
with session_factory() as db:
claim = _claim(
tenant_id="tenant-a",
claim_id="structured-tenant-claim",
claim_no="CLM-STRUCTURED-TENANT-001",
amount="456.00",
)
db.add(claim)
db.flush()
# Case Link 是历史关联索引,不能覆盖 Claim 自身的结构化租户归属。
_link_claim(db, claim, tenant_id="tenant-b")
db.commit()
tenant_a_dashboard = FinanceDashboardService(
db,
tenant_id="tenant-a",
).build_dashboard()
tenant_b_dashboard = FinanceDashboardService(
db,
tenant_id="tenant-b",
).build_dashboard()
assert tenant_a_dashboard.totals["reimbursementCount"] == 1
assert tenant_a_dashboard.totals["reimbursementAmount"] == 456.0
assert tenant_b_dashboard.totals["reimbursementCount"] == 0
def test_finance_dashboard_snapshot_cache_is_partitioned_by_tenant_and_data_scope() -> None:
session_factory = _session_factory()
with session_factory() as db:
_seed_tenant_claim(
db,
tenant_id="tenant-a",
claim_id="snapshot-claim-a",
claim_no="CLM-SNAPSHOT-A-001",
amount="880.00",
)
_seed_tenant_claim(
db,
tenant_id="tenant-b",
claim_id="snapshot-claim-b",
claim_no="CLM-SNAPSHOT-B-001",
amount="990.00",
)
db.commit()
tenant_a_service = FinanceDashboardSnapshotService(db, tenant_id="tenant-a")
tenant_b_service = FinanceDashboardSnapshotService(db, tenant_id="tenant-b")
first_a = tenant_a_service.build_dashboard()
first_b = tenant_b_service.build_dashboard()
second_a = tenant_a_service.build_dashboard()
runs = list(
db.scalars(
select(AgentRun).where(
AgentRun.route_json["task_type"].as_string() == FINANCE_DASHBOARD_TASK_TYPE
)
).all()
)
routes = [run.route_json or {} for run in runs]
assert first_a.totals["reimbursementAmount"] == 880.0
assert first_b.totals["reimbursementAmount"] == 990.0
assert second_a.generated_at == first_a.generated_at
assert len(runs) == 2
assert {route["tenant_id"] for route in routes} == {"tenant-a", "tenant-b"}
assert all(route["data_scope"] for route in routes)
assert len({route["snapshot_key"] for route in routes}) == 2
assert all(route["tenant_id"] in route["snapshot_key"] for route in routes)
def test_default_scheduler_snapshot_rejects_non_default_tenant_scope() -> None:
session_factory = _session_factory()
with session_factory() as db:
service = FinanceDashboardSnapshotService(db, tenant_id="tenant-a")
with pytest.raises(ValueError, match="default 系统租户"):
service.refresh_default_snapshot()
@pytest.mark.parametrize("tenant_id", ["", " "])
def test_finance_dashboard_access_policy_rejects_missing_tenant_scope(
tenant_id: str,
) -> None:
current_user = CurrentUserContext(
username="finance-without-tenant",
name="Finance Without Tenant",
role_codes=["finance"],
is_admin=False,
tenant_id=tenant_id,
)
assert FinanceDashboardAccessPolicy.can_read(current_user) is False
with pytest.raises(HTTPException) as exc_info:
FinanceDashboardAccessPolicy.require_read(current_user)
assert exc_info.value.status_code == 403
def test_finance_dashboard_endpoint_requires_finance_read_role_and_passes_tenant() -> None:
session_factory = _session_factory()
with session_factory() as db:
_seed_tenant_claim(
db,
tenant_id="tenant-a",
claim_id="endpoint-claim-a",
claim_no="CLM-ENDPOINT-A-001",
amount="1234.00",
)
db.commit()
app = FastAPI()
app.include_router(analytics_router)
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
ordinary_response = client.get(
"/analytics/finance-dashboard",
headers={
"X-Auth-Username": "ordinary",
"X-Auth-Role-Codes": "user",
"X-Auth-Tenant-Id": "tenant-a",
},
)
budget_monitor_response = client.get(
"/analytics/finance-dashboard",
headers={
"X-Auth-Username": "budget-monitor",
"X-Auth-Role-Codes": "budget_monitor",
"X-Auth-Tenant-Id": "tenant-a",
},
)
finance_response = client.get(
"/analytics/finance-dashboard",
headers={
"X-Auth-Username": "finance",
"X-Auth-Role-Codes": "finance",
"X-Auth-Tenant-Id": "tenant-a",
},
)
executive_other_tenant_response = client.get(
"/analytics/finance-dashboard",
headers={
"X-Auth-Username": "executive",
"X-Auth-Role-Codes": "executive",
"X-Auth-Tenant-Id": "tenant-b",
},
)
admin_response = client.get(
"/analytics/finance-dashboard",
headers={
"X-Auth-Username": "admin-reader",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-b",
},
)
assert ordinary_response.status_code == 403
assert budget_monitor_response.status_code == 403
assert finance_response.status_code == 200
assert finance_response.json()["totals"]["reimbursementAmount"] == 1234.0
assert executive_other_tenant_response.status_code == 200
assert executive_other_tenant_response.json()["totals"]["reimbursementCount"] == 0
assert admin_response.status_code == 200
def test_agent_run_endpoint_cannot_bypass_finance_snapshot_tenant_and_role_scope() -> None:
session_factory = _session_factory()
with session_factory() as db:
_seed_finance_snapshot_run(
db,
run_id="run-finance-tenant-a",
tenant_id="tenant-a",
amount="111.00",
)
_seed_finance_snapshot_run(
db,
run_id="run-finance-tenant-b",
tenant_id="tenant-b",
amount="222.00",
)
_seed_finance_snapshot_run(
db,
run_id="run-finance-legacy-unscoped",
tenant_id=None,
amount="999.00",
)
_seed_finance_snapshot_run(
db,
run_id="run-finance-corrupt-scope",
tenant_id="tenant-a",
amount="777.00",
route_data_scope="claims:corrupt;budget:corrupt",
)
db.commit()
app = FastAPI()
app.include_router(agent_runs_router)
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
finance_a_headers = {
"X-Auth-Username": "finance-a",
"X-Auth-Role-Codes": "finance",
"X-Auth-Tenant-Id": "tenant-a",
}
finance_b_headers = {
"X-Auth-Username": "finance-b",
"X-Auth-Role-Codes": "finance",
"X-Auth-Tenant-Id": "tenant-b",
}
finance_a_list = client.get("/agent-runs", headers=finance_a_headers)
ordinary_a_list = client.get(
"/agent-runs",
headers={
"X-Auth-Username": "ordinary-a",
"X-Auth-Role-Codes": "user",
"X-Auth-Tenant-Id": "tenant-a",
},
)
assert finance_a_list.status_code == 200
assert [item["run_id"] for item in finance_a_list.json()] == ["run-finance-tenant-a"]
assert ordinary_a_list.status_code == 200
assert ordinary_a_list.json() == []
allowed_detail = client.get(
"/agent-runs/run-finance-tenant-a",
headers=finance_a_headers,
)
assert allowed_detail.status_code == 200
assert allowed_detail.json()["route_json"]["snapshot_payload"]["amount"] == "111.00"
assert allowed_detail.json()["tool_calls"][0]["response_json"] == {"secret_amount": "111.00"}
cross_tenant_detail = client.get(
"/agent-runs/run-finance-tenant-a",
headers=finance_b_headers,
)
cross_tenant_admin_detail = client.get(
"/agent-runs/run-finance-tenant-a",
headers={
"X-Auth-Username": "admin-b",
"X-Auth-Is-Admin": "true",
"X-Auth-Tenant-Id": "tenant-b",
},
)
same_tenant_ordinary_detail = client.get(
"/agent-runs/run-finance-tenant-a",
headers={
"X-Auth-Username": "ordinary-a",
"X-Auth-Role-Codes": "user",
"X-Auth-Tenant-Id": "tenant-a",
},
)
legacy_unscoped_detail = client.get(
"/agent-runs/run-finance-legacy-unscoped",
headers={
"X-Auth-Username": "finance-default",
"X-Auth-Role-Codes": "finance",
"X-Auth-Tenant-Id": "default",
},
)
corrupt_scope_detail = client.get(
"/agent-runs/run-finance-corrupt-scope",
headers=finance_a_headers,
)
assert cross_tenant_detail.status_code == 404
assert cross_tenant_admin_detail.status_code == 404
assert same_tenant_ordinary_detail.status_code == 403
assert legacy_unscoped_detail.status_code == 404
assert corrupt_scope_detail.status_code == 404