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