feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -2791,7 +2791,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add(claim)
@@ -3050,6 +3050,63 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
)
def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
current_user = CurrentUserContext(
username="manager-own-approval@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
superior = Employee(
employee_no="E8112",
name="王总",
email="superior-own-approval@example.com",
)
manager = Employee(
employee_no="E8113",
name="李经理",
email="manager-own-approval@example.com",
manager=superior,
)
db.add_all([superior, manager])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-APP-SELF-201",
employee_id=manager.id,
employee_name="李经理",
department_name="市场部",
project_code="PRJ-A",
expense_type="transport",
reason="交通报销",
location="上海",
amount=Decimal("66.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
service = ExpenseClaimService(db)
with pytest.raises(ValueError, match="当前直属领导审批人"):
service.approve_claim(claim_id, current_user, opinion="同意")
with pytest.raises(ValueError, match="当前审批人"):
service.return_claim(claim_id, current_user, reason="退回")
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert claim.risk_flags_json == []
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
current_user = CurrentUserContext(
username="application-owner@example.com",
@@ -3359,6 +3416,92 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
)
def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
manager_user = CurrentUserContext(
username="manager-application-return@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E8114",
name="李经理",
email="manager-application-return@example.com",
)
employee = Employee(
employee_no="E8115",
name="张三",
email="zhangsan-application-return@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-RETURN",
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="支撑国网服务器上线部署",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
with pytest.raises(ValueError, match="退单类型"):
ExpenseClaimService(db).return_claim(
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.risk_flags_json == []
returned = ExpenseClaimService(db).return_claim(
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
)
assert returned is not None
assert returned.status == "returned"
assert returned.approval_stage == "待提交"
return_event = next(
flag
for flag in returned.risk_flags_json
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return"
)
assert return_event["label"] == "领导退回"
assert return_event["node_key"] == "returned"
assert return_event["node_label"] == "退回"
assert return_event["approval_node"] == "退回"
assert return_event["operator"] == "李经理"
assert return_event["opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["return_stage"] == "直属领导审批"
assert return_event["return_stage_key"] == "direct_manager"
assert return_event["reason_codes"] == [
"application_business_need_unclear",
"application_budget_basis_missing",
]
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
assert return_event["next_status"] == "returned"
assert return_event["next_approval_stage"] == "待提交"
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
owner = CurrentUserContext(
username="application-budget-owner-approve@example.com",
@@ -3554,6 +3697,55 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
)
def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
current_user = CurrentUserContext(
username="finance-own-approval@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E8124",
name="财务",
email="finance-own-approval@example.com",
)
db.add(employee)
db.flush()
claim = ExpenseClaim(
claim_no="RE-20260525-FINANCE-SELF",
employee_id=employee.id,
employee_name="财务",
department_name="财务部",
project_code=None,
expense_type="travel",
reason="差旅报销",
location="上海",
amount=Decimal("800.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
with pytest.raises(ValueError, match="财务终审"):
service.approve_claim(claim.id, current_user, opinion="同意入账")
with pytest.raises(ValueError, match="可以退回"):
service.return_claim(claim.id, current_user, reason="退回")
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "财务审批"
assert claim.risk_flags_json == []
def test_finance_can_approve_claim_to_archive_stage() -> None:
current_user = CurrentUserContext(
username="finance-approve@example.com",
@@ -3655,7 +3847,13 @@ def test_return_claim_rejects_already_returned_claim_without_adding_event() -> N
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
current_user = CurrentUserContext(
manager_user = CurrentUserContext(
username="manager-return-count@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
finance_user = CurrentUserContext(
username="finance-return@example.com",
name="财务复核",
role_codes=["finance"],
@@ -3663,8 +3861,22 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
)
with build_session() as db:
manager = Employee(
employee_no="E8130",
name="李经理",
email="manager-return-count@example.com",
)
employee = Employee(
employee_no="E8131",
name="张三",
email="zhangsan-return-count@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="EXP-RET-301",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code="PRJ-A",
@@ -3687,7 +3899,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
service = ExpenseClaimService(db)
first_returned = service.return_claim(
claim_id,
current_user,
manager_user,
reason="发票金额与明细金额不一致,请重新核对。",
reason_codes=["invoice_mismatch", "business_explanation"],
)
@@ -3700,7 +3912,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
second_returned = service.return_claim(
claim_id,
current_user,
finance_user,
reason="超标说明仍不完整,请补充制度例外依据。",
reason_codes=["over_policy"],
)
@@ -3718,7 +3930,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
assert return_events[0]["operator_role_codes"] == ["finance"]
assert return_events[0]["operator_role_codes"] == ["manager"]
assert return_events[1]["return_count"] == 2
assert return_events[1]["stage_return_count"] == 1
assert return_events[1]["return_stage"] == "财务审批"