feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -12,19 +12,16 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules"
|
||||
|
||||
|
||||
BUDGET_EXPENSE_TYPES = (
|
||||
BUDGET_EXPENSE_TYPES = ("all",)
|
||||
SUPPORTED_DEMO_EXPENSE_TYPES = {
|
||||
"all",
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
FIELD_LABELS = {
|
||||
@@ -456,7 +453,7 @@ RULES: tuple[DemoRiskRule, ...] = (
|
||||
"rule.expense.company_travel_expense_reimbursement",
|
||||
"差旅住宿费标准",
|
||||
("reimbursement",),
|
||||
("travel", "hotel", "transport"),
|
||||
("travel",),
|
||||
"差旅金额达到大额阈值且缺少有效出差申请时触发。",
|
||||
("差旅申请", "大额差旅", "未申请"),
|
||||
"high",
|
||||
@@ -688,6 +685,54 @@ RULES: tuple[DemoRiskRule, ...] = (
|
||||
)
|
||||
|
||||
|
||||
COMMUNICATION_RULES: tuple[DemoRiskRule, ...] = (
|
||||
DemoRiskRule(
|
||||
"risk.standard.communication_amount_over_policy",
|
||||
"通信费金额超过月度标准",
|
||||
"通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。",
|
||||
"费用标准",
|
||||
"expense_standard_over_limit",
|
||||
"expense.communication.policy",
|
||||
"通信费报销规则",
|
||||
("expense_application", "reimbursement"),
|
||||
("communication",),
|
||||
"通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。",
|
||||
("通信费", "话费", "流量费", "宽带费", "超标准"),
|
||||
"medium",
|
||||
"manual_review",
|
||||
68,
|
||||
"medium",
|
||||
field_keys=BASE_FIELDS + ("material.invoice_uploaded",),
|
||||
),
|
||||
DemoRiskRule(
|
||||
"risk.standard.communication_account_mismatch",
|
||||
"通信账户归属与报销人不一致",
|
||||
"通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。",
|
||||
"费用归属",
|
||||
"expense_owner_mismatch",
|
||||
"expense.communication.policy",
|
||||
"通信费报销规则",
|
||||
("reimbursement",),
|
||||
("communication",),
|
||||
"通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。",
|
||||
("号码归属", "账户不一致", "代垫", "统一缴费", "公共号码"),
|
||||
"high",
|
||||
"manual_review",
|
||||
82,
|
||||
"high",
|
||||
requires_attachment=True,
|
||||
field_keys=MATERIAL_FIELDS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _is_supported_demo_rule(rule: DemoRiskRule) -> bool:
|
||||
return all(expense_type in SUPPORTED_DEMO_EXPENSE_TYPES for expense_type in rule.expense_types)
|
||||
|
||||
|
||||
RULES = tuple(rule for rule in RULES if _is_supported_demo_rule(rule)) + COMMUNICATION_RULES
|
||||
|
||||
|
||||
def main() -> None:
|
||||
RISK_RULE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for rule in RULES:
|
||||
|
||||
99
server/scripts/repair_stuck_application_budget_approval.py
Normal file
99
server/scripts/repair_stuck_application_budget_approval.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Iterable
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.session import get_session_factory
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_workflow_constants import BUDGET_MANAGER_APPROVAL_STAGE
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def _role_codes(employee: Employee) -> list[str]:
|
||||
return [
|
||||
str(role.role_code or "").strip().lower()
|
||||
for role in list(employee.roles or [])
|
||||
if str(role.role_code or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def _is_direct_manager(employee: Employee | None, budget_manager: Employee | None) -> bool:
|
||||
if employee is None or budget_manager is None:
|
||||
return False
|
||||
if employee.manager_id and employee.manager_id == budget_manager.id:
|
||||
return True
|
||||
return employee.manager is not None and employee.manager.id == budget_manager.id
|
||||
|
||||
|
||||
def _budget_manager_context(budget_manager: Employee) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username=str(budget_manager.email or budget_manager.employee_no or budget_manager.name or "").strip(),
|
||||
name=str(budget_manager.name or "").strip(),
|
||||
role_codes=_role_codes(budget_manager),
|
||||
is_admin=False,
|
||||
department_name=str(
|
||||
budget_manager.organization_unit.name
|
||||
if budget_manager.organization_unit is not None
|
||||
else ""
|
||||
).strip(),
|
||||
grade=str(budget_manager.grade or "").strip(),
|
||||
employee_no=str(budget_manager.employee_no or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _iter_candidates(service: ExpenseClaimService) -> Iterable[tuple[ExpenseClaim, Employee]]:
|
||||
claims = service.db.query(ExpenseClaim).filter(
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
).all()
|
||||
for claim in claims:
|
||||
if not service._is_expense_application_claim(claim):
|
||||
continue
|
||||
budget_manager = service._access_policy.resolve_department_budget_manager(claim)
|
||||
if budget_manager is None:
|
||||
continue
|
||||
if not _is_direct_manager(claim.employee, budget_manager):
|
||||
continue
|
||||
yield claim, budget_manager
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Repair application claims stuck at budget approval when the direct manager is the budget approver.",
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Apply the repair. Without it, only prints candidates.")
|
||||
args = parser.parse_args()
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
service = ExpenseClaimService(db)
|
||||
candidates = list(_iter_candidates(service))
|
||||
print(f"candidates={len(candidates)}")
|
||||
for claim, budget_manager in candidates:
|
||||
print(
|
||||
"candidate "
|
||||
f"claim_no={claim.claim_no} "
|
||||
f"claim_id={claim.id} "
|
||||
f"employee={claim.employee_name} "
|
||||
f"budget_manager={budget_manager.name}"
|
||||
)
|
||||
if not args.apply:
|
||||
continue
|
||||
|
||||
repaired = service.approve_claim(
|
||||
claim.id,
|
||||
_budget_manager_context(budget_manager),
|
||||
opinion="历史流程修复:直属领导与预算审批人为同一人,合并预算审批。",
|
||||
)
|
||||
print(
|
||||
"repaired "
|
||||
f"claim_no={repaired.claim_no if repaired is not None else claim.claim_no} "
|
||||
f"status={repaired.status if repaired is not None else ''} "
|
||||
f"stage={repaired.approval_stage if repaired is not None else ''}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user