feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user