feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PENDING_STAGE,
PAYMENT_PENDING_STATUS,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
class ExpenseClaimApprovalFlowMixin:
@@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin:
previous_stage = str(claim.approval_stage or "").strip()
is_application_claim = self._is_expense_application_claim(claim)
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
next_budget_manager = None
merged_budget_approval = False
route_decision_flag: dict[str, Any] | None = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
approval_source = "manual_approval"
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
label = "领导审批通过"
route_decision_flag = self._build_approval_route_decision(
claim,
is_application_claim=is_application_claim,
)
requires_budget_review = bool(route_decision_flag.get("requires_budget_review"))
if is_application_claim:
merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim)
merged_budget_approval = (
requires_budget_review
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
)
if merged_budget_approval:
label = "领导及预算审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
else:
elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
else:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
else:
if requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该单据。")
approval_source = "budget_approval"
event_type = (
"expense_application_budget_approval"
if is_application_claim
else "expense_claim_budget_approval"
)
label = "预算管理者审核通过"
if is_application_claim:
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator}审批通过,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not is_application_claim:
raise ValueError("只有费用申请需要预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
approval_source = "budget_approval"
event_type = "expense_application_budget_approval"
label = "预算管理者审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
default_message = "{operator}完成预算审核,流转至{next_stage}"
elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。")
@@ -95,32 +130,35 @@ class ExpenseClaimApprovalFlowMixin:
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
if consumed_budget_flag is not None:
budget_flags.append(consumed_budget_flag)
approval_flag = {
"source": approval_source,
"event_type": event_type,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
}
approval_flag = with_risk_business_stage(
{
"source": approval_source,
"event_type": event_type,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
},
business_stage,
)
if merged_budget_approval:
approval_flag.update(
{
"budget_approval_merged": True,
"budget_approval_merged_reason": "direct_manager_is_department_budget_monitor",
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
}
)
if next_budget_manager is not None:
@@ -129,9 +167,20 @@ class ExpenseClaimApprovalFlowMixin:
"next_approver_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
"next_approver_role_code": "budget_monitor",
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
next_budget_manager,
),
}
)
if route_decision_flag is not None:
approval_flag["route_decision"] = {
"requires_budget_review": route_decision_flag.get("requires_budget_review"),
"route": route_decision_flag.get("route"),
"reasons": route_decision_flag.get("reasons", []),
"budget_result": route_decision_flag.get("budget_result", {}),
"current_risk_count": route_decision_flag.get("current_risk_count", 0),
"historical_risk_count": route_decision_flag.get("historical_risk_count", 0),
}
claim.status = next_status
claim.approval_stage = next_stage
@@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin:
elif merged_budget_approval:
approval_flag["leader_opinion"] = approval_opinion
approval_flag["budget_opinion"] = approval_opinion
elif (
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
and route_decision_flag is not None
and not route_decision_flag.get("requires_budget_review")
):
approval_flag["leader_opinion"] = approval_opinion
approval_flag["budget_opinion"] = "系统动态路由跳过预算复核"
generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim,
approval_flag=approval_flag,
@@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin:
generated_draft.risk_flags_json = self._append_budget_flags(
generated_draft.risk_flags_json,
transferred_budget_flag,
business_stage="reimbursement",
)
approval_flags: list[Any] = list(claim.risk_flags_json or [])
if route_decision_flag is not None:
approval_flags.append(route_decision_flag)
approval_flags.append(approval_flag)
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), approval_flag],
approval_flags,
budget_flags,
business_stage=business_stage,
)
self.db.commit()
self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
self.audit_service.log_action(
actor=operator,
@@ -202,26 +265,29 @@ class ExpenseClaimApprovalFlowMixin:
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_stage = str(claim.approval_stage or "").strip()
payment_flag = {
"source": "payment",
"event_type": "expense_claim_payment_completed",
"payment_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "付款完成",
"message": f"{operator} 已确认付款,报销单进入已付款。",
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": PAYMENT_PAID_STATUS,
"next_approval_stage": PAYMENT_PAID_STAGE,
"created_at": datetime.now(UTC).isoformat(),
}
payment_flag = with_risk_business_stage(
{
"source": "payment",
"event_type": "expense_claim_payment_completed",
"payment_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "付款完成",
"message": f"{operator} 已确认付款,报销单进入已付款。",
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": PAYMENT_PAID_STATUS,
"next_approval_stage": PAYMENT_PAID_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"reimbursement",
)
claim.status = PAYMENT_PAID_STATUS
claim.approval_stage = PAYMENT_PAID_STAGE