feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -422,6 +422,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
||||
assert claim is not None
|
||||
attached_items = [item for item in claim.items if item.invoice_id]
|
||||
assert len(attached_items) == 2
|
||||
pre_review_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
]
|
||||
assert len(pre_review_flags) == 1
|
||||
assert pre_review_flags[0]["created_at"]
|
||||
receipt_links = list(
|
||||
db.scalars(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
|
||||
@@ -438,6 +445,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
||||
"receipt_received",
|
||||
"attachment_associated",
|
||||
}
|
||||
expense_case = db.scalar(
|
||||
select(ExpenseCase).where(
|
||||
ExpenseCase.id == receipt_links[0].expense_case_id
|
||||
)
|
||||
)
|
||||
assert expense_case is not None
|
||||
assert expense_case.current_stage == "claiming"
|
||||
|
||||
linked_receipts = receipt_service.list_receipts(
|
||||
current_user=current_user, status_filter="linked"
|
||||
|
||||
@@ -246,7 +246,7 @@ def test_expense_case_timeline_rejects_cross_tenant_lookup(
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
||||
assert response.json()["detail"] == "费用单据不存在。"
|
||||
|
||||
|
||||
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
||||
|
||||
@@ -11,13 +11,14 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
@@ -120,6 +121,302 @@ def test_event_write_uses_caller_transaction_and_tenant_scope() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_pre_review_records_one_idempotent_expense_case_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW", employee=employee)
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"label": "票据风险",
|
||||
"message": "票据金额与行程不匹配。",
|
||||
}
|
||||
]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
first = service.pre_review_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id="pre-review-request-1",
|
||||
idempotency_key="pre-review-request-1",
|
||||
)
|
||||
repeated = service.pre_review_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id="pre-review-request-1",
|
||||
idempotency_key="pre-review-request-1",
|
||||
)
|
||||
|
||||
assert first is not None and repeated is not None
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.correlation_id == "pre-review-request-1"
|
||||
assert event.idempotency_key.startswith("pre-review:")
|
||||
assert event.payload_json["review_status"] == "failed"
|
||||
assert event.payload_json["passed"] is False
|
||||
assert event.payload_json["blocking_risk_count"] == 1
|
||||
assert event.payload_json["business_stage"] == "reimbursement"
|
||||
assert "重大风险" in event.payload_json["message"]
|
||||
assert db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
|
||||
|
||||
def test_pre_review_event_failure_rolls_back_claim_and_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-rollback@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-ROLLBACK-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-rollback-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-ROLLBACK",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-ROLLBACK", employee=employee)
|
||||
original_flags = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"label": "原始风险",
|
||||
"message": "必须保留。",
|
||||
}
|
||||
]
|
||||
claim.risk_flags_json = original_flags
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
def fail_event(*_args, **_kwargs):
|
||||
raise RuntimeError("simulated pre-review event failure")
|
||||
|
||||
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
|
||||
with pytest.raises(RuntimeError, match="simulated pre-review event failure"):
|
||||
ExpenseClaimService(db).pre_review_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
correlation_id="pre-review-rollback",
|
||||
)
|
||||
|
||||
persisted_claim = db.get(ExpenseClaim, claim_id)
|
||||
assert persisted_claim is not None
|
||||
assert persisted_claim.risk_flags_json == original_flags
|
||||
assert db.scalar(select(ExpenseCase)) is None
|
||||
assert db.scalar(select(ExpenseCaseLink)) is None
|
||||
assert db.scalar(select(BusinessEvent)) is None
|
||||
|
||||
|
||||
def test_submit_blocks_fixable_pre_review_before_budget_and_submission_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-block-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-BLOCK-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-block-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-BLOCK-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-BLOCK", employee=employee)
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "票据与明细不一致",
|
||||
"message": "请更正费用明细或重新上传正确票据。",
|
||||
"item_ids": [claim.items[0].id],
|
||||
}
|
||||
]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "needs_fix"
|
||||
|
||||
def fail_budget(*_args, **_kwargs):
|
||||
raise AssertionError("预审阻断后不应占用预算")
|
||||
|
||||
monkeypatch.setattr(service, "_reserve_budget_for_submission", fail_budget)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
persisted_claim = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted_claim is not None
|
||||
assert persisted_claim.status == "draft"
|
||||
assert persisted_claim.submitted_at is None
|
||||
pre_review_events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(pre_review_events) == 1
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
assert db.scalar(select(BudgetReservation)) is None
|
||||
assert db.scalar(select(BudgetTransaction)) is None
|
||||
|
||||
|
||||
def test_submit_rechecks_dynamic_risk_context_and_rejects_stale_ready_review(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="dynamic-review-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="DYNAMIC-REVIEW-MANAGER",
|
||||
name="李经理",
|
||||
email="dynamic-review-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="DYNAMIC-REVIEW-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(
|
||||
claim_no="RE-CASE-DYNAMIC-REVIEW",
|
||||
employee=employee,
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
review_calls = 0
|
||||
|
||||
def dynamic_review(_claim):
|
||||
nonlocal review_calls
|
||||
review_calls += 1
|
||||
risk_flags = []
|
||||
if review_calls > 1:
|
||||
risk_flags = [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "重复发票",
|
||||
"message": "预审后发现同一发票已被其他单据使用。",
|
||||
}
|
||||
]
|
||||
return {
|
||||
"risk_flags": risk_flags,
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, "_run_ai_submission_review", dynamic_review)
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
ready_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert ready_flag["decision"] == "ready"
|
||||
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=ready_flag["review_id"],
|
||||
pre_review_input_fingerprint=ready_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert error_info.value.code == "PRE_REVIEW_NEEDS_FIX"
|
||||
assert error_info.value.review["review_id"] != ready_flag["review_id"]
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
pre_review_events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(pre_review_events) == 2
|
||||
|
||||
|
||||
def test_legacy_bootstrap_excludes_migration_owned_tables(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -215,12 +512,24 @@ def test_submit_claim_creates_case_link_and_structured_event() -> None:
|
||||
assert submitted.status == "submitted"
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id))
|
||||
assert link is not None
|
||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == submitted.id))
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_submitted"
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.payload_json["previous_status"] == "draft"
|
||||
assert event.payload_json["next_status"] == "submitted"
|
||||
pre_review_event = db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == submitted.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
)
|
||||
submitted_event = db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == submitted.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
)
|
||||
assert pre_review_event is not None and submitted_event is not None
|
||||
assert submitted_event.delivery_status == "pending"
|
||||
assert submitted_event.correlation_id == pre_review_event.correlation_id
|
||||
assert submitted_event.causation_id == pre_review_event.id
|
||||
assert submitted_event.payload_json["previous_status"] == "draft"
|
||||
assert submitted_event.payload_json["next_status"] == "submitted"
|
||||
|
||||
|
||||
def test_payment_event_failure_rolls_back_payment_archive_and_nested_audit(
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
@@ -311,7 +312,7 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
|
||||
)
|
||||
|
||||
|
||||
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
||||
def test_high_risk_application_under_90_percent_routes_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||
_seed_budget_allocation(
|
||||
@@ -362,16 +363,169 @@ def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manage
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||
][0]
|
||||
assert route_flag["requires_budget_review"] is False
|
||||
assert route_flag["route"] == "approval_done"
|
||||
assert route_flag["requires_budget_review"] is True
|
||||
assert route_flag["route"] == "budget_manager"
|
||||
assert route_flag["current_risk_count"] == 1
|
||||
assert any("申请信息风险" in reason for reason in route_flag["reasons"])
|
||||
|
||||
|
||||
def test_fixable_high_risk_application_is_blocked_before_direct_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="FIXABLE-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-FIXABLE-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"label": "申请事由不完整",
|
||||
"message": "请先补充客户与项目说明。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "needs_fix"
|
||||
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError):
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||
|
||||
|
||||
def test_review_decision_high_risk_application_flows_from_pre_review_to_p8() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="REVIEW-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-REVIEW-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "review_decision",
|
||||
"label": "特殊项目风险",
|
||||
"message": "该项目需预算管理者确认。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "ready_with_review"
|
||||
submitted = service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
assert submitted is not None
|
||||
assert submitted.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
|
||||
routed = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
assert routed is not None
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
@@ -520,6 +674,65 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_resolved_high_risk_application_does_not_route_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="RESOLVED-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-RESOLVED-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "已整改风险",
|
||||
"message": "申请人已完成补充说明。",
|
||||
"business_stage": "expense_application",
|
||||
"resolution_status": "resolved",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
routed = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="风险已整改,同意申请。",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "approved"
|
||||
assert routed.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
|
||||
|
||||
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.expense_claim_pre_review_decision import (
|
||||
build_pre_review_decision,
|
||||
pre_review_identity_matches,
|
||||
)
|
||||
|
||||
|
||||
def build_claim(*, risk_flags: list[dict] | None = None) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
id="claim-pre-review-decision",
|
||||
claim_no="RE-PRE-REVIEW-DECISION",
|
||||
employee_id="employee-pre-review",
|
||||
employee_name="张三",
|
||||
department_id="department-pre-review",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-PRE-REVIEW",
|
||||
expense_type="travel",
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=list(risk_flags or []),
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
id="item-pre-review-decision",
|
||||
claim_id=claim.id,
|
||||
item_date=date(2026, 7, 16),
|
||||
item_type="travel",
|
||||
item_reason="客户现场交通",
|
||||
item_location="上海",
|
||||
item_note="",
|
||||
item_amount=Decimal("88.00"),
|
||||
invoice_id="receipt.png",
|
||||
)
|
||||
]
|
||||
return claim
|
||||
|
||||
|
||||
def test_pre_review_identity_is_stable_when_risk_input_order_changes() -> None:
|
||||
risk_flags = [
|
||||
{"source": "manual_a", "severity": "low", "message": "A"},
|
||||
{"source": "manual_b", "severity": "medium", "message": "B"},
|
||||
]
|
||||
first = build_pre_review_decision(
|
||||
build_claim(risk_flags=risk_flags),
|
||||
risk_flags=risk_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
repeated = build_pre_review_decision(
|
||||
build_claim(risk_flags=list(reversed(risk_flags))),
|
||||
risk_flags=list(reversed(risk_flags)),
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert repeated["review_id"] == first["review_id"]
|
||||
assert repeated["input_fingerprint"] == first["input_fingerprint"]
|
||||
|
||||
|
||||
def test_pre_review_identity_is_stable_when_nested_risk_ids_change_order() -> None:
|
||||
first_flags = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "medium",
|
||||
"message": "多条明细需复核。",
|
||||
"item_ids": ["item-b", "item-a"],
|
||||
"basic_rule_refs": ["rule-b", "rule-a"],
|
||||
}
|
||||
]
|
||||
repeated_flags = [
|
||||
{
|
||||
**first_flags[0],
|
||||
"item_ids": ["item-a", "item-b"],
|
||||
"basic_rule_refs": ["rule-a", "rule-b"],
|
||||
}
|
||||
]
|
||||
|
||||
first = build_pre_review_decision(
|
||||
build_claim(risk_flags=first_flags),
|
||||
risk_flags=first_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
repeated = build_pre_review_decision(
|
||||
build_claim(risk_flags=repeated_flags),
|
||||
risk_flags=repeated_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert repeated["review_id"] == first["review_id"]
|
||||
assert repeated["review_context_fingerprint"] == first[
|
||||
"review_context_fingerprint"
|
||||
]
|
||||
|
||||
|
||||
def test_pre_review_identity_changes_when_dynamic_findings_change() -> None:
|
||||
claim = build_claim()
|
||||
ready = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
changed = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"message": "预审后发现同一发票已被其他单据使用。",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert changed["input_fingerprint"] == ready["input_fingerprint"]
|
||||
assert changed["review_context_fingerprint"] != ready[
|
||||
"review_context_fingerprint"
|
||||
]
|
||||
assert changed["review_id"] != ready["review_id"]
|
||||
assert changed["decision"] == "needs_fix"
|
||||
|
||||
|
||||
def test_pre_review_blocks_high_fixable_risk_and_normalizes_string_item_id() -> None:
|
||||
claim = build_claim()
|
||||
decision = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"message": "住宿金额超过标准,请修正。",
|
||||
"item_ids": "item-pre-review-decision",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert decision["decision"] == "needs_fix"
|
||||
assert decision["blocking_count"] == 1
|
||||
assert decision["findings"][0]["item_ids"] == ["item-pre-review-decision"]
|
||||
assert (
|
||||
decision["findings"][0]["remediation"]["alternative_action"]
|
||||
== "accept_standard_limit"
|
||||
)
|
||||
|
||||
|
||||
def test_pre_review_routes_budget_governance_risk_to_approval_instead_of_blocking() -> None:
|
||||
claim = build_claim()
|
||||
decision = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "budget_control",
|
||||
"severity": "critical",
|
||||
"risk_domain": "budget",
|
||||
"actionability": "budget_governance",
|
||||
"message": "预算使用率超过治理阈值。",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert decision["decision"] == "ready_with_review"
|
||||
assert decision["blocking_count"] == 0
|
||||
assert decision["findings"][0]["disposition"] == "review"
|
||||
assert pre_review_identity_matches(
|
||||
{**decision},
|
||||
review_id=decision["review_id"],
|
||||
input_fingerprint=decision["input_fingerprint"],
|
||||
)
|
||||
@@ -31,6 +31,7 @@ from app.services.budget import BudgetService
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
@@ -249,7 +250,7 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
|
||||
issues = service._validate_claim_for_submission(claim)
|
||||
|
||||
assert "业务地点未完善" in issues
|
||||
assert any("缺少地点" in item for item in issues)
|
||||
assert not any("缺少地点" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
||||
@@ -3256,7 +3257,7 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
|
||||
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
|
||||
|
||||
|
||||
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
@@ -3271,10 +3272,10 @@ def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
assert deleted is not None
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||
@@ -3411,7 +3412,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
|
||||
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
name="submitter",
|
||||
@@ -3419,10 +3420,15 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fail_review(self, reviewed_claim):
|
||||
raise AssertionError("submit should reuse upload-time pre-review")
|
||||
original_review = ExpenseClaimService._run_ai_submission_review
|
||||
review_calls = 0
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
|
||||
def count_review(self, reviewed_claim):
|
||||
nonlocal review_calls
|
||||
review_calls += 1
|
||||
return original_review(self, reviewed_claim)
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", count_review)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
@@ -3462,8 +3468,18 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
|
||||
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
|
||||
assert review_calls == 1
|
||||
assert not any(
|
||||
flag.get("label") == "upload-time-warning"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in submitted.risk_flags_json
|
||||
if flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["review_id"]
|
||||
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
|
||||
@@ -3732,7 +3748,7 @@ def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
|
||||
|
||||
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -3798,19 +3814,22 @@ def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert blocked.submitted_at is None
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
finding["severity"] == "high"
|
||||
and finding["disposition"] == "fix"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -3968,34 +3987,22 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||
fake_platform_route_review,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and (
|
||||
"多城市" in str(flag.get("message") or "")
|
||||
or "终点" in str(flag.get("message") or "")
|
||||
)
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
route_flags = [
|
||||
flag
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("label") or "").strip() in {"行程终点异常", "多城市行程待说明"}
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
route_findings = [
|
||||
finding
|
||||
for finding in error_info.value.review["findings"]
|
||||
if "多城市" in finding["message"] or "终点" in finding["message"]
|
||||
]
|
||||
assert route_flags
|
||||
assert all(flag.get("item_ids") for flag in route_flags)
|
||||
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
assert route_findings
|
||||
assert any(
|
||||
"travel-item-2" in finding["item_ids"]
|
||||
for finding in route_findings
|
||||
)
|
||||
|
||||
|
||||
@@ -4147,7 +4154,7 @@ def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -4282,16 +4289,21 @@ def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_re
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and "住宿标准" in str(flag.get("message") or "")
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
finding.get("remediation", {}).get("alternative_action")
|
||||
== "accept_standard_limit"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
assert any(
|
||||
"住宿" in finding["message"] or "酒店" in finding["message"]
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
|
||||
|
||||
@@ -4949,13 +4961,13 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
|
||||
with pytest.raises(ValueError, match="申请人本人可以删除单据"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
def test_executive_cannot_delete_submitted_claim_without_admin_role() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
@@ -4985,11 +4997,10 @@ def test_executive_can_delete_submitted_claim() -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有草稿"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_direct_manager_cannot_delete_application_claim() -> None:
|
||||
@@ -6784,16 +6795,16 @@ def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == "预算管理者审批"
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "同意"
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert reimbursement_claim_query(db).count() == 0
|
||||
assert reimbursement_claim_query(db).count() == 1
|
||||
|
||||
|
||||
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
||||
|
||||
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.steward import StewardActionExecuteRequest, StewardTask
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.steward_action_executor import StewardActionExecutor
|
||||
from app.services.travel_reimbursement_calculator import (
|
||||
TravelReimbursementCalculatorService,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session() -> 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 test_ontology_draft_event_uses_context_tenant() -> None:
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="TENANT-DRAFT-EMPLOYEE",
|
||||
name="租户草稿员工",
|
||||
email="tenant-draft@example.com",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id="tenant-draft-run",
|
||||
user_id=employee.email,
|
||||
message="2026-07-16 在上海拜访客户,交通费 32 元,保存草稿",
|
||||
ontology=OntologyParseResult(run_id="tenant-draft-run"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-expense-a",
|
||||
"name": employee.name,
|
||||
"review_action": "save_draft",
|
||||
"review_form_values": {
|
||||
"expense_type": "交通费",
|
||||
"occurred_date": "2026-07-16",
|
||||
"location": "上海",
|
||||
"reason": "客户拜访",
|
||||
"amount": "32元",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == result["claim_id"])
|
||||
)
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == result["claim_id"])
|
||||
)
|
||||
assert event is not None
|
||||
assert event.tenant_id == "tenant-expense-a"
|
||||
assert link is not None
|
||||
assert link.tenant_id == "tenant-expense-a"
|
||||
|
||||
|
||||
class ReviewSubmitTenantProbe(ExpenseClaimReviewPreviewMixin):
|
||||
def __init__(self) -> None:
|
||||
self.submission_user: CurrentUserContext | None = None
|
||||
|
||||
def upsert_draft_from_ontology(self, **_kwargs):
|
||||
return {"claim_id": "tenant-review-claim", "draft_only": True}
|
||||
|
||||
def submit_claim(self, _claim_id: str, current_user: CurrentUserContext):
|
||||
self.submission_user = current_user
|
||||
return SimpleNamespace(
|
||||
id="tenant-review-claim",
|
||||
claim_no="BX-TENANT-REVIEW",
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
amount=Decimal("32.00"),
|
||||
invoice_count=1,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
|
||||
|
||||
def test_review_next_step_uses_context_tenant() -> None:
|
||||
probe = ReviewSubmitTenantProbe()
|
||||
result = probe.save_or_submit_from_ontology(
|
||||
run_id="tenant-review-run",
|
||||
user_id="tenant-review@example.com",
|
||||
message="确认提交",
|
||||
ontology=OntologyParseResult(run_id="tenant-review-run"),
|
||||
context_json={
|
||||
"review_action": "next_step",
|
||||
"tenant_id": "tenant-expense-b",
|
||||
"name": "租户预览员工",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert probe.submission_user is not None
|
||||
assert probe.submission_user.tenant_id == "tenant-expense-b"
|
||||
|
||||
|
||||
def test_review_calculators_use_context_tenant(monkeypatch) -> None:
|
||||
captured_tenants: list[str] = []
|
||||
|
||||
def fake_calculate(_self, _payload, current_user):
|
||||
captured_tenants.append(current_user.tenant_id)
|
||||
return SimpleNamespace(
|
||||
grade="P4",
|
||||
days=2,
|
||||
matched_city="上海",
|
||||
hotel_rate=Decimal("300.00"),
|
||||
hotel_amount=Decimal("600.00"),
|
||||
total_allowance_rate=Decimal("100.00"),
|
||||
allowance_amount=Decimal("200.00"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(TravelReimbursementCalculatorService, "calculate", fake_calculate)
|
||||
with build_session() as db:
|
||||
expense_service = ExpenseClaimService(db)
|
||||
expense_service._build_expense_review_preview_calculation_copy(
|
||||
user_id="tenant-calculator@example.com",
|
||||
message="2026-07-16 至 2026-07-17 去上海出差,交通费 32 元",
|
||||
ontology=OntologyParseResult(run_id="tenant-calculator-preview"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-calculator",
|
||||
"employee_grade": "P4",
|
||||
"location": "上海",
|
||||
"occurred_date": "2026-07-16",
|
||||
"amount": "32元",
|
||||
},
|
||||
)
|
||||
|
||||
payload = UserAgentRequest(
|
||||
run_id="tenant-calculator-message",
|
||||
user_id="tenant-calculator@example.com",
|
||||
message="上海出差",
|
||||
ontology=OntologyParseResult(run_id="tenant-calculator-message"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-calculator",
|
||||
"grade": "P4",
|
||||
"name": "租户测算员工",
|
||||
},
|
||||
)
|
||||
UserAgentService(db)._build_travel_receipt_estimate_copy(
|
||||
payload,
|
||||
travel_receipt_state={
|
||||
"destination": "上海",
|
||||
"days": 2,
|
||||
"ticket_type_label": "火车票",
|
||||
"ticket_amount": "32",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured_tenants == ["tenant-calculator", "tenant-calculator"]
|
||||
|
||||
|
||||
def test_steward_reimbursement_context_uses_current_tenant() -> None:
|
||||
task = StewardTask(
|
||||
task_id="tenant-steward-task",
|
||||
task_type="reimbursement",
|
||||
assigned_agent="reimbursement_assistant",
|
||||
title="租户报销",
|
||||
summary="上海客户拜访交通费 32 元",
|
||||
requested_action="save_draft",
|
||||
ontology_fields={
|
||||
"expense_type": "transport",
|
||||
"time_range": "2026-07-16",
|
||||
"location": "上海",
|
||||
"reason": "客户拜访",
|
||||
"amount": "32元",
|
||||
},
|
||||
missing_fields=[],
|
||||
confirmation_required=False,
|
||||
)
|
||||
request = StewardActionExecuteRequest(
|
||||
action_type="create_reimbursement_draft",
|
||||
message="保存报销草稿",
|
||||
task=task,
|
||||
)
|
||||
current_user = CurrentUserContext(
|
||||
username="tenant-steward@example.com",
|
||||
name="租户小财管家用户",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-steward",
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
context = StewardActionExecutor(db)._build_reimbursement_context_json(
|
||||
request,
|
||||
current_user,
|
||||
)
|
||||
|
||||
assert context["tenant_id"] == "tenant-steward"
|
||||
|
||||
|
||||
def test_application_approval_keeps_case_at_approved_to_spend() -> None:
|
||||
with build_session() as db:
|
||||
department = OrganizationUnit(
|
||||
unit_code="TENANT-CASE-TRAVEL",
|
||||
name="租户差旅部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="TENANT-CASE-MANAGER",
|
||||
name="租户差旅经理",
|
||||
email="tenant-case-manager@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="TENANT-CASE-EMPLOYEE",
|
||||
name="租户差旅员工",
|
||||
email="tenant-case-employee@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, employee])
|
||||
db.flush()
|
||||
db.add(
|
||||
BudgetAllocation(
|
||||
budget_no="BUD-TENANT-CASE-TRAVEL",
|
||||
fiscal_year=2026,
|
||||
period_type="year",
|
||||
period_key="2026",
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
cost_center=None,
|
||||
project_code=None,
|
||||
subject_code="travel",
|
||||
subject_name="差旅费",
|
||||
original_amount=Decimal("50000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
)
|
||||
)
|
||||
application = ExpenseClaim(
|
||||
claim_no="AP-TENANT-CASE-GENERATE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code="PRJ-TENANT-CASE",
|
||||
expense_type="travel_application",
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(application)
|
||||
db.flush()
|
||||
ExpenseCaseService(db).ensure_case_for_claim(
|
||||
application,
|
||||
tenant_id="tenant-case-stage",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
application.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-case-stage",
|
||||
),
|
||||
opinion="业务必要,同意申请",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
expense_case = db.scalar(select(ExpenseCase))
|
||||
assert expense_case is not None
|
||||
assert expense_case.tenant_id == "tenant-case-stage"
|
||||
assert expense_case.current_stage == "approved_to_spend"
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(BusinessEvent.expense_case_id == expense_case.id)
|
||||
.order_by(BusinessEvent.occurred_at)
|
||||
).all()
|
||||
)
|
||||
assert [event.event_type for event in events] == [
|
||||
"application_approved",
|
||||
"reimbursement_draft_generated",
|
||||
]
|
||||
assert {event.tenant_id for event in events} == {"tenant-case-stage"}
|
||||
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
|
||||
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog
|
||||
|
||||
|
||||
def _spreadsheet_asset() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
config_json={
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_document": {"storage_key": "corrupted.xlsx"},
|
||||
},
|
||||
current_version="v1.0.0",
|
||||
name="临时损坏规则",
|
||||
)
|
||||
|
||||
|
||||
def _spreadsheet_version() -> SimpleNamespace:
|
||||
return SimpleNamespace(content="", version="v1.0.0")
|
||||
|
||||
|
||||
def _assert_unreadable_workbook_is_skipped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workbook_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AgentAssetSpreadsheetManager,
|
||||
"resolve_storage_path",
|
||||
lambda _manager, _storage_key: workbook_path,
|
||||
)
|
||||
catalog = build_default_expense_rule_catalog()
|
||||
default_catalog = deepcopy(catalog)
|
||||
|
||||
ExpenseRuleRuntimeService(db=None)._apply_spreadsheet_runtime_payload(
|
||||
catalog,
|
||||
asset=_spreadsheet_asset(),
|
||||
version=_spreadsheet_version(),
|
||||
)
|
||||
|
||||
assert catalog == default_catalog
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.scene_policies
|
||||
|
||||
|
||||
def test_corrupted_spreadsheet_is_skipped_without_breaking_default_catalog(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
corrupted_workbook = tmp_path / "corrupted.xlsx"
|
||||
corrupted_workbook.write_bytes(b"not-an-xlsx-archive")
|
||||
|
||||
_assert_unreadable_workbook_is_skipped(monkeypatch, corrupted_workbook)
|
||||
|
||||
|
||||
def test_invalid_spreadsheet_format_is_skipped_without_breaking_catalog(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
invalid_workbook = tmp_path / "invalid.xlsx"
|
||||
invalid_workbook.touch()
|
||||
monkeypatch.setattr(
|
||||
"app.services.expense_rule_runtime.load_workbook",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
InvalidFileException("unsupported workbook format")
|
||||
),
|
||||
)
|
||||
|
||||
_assert_unreadable_workbook_is_skipped(monkeypatch, invalid_workbook)
|
||||
@@ -13,7 +13,10 @@ from app.main import create_app
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.linked_reimbursement_draft_jobs import (
|
||||
_build_direct_context_json,
|
||||
_find_application_claim,
|
||||
clear_linked_reimbursement_draft_jobs_for_tests,
|
||||
)
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
@@ -150,6 +153,122 @@ def test_linked_reimbursement_draft_job_runs_after_conversation_leaves(monkeypat
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
def test_linked_job_overrides_forged_tenant_and_rejects_same_owner_cross_tenant(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
captured_contexts = []
|
||||
|
||||
def fake_run(self, payload):
|
||||
captured_contexts.append(dict(payload.context_json or {}))
|
||||
return OrchestratorResponse(
|
||||
run_id="run-linked-tenant-guard",
|
||||
conversation_id=None,
|
||||
selected_agent="user_agent",
|
||||
route_reason="验证租户边界。",
|
||||
permission_level="draft_write",
|
||||
status="succeeded",
|
||||
result={
|
||||
"message": "报销草稿已生成。",
|
||||
"draft_payload": {
|
||||
"claim_id": "tenant-guard-draft",
|
||||
"claim_no": "RE-TENANT-GUARD",
|
||||
"status": "draft",
|
||||
"expense_type": "travel",
|
||||
},
|
||||
},
|
||||
requires_confirmation=False,
|
||||
trace_summary=OrchestratorTraceSummary(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
tool_count=1,
|
||||
failed_tool_count=0,
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OrchestratorService, "run", fake_run)
|
||||
try:
|
||||
client, _session_factory = build_client(monkeypatch)
|
||||
tenant_a_headers = {
|
||||
"x-auth-username": "same-owner@example.com",
|
||||
"x-auth-name": "Same Owner",
|
||||
"x-auth-role-codes": "user",
|
||||
"x-auth-tenant-id": "tenant-a",
|
||||
}
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
|
||||
headers=tenant_a_headers,
|
||||
json={
|
||||
"message": "创建普通报销草稿",
|
||||
"context_json": {
|
||||
"tenant_id": "tenant-forged",
|
||||
"tenantId": "tenant-forged-camel",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert captured_contexts == [
|
||||
{
|
||||
"tenant_id": "tenant-a",
|
||||
"entry_source": "workbench-ai",
|
||||
"session_type": "expense",
|
||||
}
|
||||
]
|
||||
job_id = response.json()["job_id"]
|
||||
cross_tenant_response = client.get(
|
||||
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
|
||||
headers={**tenant_a_headers, "x-auth-tenant-id": "tenant-b"},
|
||||
)
|
||||
assert cross_tenant_response.status_code == 404
|
||||
finally:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
def test_linked_application_lookup_and_context_are_tenant_scoped() -> None:
|
||||
session_factory = build_in_memory_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_employee_and_application(db)
|
||||
application = db.get(ExpenseClaim, "application-linked-draft-fast")
|
||||
assert application is not None
|
||||
ExpenseCaseService(db).ensure_case_for_claim(
|
||||
application,
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert (
|
||||
_find_application_claim(
|
||||
db,
|
||||
claim_no="AP-202606-FAST",
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
is None
|
||||
)
|
||||
resolved = _find_application_claim(
|
||||
db,
|
||||
claim_no="AP-202606-FAST",
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
assert resolved is not None
|
||||
context = _build_direct_context_json(
|
||||
db,
|
||||
{
|
||||
"tenant_id": "tenant-forged",
|
||||
"tenantId": "tenant-forged-camel",
|
||||
"review_form_values": {
|
||||
"application_claim_no": "AP-202606-FAST",
|
||||
},
|
||||
},
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
assert context["tenant_id"] == "tenant-a"
|
||||
assert "tenantId" not in context
|
||||
assert context["review_form_values"]["application_claim_id"] == application.id
|
||||
|
||||
|
||||
def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ocr import OcrService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
@@ -124,6 +125,161 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
||||
return claim, item
|
||||
|
||||
|
||||
def test_claim_pre_review_and_submit_share_structured_handshake() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-request-id": "pre-review-endpoint-ready-1",
|
||||
}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
assert review["review_id"]
|
||||
assert review["input_fingerprint"].startswith("sha256:")
|
||||
assert review["rule_set_fingerprint"].startswith("sha256:")
|
||||
|
||||
submit_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert submit_response.status_code == 200
|
||||
assert submit_response.json()["status"] == "submitted"
|
||||
with session_factory() as db:
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(BusinessEvent.aggregate_id == "claim-attachment-1")
|
||||
.order_by(BusinessEvent.occurred_at.asc())
|
||||
).all()
|
||||
)
|
||||
assert [event.event_type for event in events] == [
|
||||
"claim_pre_review_completed",
|
||||
"claim_submitted",
|
||||
]
|
||||
assert events[0].correlation_id == "pre-review-endpoint-ready-1"
|
||||
assert events[1].correlation_id == events[0].correlation_id
|
||||
assert events[1].causation_id == events[0].id
|
||||
|
||||
|
||||
def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "wrong-receipt.png"
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "票据与明细不一致",
|
||||
"message": "请更正费用明细或重新上传正确票据。",
|
||||
"item_ids": [item.id],
|
||||
}
|
||||
]
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_NEEDS_FIX"
|
||||
assert detail["review"]["decision"] == "needs_fix"
|
||||
assert detail["review"]["blocking_count"] == 1
|
||||
assert detail["review"]["findings"][0]["remediation"]["action"] == "edit_item_note"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
assert claim.submitted_at is None
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
|
||||
|
||||
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "zhangsan@example.com"}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
|
||||
def changed_review(_service, _claim):
|
||||
return {
|
||||
"risk_flags": [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"actionability": "review_decision",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "新增历史风险",
|
||||
"message": "预审后发现新的历史风险记录,请确认后重试。",
|
||||
}
|
||||
],
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimService,
|
||||
"_run_ai_submission_review",
|
||||
changed_review,
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers=headers,
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_CHANGED"
|
||||
assert detail["review"]["review_id"] != review["review_id"]
|
||||
assert detail["review"]["decision"] == "ready_with_review"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
|
||||
|
||||
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
@@ -843,7 +999,7 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "张三",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user