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