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

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