Files
X-Financial/server/tests/test_expense_claim_tenant_scope.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

359 lines
12 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ExpenseClaimUpdate
from app.services.budget import BudgetService
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claims import ExpenseClaimService
from app.test_helpers.db import build_in_memory_session_factory
def _build_claim(
*,
claim_id: str,
claim_no: str,
tenant_id: str,
employee: Employee,
) -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
tenant_id=tenant_id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id=None,
department_name="租户隔离部",
project_code=None,
expense_type="office",
reason=f"{claim_no} 原始事由",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
def _build_employee(*, tenant_id: str, suffix: str, name: str, email: str) -> Employee:
return Employee(
id=f"tenant-{suffix}-employee",
tenant_id=tenant_id,
employee_no=f"TENANT-{suffix.upper()}-001",
name=name,
email=email,
)
def _current_user(tenant_id: str) -> CurrentUserContext:
return CurrentUserContext(
username="same-owner@example.com",
name="同名员工",
role_codes=["user"],
is_admin=False,
tenant_id=tenant_id,
)
def test_same_identity_claims_are_isolated_by_server_tenant() -> None:
session_factory = build_in_memory_session_factory()
with session_factory() as db:
employee_a = _build_employee(
tenant_id="tenant-a",
suffix="scope-a",
name="同名员工",
email="same-owner@example.com",
)
employee_b = _build_employee(
tenant_id="tenant-b",
suffix="scope-b",
name="同名员工",
email="same-owner@example.com",
)
default_employee = _build_employee(
tenant_id="default",
suffix="scope-default",
name="同名员工",
email="same-owner@example.com",
)
tenant_a_claim = _build_claim(
claim_id="tenant-a-claim",
claim_no="RE-TENANT-A",
tenant_id="tenant-a",
employee=employee_a,
)
tenant_b_claim = _build_claim(
claim_id="tenant-b-claim",
claim_no="RE-TENANT-B",
tenant_id="tenant-b",
employee=employee_b,
)
legacy_default_claim = _build_claim(
claim_id="legacy-default-claim",
claim_no="RE-LEGACY-DEFAULT",
tenant_id="default",
employee=default_employee,
)
db.add_all(
[
employee_a,
employee_b,
default_employee,
tenant_a_claim,
tenant_b_claim,
legacy_default_claim,
]
)
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
db.commit()
service = ExpenseClaimService(db)
tenant_a_user = _current_user("tenant-a")
tenant_b_user = _current_user("tenant-b")
default_user = _current_user("default")
assert {claim.id for claim in service.list_claims(tenant_a_user)} == {tenant_a_claim.id}
assert {claim.id for claim in service.list_claims(tenant_b_user)} == {tenant_b_claim.id}
assert {claim.id for claim in service.list_claims(default_user)} == {
legacy_default_claim.id
}
assert service.get_claim(tenant_b_claim.id, tenant_a_user) is None
assert service.get_claim(tenant_a_claim.id, tenant_b_user) is None
assert (
service.update_claim(
claim_id=tenant_b_claim.id,
payload=ExpenseClaimUpdate(reason="跨租户篡改"),
current_user=tenant_a_user,
)
is None
)
db.expire_all()
assert db.get(ExpenseClaim, tenant_b_claim.id).reason == "RE-TENANT-B 原始事由"
def _seed_cross_tenant_risk_history(db):
employee_a = _build_employee(
tenant_id="tenant-a",
suffix="history-a",
name="同名风险员工",
email="same-risk-owner@example.com",
)
employee_b = _build_employee(
tenant_id="tenant-b",
suffix="history-b",
name="同名风险员工",
email="same-risk-owner@example.com",
)
clean_claim = _build_claim(
claim_id="tenant-a-clean-claim",
claim_no="RE-TENANT-A-CLEAN",
tenant_id="tenant-a",
employee=employee_a,
)
risky_claim = _build_claim(
claim_id="tenant-b-risky-claim",
claim_no="RE-TENANT-B-RISKY",
tenant_id="tenant-b",
employee=employee_b,
)
risky_claim.risk_flags_json = [
{
"source": "submission_review",
"severity": "high",
"label": "跨租户风险",
"message": "该风险只属于 tenant-b。",
}
]
db.add_all([employee_a, employee_b, clean_claim, risky_claim])
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(clean_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(risky_claim, tenant_id="tenant-b")
db.commit()
return clean_claim
def test_cross_tenant_risk_history_does_not_pollute_pre_review(monkeypatch) -> None:
with build_in_memory_session_factory()() as db:
clean_claim = _seed_cross_tenant_risk_history(db)
service = ExpenseClaimService(db)
monkeypatch.setattr(service, "_resolve_claim_manager_name", lambda _claim: "直属领导")
monkeypatch.setattr(
service,
"_run_travel_policy_review",
lambda _claim: {"flags": [], "blocking_reasons": []},
)
monkeypatch.setattr(
service,
"_run_scene_policy_review",
lambda _claim: {"flags": [], "blocking_reasons": []},
)
monkeypatch.setattr(
service,
"evaluate_platform_risk_rules",
lambda _claim, **_kwargs: {"flags": [], "rule_set_fingerprint": ""},
)
assert service._count_recent_risky_claims(clean_claim) == 0
pre_review = service.refresh_claim_pre_review_state(
clean_claim,
is_application_claim=False,
)
assert pre_review is not None
assert pre_review["decision"] == "ready"
assert not any(
isinstance(flag, dict) and str(flag.get("label") or "").startswith("历史风险")
for flag in clean_claim.risk_flags_json
)
def test_cross_tenant_risk_history_does_not_route_to_p8(monkeypatch) -> None:
with build_in_memory_session_factory()() as db:
clean_claim = _seed_cross_tenant_risk_history(db)
monkeypatch.setattr(
BudgetService,
"analyze_claim_budget",
lambda _self, _claim: {
"score": 100,
"rating": "pass",
"risk_level": "low",
"summary": "预算正常",
"metrics": {},
"budget_context": {"budget_applicable": False},
},
)
service = ExpenseClaimService(db)
assert service._count_recent_substantive_risky_claims(clean_claim) == 0
route = service._build_approval_route_decision(
clean_claim,
is_application_claim=False,
)
assert route["historical_risk_count"] == 0
assert route["requires_budget_review"] is False
assert route["route"] == "finance"
def test_draft_lookup_never_returns_cross_tenant_claim_and_keeps_default_legacy() -> None:
session_factory = build_in_memory_session_factory()
with session_factory() as db:
employee_a = _build_employee(
tenant_id="tenant-a",
suffix="draft-a",
name="同名草稿员工",
email="same-draft-owner@example.com",
)
employee_b = _build_employee(
tenant_id="tenant-b",
suffix="draft-b",
name="同名草稿员工",
email="same-draft-owner@example.com",
)
default_employee = _build_employee(
tenant_id="default",
suffix="draft-default",
name="同名草稿员工",
email="same-draft-owner@example.com",
)
tenant_a_claim = _build_claim(
claim_id="tenant-a-draft",
claim_no="RE-TENANT-A-DRAFT",
tenant_id="tenant-a",
employee=employee_a,
)
tenant_b_claim = _build_claim(
claim_id="tenant-b-draft",
claim_no="RE-TENANT-B-DRAFT",
tenant_id="tenant-b",
employee=employee_b,
)
legacy_default_claim = _build_claim(
claim_id="legacy-default-draft",
claim_no="RE-LEGACY-DEFAULT-DRAFT",
tenant_id="default",
employee=default_employee,
)
db.add_all(
[
employee_a,
employee_b,
default_employee,
tenant_a_claim,
tenant_b_claim,
legacy_default_claim,
]
)
db.flush()
case_service = ExpenseCaseService(db)
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
db.commit()
service = ExpenseClaimService(db)
tenant_b_ontology = OntologyParseResult(
run_id="tenant-b-draft-lookup",
entities=[
OntologyEntity(
type="expense_claim",
value=tenant_b_claim.claim_no,
normalized_value=tenant_b_claim.claim_no,
confidence=1.0,
)
],
)
empty_ontology = OntologyParseResult(run_id="tenant-draft-candidate")
assert (
service._find_target_claim(
ontology=empty_ontology,
context_json={
"tenant_id": "tenant-a",
"draft_claim_id": tenant_b_claim.id,
},
)
is None
)
assert (
service._find_target_claim(
ontology=tenant_b_ontology,
context_json={"tenantId": "tenant-a"},
)
is None
)
association_candidate = service._find_association_candidate(
ontology=empty_ontology,
context_json={
"tenant_id": "tenant-a",
"draft_claim_id": tenant_b_claim.id,
},
user_id=employee_a.email,
employee=employee_a,
)
assert association_candidate is not None
assert association_candidate.id == tenant_a_claim.id
legacy_by_id = service._find_target_claim(
ontology=empty_ontology,
context_json={
"tenant_id": "default",
"draft_claim_id": legacy_default_claim.id,
},
)
assert legacy_by_id is not None
assert legacy_by_id.id == legacy_default_claim.id