290 lines
9.9 KiB
Python
290 lines
9.9 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, employee: Employee) -> ExpenseClaim:
|
|
return ExpenseClaim(
|
|
id=claim_id,
|
|
claim_no=claim_no,
|
|
employee_id=employee.id,
|
|
employee_name=employee.name,
|
|
department_id="tenant-scope-department",
|
|
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 _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 = Employee(
|
|
id="tenant-scope-employee",
|
|
employee_no="TENANT-SCOPE-001",
|
|
name="同名员工",
|
|
email="same-owner@example.com",
|
|
)
|
|
tenant_a_claim = _build_claim(
|
|
claim_id="tenant-a-claim",
|
|
claim_no="RE-TENANT-A",
|
|
employee=employee,
|
|
)
|
|
tenant_b_claim = _build_claim(
|
|
claim_id="tenant-b-claim",
|
|
claim_no="RE-TENANT-B",
|
|
employee=employee,
|
|
)
|
|
legacy_default_claim = _build_claim(
|
|
claim_id="legacy-default-claim",
|
|
claim_no="RE-LEGACY-DEFAULT",
|
|
employee=employee,
|
|
)
|
|
db.add_all([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 = Employee(
|
|
id="tenant-history-employee",
|
|
employee_no="TENANT-HISTORY-001",
|
|
name="同名风险员工",
|
|
email="same-risk-owner@example.com",
|
|
)
|
|
clean_claim = _build_claim(
|
|
claim_id="tenant-a-clean-claim",
|
|
claim_no="RE-TENANT-A-CLEAN",
|
|
employee=employee,
|
|
)
|
|
risky_claim = _build_claim(
|
|
claim_id="tenant-b-risky-claim",
|
|
claim_no="RE-TENANT-B-RISKY",
|
|
employee=employee,
|
|
)
|
|
risky_claim.risk_flags_json = [
|
|
{
|
|
"source": "submission_review",
|
|
"severity": "high",
|
|
"label": "跨租户风险",
|
|
"message": "该风险只属于 tenant-b。",
|
|
}
|
|
]
|
|
db.add_all([employee, 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 = Employee(
|
|
id="tenant-draft-employee",
|
|
employee_no="TENANT-DRAFT-001",
|
|
name="同名草稿员工",
|
|
email="same-draft-owner@example.com",
|
|
)
|
|
tenant_a_claim = _build_claim(
|
|
claim_id="tenant-a-draft",
|
|
claim_no="RE-TENANT-A-DRAFT",
|
|
employee=employee,
|
|
)
|
|
tenant_b_claim = _build_claim(
|
|
claim_id="tenant-b-draft",
|
|
claim_no="RE-TENANT-B-DRAFT",
|
|
employee=employee,
|
|
)
|
|
legacy_default_claim = _build_claim(
|
|
claim_id="legacy-default-draft",
|
|
claim_no="RE-LEGACY-DEFAULT-DRAFT",
|
|
employee=employee,
|
|
)
|
|
db.add_all([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.email,
|
|
employee=employee,
|
|
)
|
|
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
|