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

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