feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View File

@@ -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"

View File

@@ -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(

View File

@@ -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(

View File

@@ -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")

View 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"],
)

View File

@@ -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:

View 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"}

View 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

View 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)

View File

@@ -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()

View File

@@ -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",
},