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

@@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy:
return False
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
return self.is_department_p8_budget_monitor(current_user, claim)
return self.is_department_budget_approver(current_user, claim)
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
@@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy:
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return False
return self.is_department_budget_approver(current_user, claim)
def is_department_budget_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
current_employee = self.resolve_current_employee(current_user)
if current_employee is None:
return False
role_codes |= self._collect_employee_role_codes(current_employee)
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
return False
if not self._employee_has_budget_approval_grade(current_employee):
return False
@@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy:
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where(
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
Employee.roles.any(Role.role_code.in_(BUDGET_APPROVAL_ROLE_CODES)),
or_(*department_conditions),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
@@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy:
stmt = stmt.where(Employee.id != claim_employee_id)
return self.db.scalar(stmt)
def resolve_budget_approval_role_code(self, employee: Employee | None) -> str:
role_codes = self._collect_employee_role_codes(employee)
for role_code in ("budget_monitor", "executive"):
if role_code in role_codes:
return role_code
return BUDGET_MONITOR_ROLE_CODE
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return claim
budget_manager = self.resolve_department_budget_manager(claim)
if budget_manager is None:
return claim
setattr(claim, "budget_approver_name", str(budget_manager.name or "").strip())
setattr(claim, "budget_approver_grade", str(budget_manager.grade or "").strip())
setattr(
claim,
"budget_approver_role_code",
self.resolve_budget_approval_role_code(budget_manager),
)
return claim
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
for claim in claims:
self.attach_budget_approval_snapshot(claim)
return claims
@staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return {
@@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy:
if str(item).strip()
}
@staticmethod
def _collect_employee_role_codes(employee: Employee | None) -> set[str]:
if employee is None:
return set()
return {
str(role.role_code or "").strip().lower()
for role in list(employee.roles or [])
if str(role.role_code or "").strip()
}
@staticmethod
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
@@ -293,6 +334,7 @@ class ExpenseClaimAccessPolicy:
[
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
str(current_user.employee_no or "").strip(),
]
)
@@ -309,9 +351,10 @@ class ExpenseClaimAccessPolicy:
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
def is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
claim_employee_id = str(claim.employee_id or "").strip()
current_employee = self.resolve_current_employee(current_user)
if current_employee is not None:
if str(claim.employee_id or "").strip() == current_employee.id:
if claim_employee_id == current_employee.id:
return True
identity_values = {
str(current_employee.name or "").strip(),
@@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy:
{
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
str(current_user.employee_no or "").strip(),
}
)
identity_values.discard("")
if claim_employee_id and claim_employee_id in identity_values:
return True
return str(claim.employee_name or "").strip() in identity_values
@staticmethod
@@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy:
add_condition("employee_name", employee.name)
else:
add_condition("employee_id", username)
add_condition("employee_id", str(current_user.employee_no or "").strip())
add_condition("employee_name", username)
add_condition("employee_name", str(current_user.name or "").strip())
add_condition("employee_name", str(current_user.employee_no or "").strip())
return conditions
@@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy:
return conditions
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return []
employee = self.resolve_current_employee(current_user)
role_codes = self.normalize_role_codes(current_user) | self._collect_employee_role_codes(employee)
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
return []
if employee is None or not self._employee_has_budget_approval_grade(employee):
return []
@@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy:
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
if current_user.is_admin:
return stmt.where(ExpenseClaim.status == "submitted")
conditions = []
if "finance" in role_codes: