feat(expenses): add authoritative pre-review workflow
This commit is contained in:
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
@@ -0,0 +1,289 @@
|
||||
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
|
||||
Reference in New Issue
Block a user