feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -9,7 +9,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from auth_helpers import install_legacy_header_auth_override
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.approval_action import ApprovalActionLedger
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||
@@ -213,12 +214,15 @@ def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||
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",
|
||||
assert (
|
||||
db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
)
|
||||
) is None
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||
@@ -402,7 +406,9 @@ def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "qianqi@example.com"}
|
||||
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
|
||||
response = client.get(
|
||||
"/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["finance_owner_name"] == "Wang Finance Group"
|
||||
@@ -518,7 +524,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
||||
meta_payload = meta_response.json()
|
||||
assert meta_payload["media_type"] == "image/png"
|
||||
assert meta_payload["preview_kind"] == "image"
|
||||
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
|
||||
assert meta_payload["preview_url"].endswith(
|
||||
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
|
||||
)
|
||||
assert meta_payload["analysis"]["headline"]
|
||||
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
|
||||
|
||||
@@ -550,7 +558,9 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
||||
assert deleted_meta_response.status_code == 404
|
||||
|
||||
|
||||
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
@@ -596,7 +606,9 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa
|
||||
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
|
||||
|
||||
|
||||
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
@@ -679,14 +691,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
action_headers = {
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
}
|
||||
stale_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-stale-1",
|
||||
"expected_status": "draft",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert stale_response.status_code == 409
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={"opinion": "情况属实,同意报销。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -701,13 +731,136 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
approval_events = [
|
||||
item
|
||||
for item in payload["risk_flags_json"]
|
||||
if item["source"] == "manual_approval"
|
||||
item for item in payload["risk_flags_json"] if item["source"] == "manual_approval"
|
||||
]
|
||||
assert approval_events[0]["operator"] == "李经理"
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
replay_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "情况属实,同意报销。",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert replay_response.status_code == 200
|
||||
assert replay_response.json()["approval_stage"] == "财务审批"
|
||||
|
||||
changed_payload_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={
|
||||
"opinion": "改为有条件通过",
|
||||
"request_id": "approve-api-claim-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers=action_headers,
|
||||
)
|
||||
assert changed_payload_response.status_code == 409
|
||||
with session_factory() as db:
|
||||
ledgers = list(db.scalars(select(ApprovalActionLedger)).all())
|
||||
assert len(ledgers) == 1
|
||||
assert ledgers[0].completed_at is not None
|
||||
|
||||
|
||||
def test_approve_claim_endpoint_blocks_open_high_risk_with_machine_readable_detail() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="manager-risk-block-api",
|
||||
employee_no="M-RISK-BLOCK-API",
|
||||
name="风险经理",
|
||||
email="manager-risk-block-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="employee-risk-block-api",
|
||||
employee_no="E-RISK-BLOCK-API",
|
||||
name="风险员工",
|
||||
email="employee-risk-block-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-risk-block-api",
|
||||
claim_no="EXP-RISK-BLOCK-API",
|
||||
employee=employee,
|
||||
employee_name=employee.name,
|
||||
department_name="风控部",
|
||||
expense_type="travel",
|
||||
reason="客户拜访",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
observation = RiskObservation(
|
||||
id="risk-block-api-observation",
|
||||
tenant_id="default",
|
||||
observation_key="risk:claim-risk-block-api:duplicate",
|
||||
subject_type="expense_claim",
|
||||
subject_key="claim:claim-risk-block-api",
|
||||
subject_label=claim.claim_no,
|
||||
claim_id=claim.id,
|
||||
claim_no=claim.claim_no,
|
||||
risk_type="duplicate_invoice",
|
||||
risk_signal="duplicate_invoice",
|
||||
title="重复票据风险",
|
||||
description="同一票据可能重复报销。",
|
||||
risk_score=92,
|
||||
risk_level="high",
|
||||
confidence_score=0.95,
|
||||
control_stage="reimbursement",
|
||||
control_mode="risk_observation",
|
||||
automation_mode="semi_auto_review",
|
||||
source="financial_risk_graph",
|
||||
algorithm_version="financial_risk_graph.v1",
|
||||
status="pending_review",
|
||||
feedback_status="unreviewed",
|
||||
)
|
||||
db.add_all([manager, employee, claim, observation])
|
||||
db.commit()
|
||||
manager_email = manager.email
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-risk-block-api/approve",
|
||||
headers={
|
||||
"X-Auth-Username": manager_email,
|
||||
"X-Auth-Name": "Risk Manager",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
json={
|
||||
"opinion": "同意",
|
||||
"request_id": "approve-risk-block-api-001",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK"
|
||||
assert detail["observations"] == [
|
||||
{
|
||||
"id": "risk-block-api-observation",
|
||||
"title": "重复票据风险",
|
||||
"risk_level": "high",
|
||||
"adjudication": "unreviewed",
|
||||
"lifecycle_status": "open",
|
||||
}
|
||||
]
|
||||
with session_factory() as db:
|
||||
persisted = db.get(ExpenseClaim, "claim-risk-block-api")
|
||||
assert persisted is not None
|
||||
assert persisted.approval_stage == "直属领导审批"
|
||||
assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 0
|
||||
|
||||
|
||||
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
@@ -766,10 +919,11 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请风险复核",
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "route_review",
|
||||
"label": "申请风险复核",
|
||||
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
||||
}
|
||||
],
|
||||
@@ -779,7 +933,12 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
|
||||
json={"opinion": "业务必要,同意申请。"},
|
||||
json={
|
||||
"opinion": "业务必要,同意申请。",
|
||||
"request_id": "approve-api-application-1",
|
||||
"expected_status": "submitted",
|
||||
"expected_approval_stage": "直属领导审批",
|
||||
},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-application-approve-api@example.com",
|
||||
@@ -853,7 +1012,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch,
|
||||
assert upload_response.status_code == 200
|
||||
meta_payload = upload_response.json()["attachment"]
|
||||
assert meta_payload["preview_kind"] == "image"
|
||||
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
|
||||
assert meta_payload["preview_url"].endswith(
|
||||
f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview"
|
||||
)
|
||||
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
|
||||
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
|
||||
@@ -1015,7 +1176,9 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
|
||||
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
client, session_factory = build_client()
|
||||
@@ -1157,8 +1320,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
||||
assert outcome.outcome_type == "application_submitted"
|
||||
assert outcome.business_event_id == event.id
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_submission"
|
||||
isinstance(flag, dict) and flag.get("event_type") == "expense_application_submission"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
@@ -1212,7 +1374,9 @@ def test_application_direct_submit_rolls_back_budget_when_case_event_fails(
|
||||
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
|
||||
|
||||
|
||||
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
||||
def test_application_preview_action_saves_draft_with_detail_reference(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
client, session_factory = build_client()
|
||||
@@ -1288,19 +1452,16 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
|
||||
assert claim.approval_stage == "待提交"
|
||||
assert claim.submitted_at is None
|
||||
assert claim.employee_name == "张三"
|
||||
assert db.scalar(
|
||||
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
|
||||
) is None
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
assert (
|
||||
db.scalar(select(BudgetReservation).where(BudgetReservation.source_id == claim.id))
|
||||
is None
|
||||
)
|
||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.payload_json["previous_status"] == ""
|
||||
assert event.payload_json["next_status"] == "draft"
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
)
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
assert link is not None
|
||||
assert link.relation_type == "application"
|
||||
|
||||
@@ -1372,9 +1533,10 @@ def test_application_preview_action_rejects_forged_identity_when_editing_other_c
|
||||
assert persisted.reason == "其他员工原申请"
|
||||
assert persisted.status == "returned"
|
||||
assert persisted.approval_stage == "退回补充"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
|
||||
) is None
|
||||
assert (
|
||||
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
|
||||
@@ -1420,17 +1582,13 @@ def test_application_preview_action_reuses_created_draft_for_identical_retry() -
|
||||
with session_factory() as db:
|
||||
application_claims = list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim).where(
|
||||
ExpenseClaim.expense_type == "travel_application"
|
||||
)
|
||||
select(ExpenseClaim).where(ExpenseClaim.expense_type == "travel_application")
|
||||
).all()
|
||||
)
|
||||
assert len(application_claims) == 1
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == application_claims[0].id
|
||||
)
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == application_claims[0].id)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
|
||||
Reference in New Issue
Block a user