feat: 完善审批退回流程与报销申请关联
后端优化报销单访问策略和常量定义,增强退回原因和审批状态 流转,前端完善退回对话框和审批交互组件,新增报销申请关联 模型,优化文档中心行数据和审批收件箱工具函数,增强引导 流程和会话模型,补充单元测试覆盖。
This commit is contained in:
@@ -95,35 +95,19 @@ class ExpenseClaimAccessPolicy:
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
|
||||
|
||||
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if self.has_privileged_claim_access(current_user):
|
||||
return True
|
||||
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
|
||||
return False
|
||||
if str(claim.status or "").strip().lower() != "submitted":
|
||||
return False
|
||||
if str(claim.approval_stage or "").strip() != "直属领导审批":
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
return False
|
||||
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
|
||||
return False
|
||||
|
||||
claim_employee = claim.employee
|
||||
if current_employee is not None and claim_employee is not None:
|
||||
if claim_employee.manager_id == current_employee.id:
|
||||
return True
|
||||
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
|
||||
return True
|
||||
|
||||
approver_name = str(
|
||||
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
|
||||
).strip()
|
||||
if not approver_name:
|
||||
return False
|
||||
|
||||
return self.resolve_claim_manager_name(claim) == approver_name
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage == "直属领导审批":
|
||||
return self.is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
|
||||
claim,
|
||||
current_user,
|
||||
)
|
||||
return False
|
||||
|
||||
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
@@ -131,7 +115,10 @@ class ExpenseClaimAccessPolicy:
|
||||
return self.is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
return current_user.is_admin or "finance" in role_codes
|
||||
return (
|
||||
(current_user.is_admin or "finance" in role_codes)
|
||||
and not self.is_claim_owned_by_current_user(claim, current_user)
|
||||
)
|
||||
return False
|
||||
|
||||
def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
|
||||
@@ -220,6 +220,11 @@ RETURN_REASON_OPTIONS = {
|
||||
"business_explanation": "业务事由/地点/人员信息不完整",
|
||||
"duplicate_or_abnormal": "疑似重复或异常票据",
|
||||
"approval_question": "审批人需要补充说明",
|
||||
"application_info_incomplete": "申请信息不完整",
|
||||
"application_business_need_unclear": "业务必要性说明不足",
|
||||
"application_budget_basis_missing": "预算测算依据不足",
|
||||
"application_policy_mismatch": "制度口径不匹配",
|
||||
"application_attachment_needed": "前置材料需补充",
|
||||
}
|
||||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||||
DOCUMENT_DATE_PATTERN = re.compile(
|
||||
|
||||
@@ -578,10 +578,26 @@ class ExpenseClaimService(
|
||||
previous_status = str(claim.status or "").strip()
|
||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
return_event_type = (
|
||||
"expense_application_return"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "人工退回"
|
||||
)
|
||||
return_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_claim and is_direct_manager_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||
return_count = len(existing_return_flags) + 1
|
||||
@@ -600,12 +616,17 @@ class ExpenseClaimService(
|
||||
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"event_type": "expense_claim_return",
|
||||
"event_type": return_event_type,
|
||||
"return_event_id": str(uuid.uuid4()),
|
||||
"severity": "medium",
|
||||
"label": "人工退回",
|
||||
"label": return_label,
|
||||
"node_key": "returned",
|
||||
"node_label": "退回",
|
||||
"approval_node": "退回",
|
||||
"message": message,
|
||||
"reason": return_reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
|
||||
@@ -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"] == "财务审批"
|
||||
|
||||
Reference in New Issue
Block a user