feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -30,6 +30,7 @@ from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ocr import OcrService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
@@ -124,6 +125,161 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
||||
return claim, item
|
||||
|
||||
|
||||
def test_claim_pre_review_and_submit_share_structured_handshake() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-request-id": "pre-review-endpoint-ready-1",
|
||||
}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
assert review["review_id"]
|
||||
assert review["input_fingerprint"].startswith("sha256:")
|
||||
assert review["rule_set_fingerprint"].startswith("sha256:")
|
||||
|
||||
submit_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert submit_response.status_code == 200
|
||||
assert submit_response.json()["status"] == "submitted"
|
||||
with session_factory() as db:
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(BusinessEvent.aggregate_id == "claim-attachment-1")
|
||||
.order_by(BusinessEvent.occurred_at.asc())
|
||||
).all()
|
||||
)
|
||||
assert [event.event_type for event in events] == [
|
||||
"claim_pre_review_completed",
|
||||
"claim_submitted",
|
||||
]
|
||||
assert events[0].correlation_id == "pre-review-endpoint-ready-1"
|
||||
assert events[1].correlation_id == events[0].correlation_id
|
||||
assert events[1].causation_id == events[0].id
|
||||
|
||||
|
||||
def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "wrong-receipt.png"
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "票据与明细不一致",
|
||||
"message": "请更正费用明细或重新上传正确票据。",
|
||||
"item_ids": [item.id],
|
||||
}
|
||||
]
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_NEEDS_FIX"
|
||||
assert detail["review"]["decision"] == "needs_fix"
|
||||
assert detail["review"]["blocking_count"] == 1
|
||||
assert detail["review"]["findings"][0]["remediation"]["action"] == "edit_item_note"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
assert claim.submitted_at is None
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
|
||||
|
||||
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "zhangsan@example.com"}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
|
||||
def changed_review(_service, _claim):
|
||||
return {
|
||||
"risk_flags": [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"actionability": "review_decision",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "新增历史风险",
|
||||
"message": "预审后发现新的历史风险记录,请确认后重试。",
|
||||
}
|
||||
],
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimService,
|
||||
"_run_ai_submission_review",
|
||||
changed_review,
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers=headers,
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_CHANGED"
|
||||
assert detail["review"]["review_id"] != review["review_id"]
|
||||
assert detail["review"]["decision"] == "ready_with_review"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
|
||||
|
||||
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
@@ -843,7 +999,7 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "张三",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user