1001 lines
37 KiB
Python
1001 lines
37 KiB
Python
# =============================================================================
|
||
# IT智能服务台 — 审批流程 API
|
||
# =============================================================================
|
||
# 说明:提供审批模板管理和API提交功能
|
||
# - 模板详情获取
|
||
# - API提交审批申请
|
||
# - 审批状态回调处理
|
||
# =============================================================================
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from typing import Optional
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, Query
|
||
from pydantic import BaseModel
|
||
import redis.asyncio as aioredis
|
||
|
||
from app.config import settings
|
||
from app.services.asset_service import AssetService
|
||
from app.services.wecom_service import WecomService
|
||
from app.utils.token_manager import TokenManager
|
||
from app.utils.response import success_response
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
|
||
# IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送)
|
||
ASSET_UPGRADE_TEMPLATE_ID = "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS"
|
||
|
||
# Redis客户端(依赖注入)
|
||
async def get_redis() -> aioredis.Redis:
|
||
"""获取Redis客户端依赖"""
|
||
from app.main import redis_client
|
||
return redis_client
|
||
|
||
|
||
# =============================================================================
|
||
# 审批模板配置(静态硬编码 — 18个审批流程,12种审批类型)
|
||
# =============================================================================
|
||
# 数据来源:docs/02-产品需求/approval_templates.json
|
||
# 所有模板 type 统一为 "jump"(直接URL跳转)
|
||
|
||
APPROVAL_TEMPLATES: dict[str, dict] = {
|
||
# --- 办公用品申请 ---
|
||
"office_supplies": {
|
||
"id": "office_supplies",
|
||
"name": "办公用品超额领用审批",
|
||
"type": "jump",
|
||
"keywords": ["办公用品", "超额", "领用"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "办公用品申请",
|
||
"icon": "gift-o",
|
||
"desc": "超配额申领",
|
||
},
|
||
# --- 设备申请 ---
|
||
# IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效)
|
||
"asset_receive": {
|
||
"id": "asset_receive",
|
||
"name": "IT资产领用申请",
|
||
"type": "jump",
|
||
"keywords": ["资产领用", "领用登记", "设备领用"],
|
||
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
|
||
"location": "运维平台",
|
||
"category": "设备申请",
|
||
"icon": "orders-o",
|
||
"desc": "申请新设备",
|
||
},
|
||
# IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效)
|
||
"asset_borrow": {
|
||
"id": "asset_borrow",
|
||
"name": "IT资产借用申请",
|
||
"type": "jump",
|
||
"keywords": ["资产借用", "借用", "借用设备"],
|
||
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
|
||
"location": "运维平台",
|
||
"category": "设备申请",
|
||
"icon": "orders-o",
|
||
"desc": "临时借用设备",
|
||
},
|
||
# IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效)
|
||
"asset_upgrade": {
|
||
"id": "asset_upgrade",
|
||
"name": "IT资产升级申请",
|
||
"type": "jump",
|
||
"keywords": ["资产升级", "设备升级", "升级"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE",
|
||
"location": "运维平台",
|
||
"category": "设备申请",
|
||
"icon": "orders-o",
|
||
"desc": "设备升级换新",
|
||
},
|
||
"it_device_repair": {
|
||
"id": "it_device_repair",
|
||
"name": "IT设备升级与硬件维修",
|
||
"type": "jump",
|
||
"keywords": ["设备升级", "硬件维修", "设备维修"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE",
|
||
"location": "运维平台",
|
||
"category": "设备申请",
|
||
"icon": "warn-o",
|
||
"desc": "设备维修",
|
||
},
|
||
# --- 账号权限申请 ---
|
||
"wecom_external": {
|
||
"id": "wecom_external",
|
||
"name": "企微外联权限申请",
|
||
"type": "jump",
|
||
"keywords": ["外联", "企微外联", "外部联系人"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "账号权限申请",
|
||
"icon": "lock",
|
||
"desc": "外部联系人权限",
|
||
},
|
||
"zero_trust_vpn": {
|
||
"id": "zero_trust_vpn",
|
||
"name": "员工零信任(原VPN)账号",
|
||
"type": "jump",
|
||
"keywords": ["VPN", "vpn", "零信任", "VPN账号"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||
"location": "运维平台",
|
||
"category": "账号权限申请",
|
||
"icon": "lock",
|
||
"desc": "零信任VPN",
|
||
},
|
||
# --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效)
|
||
"software_service": {
|
||
"id": "software_service",
|
||
"name": "商业软件服务申请",
|
||
"type": "jump",
|
||
"keywords": ["软件", "商业软件", "软件服务"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7",
|
||
"location": "运维平台",
|
||
"category": "软件服务申请",
|
||
"icon": "apps-o",
|
||
"desc": "正版软件授权",
|
||
},
|
||
# --- 资产处置申请 ---
|
||
"asset_repair": {
|
||
"id": "asset_repair",
|
||
"name": "IT资产外修申请",
|
||
"type": "jump",
|
||
"keywords": ["外修", "资产外修", "设备维修"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "资产处置申请",
|
||
"icon": "warn-o",
|
||
"desc": "设备送修",
|
||
},
|
||
"asset_scrap": {
|
||
"id": "asset_scrap",
|
||
"name": "IT资产报废申请",
|
||
"type": "jump",
|
||
"keywords": ["报废", "资产报废", "设备报废"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "资产处置申请",
|
||
"icon": "delete-o",
|
||
"desc": "设备报废",
|
||
},
|
||
"asset_return": {
|
||
"id": "asset_return",
|
||
"name": "资产退还登记",
|
||
"type": "jump",
|
||
"keywords": ["退还", "资产退还", "退还设备"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "资产处置申请",
|
||
"icon": "back-top",
|
||
"desc": "退还设备",
|
||
},
|
||
# --- 会议室故障报修 ---
|
||
"meeting_room_repair": {
|
||
"id": "meeting_room_repair",
|
||
"name": "会议室故障报修",
|
||
"type": "jump",
|
||
"keywords": ["会议室", "故障报修", "报修"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "会议室故障报修",
|
||
"icon": "warn-o",
|
||
"desc": "会议室设备故障",
|
||
},
|
||
# --- 企业应用管理 ---
|
||
"app_management": {
|
||
"id": "app_management",
|
||
"name": "企业应用管理",
|
||
"type": "jump",
|
||
"keywords": ["企业应用", "应用管理", "应用开通"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "企业应用管理",
|
||
"icon": "apps-o",
|
||
"desc": "企业应用开通管理",
|
||
},
|
||
# --- 资产变更确认 ---
|
||
"asset_change": {
|
||
"id": "asset_change",
|
||
"name": "资产变更确认",
|
||
"type": "jump",
|
||
"keywords": ["资产变更", "变更确认", "变更"],
|
||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list",
|
||
"location": "企微审批",
|
||
"category": "资产变更确认",
|
||
"icon": "exchange",
|
||
"desc": "资产信息变更",
|
||
},
|
||
# --- 终端设备网络准入 ---
|
||
"network_access": {
|
||
"id": "network_access",
|
||
"name": "终端设备网络准入申请",
|
||
"type": "jump",
|
||
"keywords": ["网络准入", "终端准入", "准入申请"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7",
|
||
"location": "运维平台",
|
||
"category": "终端设备网络准入",
|
||
"icon": "lock",
|
||
"desc": "终端网络准入",
|
||
},
|
||
# --- 活动与会议技术支持 ---
|
||
"event_support": {
|
||
"id": "event_support",
|
||
"name": "活动与会议技术支持",
|
||
"type": "jump",
|
||
"keywords": ["活动支持", "会议支持", "技术支持", "活动技术"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81",
|
||
"location": "运维平台",
|
||
"category": "活动与会议技术支持",
|
||
"icon": "calendar-o",
|
||
"desc": "活动会议技术保障",
|
||
},
|
||
# --- 员工IT支持与故障报修 ---
|
||
"it_support_repair": {
|
||
"id": "it_support_repair",
|
||
"name": "员工IT支持与故障报修",
|
||
"type": "jump",
|
||
"keywords": ["故障报修", "IT支持", "技术支持", "报修"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE",
|
||
"location": "运维平台",
|
||
"category": "员工IT支持与故障报修",
|
||
"icon": "warn-o",
|
||
"desc": "IT支持与故障报修",
|
||
},
|
||
# --- 公共邮箱账号申请 ---
|
||
"public_email": {
|
||
"id": "public_email",
|
||
"name": "公共邮箱账号申请",
|
||
"type": "jump",
|
||
"keywords": ["公共邮箱", "公共账号", "共享邮箱"],
|
||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||
"location": "运维平台",
|
||
"category": "公共邮箱账号申请",
|
||
"icon": "lock",
|
||
"desc": "公共邮箱账号",
|
||
},
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 审批意图识别 — 关键词预过滤 & 降级兜底
|
||
# =============================================================================
|
||
# 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息,
|
||
# 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。
|
||
|
||
# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13)
|
||
# ------------------------------------------------------------
|
||
# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。
|
||
#
|
||
# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等),
|
||
# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。
|
||
#
|
||
# 改造后策略:
|
||
# - 强意图动词:用户明确表达"申请/提交"意愿时才触发
|
||
# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现
|
||
# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等)
|
||
# 改由 Dify 意图识别内部判断,不作为预过滤触发条件
|
||
#
|
||
# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用
|
||
APPROVAL_PREFILTER_KEYWORDS: list[str] = [
|
||
# === 强意图动词/短语(用户明确表达申请意愿)===
|
||
"申请", "审批", "提交", "表单", "走流程",
|
||
"帮我申请", "我要申请", "需要申请", "想申请",
|
||
# === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)===
|
||
"资产领用", "资产借用", "资产升级", "资产变更", "资产处置",
|
||
"网络准入", "终端准入",
|
||
"办公用品", "超额领用",
|
||
"企业应用管理", "应用开通",
|
||
"公共邮箱", "共享邮箱", "公共账号",
|
||
"故障报修", "IT支持",
|
||
"自备电脑", "电脑补贴", "BYOD", "byod",
|
||
]
|
||
|
||
# 关键词到审批类型的映射(Dify 不可用时降级兜底用)
|
||
KEYWORD_TO_APPROVAL_TYPE: dict[str, str] = {
|
||
# 设备申请
|
||
"设备": "设备申请", "电脑": "设备申请", "笔记本": "设备申请",
|
||
"显示器": "设备申请", "资产领用": "设备申请", "资产借用": "设备申请", "资产升级": "设备申请",
|
||
# 账号权限申请
|
||
"VPN": "账号权限申请", "vpn": "账号权限申请", "账号": "账号权限申请",
|
||
"邮箱": "账号权限申请", "外联": "账号权限申请", "权限": "账号权限申请",
|
||
# 软件服务申请
|
||
"软件": "软件服务申请", "业务系统": "软件服务申请",
|
||
# 资产处置申请
|
||
"外修": "资产处置申请", "报废": "资产处置申请", "退还": "资产处置申请",
|
||
# 办公用品申请
|
||
"办公用品": "办公用品申请", "超额": "办公用品申请",
|
||
# 会议室故障报修
|
||
"会议室": "会议室故障报修", "故障报修": "会议室故障报修", "报修": "会议室故障报修",
|
||
# 企业应用管理
|
||
"企业应用": "企业应用管理", "应用管理": "企业应用管理", "应用开通": "企业应用管理",
|
||
# 资产变更确认
|
||
"资产变更": "资产变更确认", "变更确认": "资产变更确认", "变更": "资产变更确认",
|
||
# 终端设备网络准入
|
||
"网络准入": "终端设备网络准入", "终端准入": "终端设备网络准入", "准入申请": "终端设备网络准入",
|
||
# 活动与会议技术支持
|
||
"活动支持": "活动与会议技术支持", "会议支持": "活动与会议技术支持", "活动技术": "活动与会议技术支持",
|
||
# 员工IT支持与故障报修
|
||
"IT支持": "员工IT支持与故障报修",
|
||
# 公共邮箱账号申请
|
||
"公共邮箱": "公共邮箱账号申请", "公共账号": "公共邮箱账号申请", "共享邮箱": "公共邮箱账号申请",
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# Schema 定义
|
||
# =============================================================================
|
||
|
||
class ApprovalTemplateResponse(BaseModel):
|
||
"""审批模板响应"""
|
||
id: str
|
||
name: str
|
||
type: str
|
||
keywords: list[str]
|
||
|
||
|
||
class ApprovalJumpRequest(BaseModel):
|
||
"""跳转审批请求"""
|
||
template_id: str
|
||
employee_id: Optional[str] = None
|
||
|
||
|
||
class ApprovalJumpResponse(BaseModel):
|
||
"""跳转审批响应"""
|
||
url: str
|
||
template_name: str
|
||
|
||
|
||
class ApprovalContentItem(BaseModel):
|
||
"""审批表单控件内容"""
|
||
control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc.
|
||
id: str # 控件ID
|
||
value: dict # 控件值
|
||
|
||
|
||
class ApprovalSubmitRequest(BaseModel):
|
||
"""API提交审批请求"""
|
||
template_id: str
|
||
employee_id: str # 申请人userid
|
||
contents: list[ApprovalContentItem] # 表单内容
|
||
use_template_approver: int = 1 # 1-使用模板预设流程
|
||
|
||
|
||
class ApprovalSubmitResponse(BaseModel):
|
||
"""API提交审批响应"""
|
||
sp_no: str # 审批单号
|
||
template_name: str
|
||
|
||
|
||
class ApprovalCallbackRequest(BaseModel):
|
||
"""审批回调请求(XML解析后的模型)"""
|
||
sp_no: str
|
||
sp_name: str
|
||
template_id: str
|
||
apply_time: int
|
||
applyer_userid: str
|
||
sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
|
||
status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注
|
||
|
||
|
||
class ApprovalUrgeRequest(BaseModel):
|
||
"""审批推送请求 — 手动触发资产年限核查+推送"""
|
||
sp_no: str
|
||
|
||
|
||
# =============================================================================
|
||
# 企微API调用辅助函数
|
||
# =============================================================================
|
||
|
||
async def get_approval_token(redis: aioredis.Redis) -> str:
|
||
"""获取企微access_token(使用IT支持应用Secret,IP已在白名单中)。"""
|
||
manager = TokenManager(redis)
|
||
try:
|
||
return await manager.get_token()
|
||
finally:
|
||
await manager.close()
|
||
|
||
|
||
async def get_template_detail(access_token: str, template_id: str) -> dict:
|
||
"""获取审批模板详情
|
||
|
||
对应企微API:
|
||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail
|
||
|
||
返回模板内的控件构成及控件ID
|
||
"""
|
||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail"
|
||
params = {"access_token": access_token}
|
||
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
|
||
response = await client.post(url, params=params, json={"template_id": template_id})
|
||
result = response.json()
|
||
|
||
if result.get("errcode") != 0:
|
||
logger.error(f"获取模板详情失败: {result.get('errmsg')}")
|
||
raise Exception(f"获取模板详情失败: {result.get('errmsg')}")
|
||
|
||
return result
|
||
|
||
|
||
async def submit_approval_api(
|
||
access_token: str,
|
||
template_id: str,
|
||
creator_userid: str,
|
||
contents: list[dict],
|
||
use_template_approver: int = 1
|
||
) -> dict:
|
||
"""提交审批申请
|
||
|
||
对应企微API:
|
||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent
|
||
|
||
Args:
|
||
access_token: 审批应用access_token
|
||
template_id: 模板ID
|
||
creator_userid: 申请人userid
|
||
contents: 表单控件内容列表
|
||
use_template_approver: 1-使用模板预设流程
|
||
|
||
Returns:
|
||
{"sp_no": "审批单号"}
|
||
"""
|
||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent"
|
||
params = {"access_token": access_token}
|
||
|
||
payload = {
|
||
"creator_userid": creator_userid,
|
||
"template_id": template_id,
|
||
"use_template_approver": use_template_approver,
|
||
"apply_data": {
|
||
"contents": contents
|
||
}
|
||
}
|
||
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
|
||
response = await client.post(url, params=params, json=payload)
|
||
result = response.json()
|
||
|
||
if result.get("errcode") != 0:
|
||
logger.error(f"提交审批失败: {result.get('errmsg')}")
|
||
raise Exception(f"提交审批失败: {result.get('errmsg')}")
|
||
|
||
return {"sp_no": result.get("sp_no")}
|
||
|
||
|
||
# =============================================================================
|
||
# 资产升级审批推送 — 辅助函数
|
||
# =============================================================================
|
||
|
||
async def get_approval_detail(access_token: str, sp_no: str) -> dict:
|
||
"""获取审批申请详情。
|
||
|
||
对应企微API:
|
||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail
|
||
|
||
返回审批单的表单数据、审批流程节点、当前审批人等信息。
|
||
|
||
Args:
|
||
access_token: 审批应用 access_token
|
||
sp_no: 审批单号
|
||
|
||
Returns:
|
||
dict: 企微API返回的完整审批详情
|
||
|
||
Raises:
|
||
Exception: API调用失败或返回错误
|
||
"""
|
||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail"
|
||
params = {"access_token": access_token}
|
||
payload = {"sp_no": sp_no}
|
||
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
|
||
response = await client.post(url, params=params, json=payload)
|
||
result = response.json()
|
||
|
||
if result.get("errcode") != 0:
|
||
logger.error(f"获取审批详情失败: sp_no={sp_no}, errmsg={result.get('errmsg')}")
|
||
raise Exception(f"获取审批详情失败: {result.get('errmsg')}")
|
||
|
||
return result
|
||
|
||
|
||
async def get_approval_data(
|
||
access_token: str,
|
||
starttime: int,
|
||
endtime: int,
|
||
filters: Optional[list] = None,
|
||
cursor: int = 0,
|
||
size: int = 100,
|
||
) -> dict:
|
||
"""获取审批申请数据列表。
|
||
|
||
对应企微API:
|
||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata
|
||
|
||
按时间范围和过滤条件查询审批单列表,返回审批单号及基本信息。
|
||
支持 cursor 分页。
|
||
|
||
Args:
|
||
access_token: 审批应用 access_token
|
||
starttime: 查询开始时间(秒级 Unix 时间戳)
|
||
endtime: 查询结束时间(秒级 Unix 时间戳)
|
||
filters: 过滤条件列表,格式 [{"key": "template_id", "value": "xxx"}, ...]
|
||
cursor: 分页游标(从 0 开始)
|
||
size: 单页返回数量(最大 100)
|
||
|
||
Returns:
|
||
dict: 企微API返回的完整响应,包含 data(审批列表)、total、next_cursor
|
||
|
||
Raises:
|
||
Exception: API调用失败或返回错误
|
||
"""
|
||
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata"
|
||
params = {"access_token": access_token}
|
||
payload = {
|
||
"starttime": starttime,
|
||
"endtime": endtime,
|
||
"cursor": cursor,
|
||
"size": size,
|
||
}
|
||
if filters:
|
||
payload["filters"] = filters
|
||
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
|
||
response = await client.post(url, params=params, json=payload)
|
||
result = response.json()
|
||
|
||
if result.get("errcode") != 0:
|
||
logger.error(
|
||
f"getapprovaldata 调用失败: errcode={result.get('errcode')}, "
|
||
f"errmsg={result.get('errmsg')}"
|
||
)
|
||
raise Exception(f"获取审批数据列表失败: {result.get('errmsg')}")
|
||
|
||
return result
|
||
|
||
|
||
def _extract_asset_code(approval_detail: dict) -> Optional[str]:
|
||
"""从审批单表单数据中提取资产编号。
|
||
|
||
遍历 apply_data.contents,查找标题包含"资产编号"/"固定资产编号"等关键字的控件,
|
||
提取其值。支持 Text/Textarea(value.text)和 Selector(value.value)两种控件类型。
|
||
|
||
Args:
|
||
approval_detail: get_approval_detail 返回的完整审批详情
|
||
|
||
Returns:
|
||
str: 资产编号,提取失败返回 None
|
||
"""
|
||
try:
|
||
contents = (
|
||
approval_detail
|
||
.get("info", {})
|
||
.get("apply_data", {})
|
||
.get("contents", [])
|
||
)
|
||
# 资产编号可能的标题名
|
||
asset_code_titles = {"资产编号", "固定资产编号", "资产编码", "设备编号"}
|
||
|
||
for item in contents:
|
||
# 检查控件标题
|
||
title_list = item.get("title", [])
|
||
for title_obj in title_list:
|
||
title_text = title_obj.get("text", "")
|
||
if title_text in asset_code_titles:
|
||
# 提取控件值
|
||
value = item.get("value", {})
|
||
# Text / Textarea 类型:value.text
|
||
text = value.get("text", "")
|
||
if text:
|
||
return text.strip()
|
||
# Selector 类型:value.value
|
||
sel_value = value.get("value", "")
|
||
if sel_value:
|
||
return sel_value.strip()
|
||
except Exception as e:
|
||
logger.error(f"提取资产编号失败: {e}")
|
||
return None
|
||
|
||
|
||
def _extract_current_approver(approval_detail: dict) -> Optional[str]:
|
||
"""从审批单详情中提取当前审批人 userid。
|
||
|
||
遍历 sp_record,查找 sp_status=1(审批中)的节点,
|
||
返回其 details 中第一个 sp_status=1(待处理)的 approver.userid。
|
||
|
||
注意:企微 API 实际返回的字段名:
|
||
- sp_record[].sp_status(非 status)
|
||
- sp_record[].approverattr(非 approverAttr)
|
||
- sp_record[].details[].approver.userid(非 sp_record[].approver[].userid)
|
||
|
||
Args:
|
||
approval_detail: get_approval_detail 返回的完整审批详情
|
||
|
||
Returns:
|
||
str: 当前审批人 userid,提取失败返回 None
|
||
"""
|
||
try:
|
||
sp_record = approval_detail.get("info", {}).get("sp_record", [])
|
||
for record in sp_record:
|
||
# 企微 API 字段名为 sp_status(非 status),1=审批中
|
||
if record.get("sp_status") != 1:
|
||
continue
|
||
|
||
# 审批人信息在 details[].approver 中(非 record.approver)
|
||
details = record.get("details", [])
|
||
for detail in details:
|
||
# detail.sp_status=1 表示该审批人尚未处理
|
||
if detail.get("sp_status") == 1:
|
||
approver = detail.get("approver", {})
|
||
if isinstance(approver, dict):
|
||
userid = approver.get("userid", "")
|
||
if userid:
|
||
return userid
|
||
elif isinstance(approver, list) and len(approver) > 0:
|
||
userid = approver[0].get("userid", "")
|
||
if userid:
|
||
return userid
|
||
except Exception as e:
|
||
logger.error(f"提取当前审批人失败: {e}")
|
||
return None
|
||
|
||
|
||
def _build_urge_description(check_result: dict, applyer_userid: str) -> str:
|
||
"""构建推送卡片的描述文本。
|
||
|
||
根据资产核查结果拼接多行描述,包含资产编号、设备名称、启用日期、
|
||
已使用年限、核查结论、申请人等信息。
|
||
|
||
Args:
|
||
check_result: AssetService.check_device_age 返回的核查结果
|
||
applyer_userid: 申请人 userid
|
||
|
||
Returns:
|
||
str: 多行描述文本
|
||
"""
|
||
lines = []
|
||
|
||
# 资产编号
|
||
lines.append(f"资产编号:{check_result.get('asset_code', '未知')}")
|
||
|
||
# 设备名称
|
||
if check_result.get("asset_name"):
|
||
lines.append(f"设备名称:{check_result['asset_name']}")
|
||
|
||
# 启用日期
|
||
if check_result.get("start_date"):
|
||
lines.append(f"启用日期:{check_result['start_date']}")
|
||
|
||
# 已使用年限
|
||
if check_result.get("years_display"):
|
||
lines.append(f"已使用:{check_result['years_display']}")
|
||
|
||
# 核查结论
|
||
threshold = settings.asset_replacement_threshold_years
|
||
if check_result.get("found") and check_result.get("meets_threshold") is not None:
|
||
if check_result["meets_threshold"]:
|
||
lines.append(f"核查结论:✅ 已满{threshold}年,符合更换条件")
|
||
else:
|
||
lines.append(f"核查结论:❌ 未满{threshold}年,不符合更换条件")
|
||
elif not check_result.get("found"):
|
||
lines.append("核查结论:⚠️ 未找到资产记录,请人工核查")
|
||
else:
|
||
lines.append("核查结论:⚠️ 无法计算年限,请人工核查")
|
||
|
||
# 申请人
|
||
lines.append(f"申请人:{applyer_userid}")
|
||
|
||
# 提示
|
||
lines.append("请点击查看审批详情")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _do_asset_urge(sp_no: str, redis: aioredis.Redis) -> dict:
|
||
"""执行资产年限核查 + 向审批人推送卡片消息的完整逻辑。
|
||
|
||
流程:
|
||
1. 获取审批应用 access_token
|
||
2. 调用 oa/getapprovaldetail 获取审批单详情
|
||
3. 从表单数据提取资产编号
|
||
4. 从审批流程提取当前审批人 userid
|
||
5. 用 AssetService 核查设备使用年限
|
||
6. 向当前审批人推送企微文本卡片消息
|
||
|
||
所有步骤均有 try/except 保护,失败时记日志但不抛出异常。
|
||
|
||
Args:
|
||
sp_no: 审批单号
|
||
redis: Redis 客户端(用于获取 access_token)
|
||
|
||
Returns:
|
||
dict: 执行结果,包含 success/message 以及核查详情
|
||
"""
|
||
try:
|
||
# 1. 获取审批应用 access_token
|
||
token = await get_approval_token(redis)
|
||
|
||
# 2. 获取审批单详情
|
||
detail = await get_approval_detail(token, sp_no)
|
||
|
||
# 3. 提取资产编号
|
||
asset_code = _extract_asset_code(detail)
|
||
if not asset_code:
|
||
logger.warning(f"审批单 {sp_no} 未能提取资产编号")
|
||
return {"success": False, "message": "未能从审批表单提取资产编号", "sp_no": sp_no}
|
||
|
||
# 4. 提取当前审批人
|
||
approver = _extract_current_approver(detail)
|
||
if not approver:
|
||
logger.warning(f"审批单 {sp_no} 未能提取当前审批人")
|
||
return {"success": False, "message": "未能提取当前审批人 userid", "sp_no": sp_no}
|
||
|
||
# 5. 提取申请人 userid
|
||
applyer_userid = (
|
||
detail.get("info", {}).get("applyer", {}).get("userid", "")
|
||
)
|
||
|
||
# 6. 核查资产年限
|
||
asset_service = AssetService()
|
||
threshold = settings.asset_replacement_threshold_years
|
||
check_result = asset_service.check_device_age(asset_code, threshold)
|
||
|
||
# 7. 构建并推送卡片消息
|
||
description = _build_urge_description(check_result, applyer_userid)
|
||
card_url = (
|
||
f"https://app.work.weixin.qq.com/wework_admin/approval_v3"
|
||
f"#/?template_id={ASSET_UPGRADE_TEMPLATE_ID}&sp_id={sp_no}"
|
||
)
|
||
|
||
# 使用 IT 支持应用的 access_token 发送消息(非审批应用 token)
|
||
wecom_service = WecomService(redis_client=redis)
|
||
await wecom_service.send_card_message(
|
||
user_id=approver,
|
||
title="IT资产升级审批 — 设备年限核查",
|
||
description=description,
|
||
url=card_url,
|
||
btntxt="去审批",
|
||
)
|
||
|
||
logger.info(f"资产年限核查推送成功: sp_no={sp_no}, approver={approver}")
|
||
return {
|
||
"success": True,
|
||
"message": "推送成功",
|
||
"sp_no": sp_no,
|
||
"approver": approver,
|
||
"asset_code": asset_code,
|
||
"check_result": check_result,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"资产年限核查推送失败: sp_no={sp_no}, error={e}")
|
||
return {"success": False, "message": f"推送失败: {e}", "sp_no": sp_no}
|
||
|
||
|
||
# =============================================================================
|
||
# API 端点
|
||
# =============================================================================
|
||
|
||
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
|
||
async def get_approval_templates():
|
||
"""获取所有审批模板列表"""
|
||
return list(APPROVAL_TEMPLATES.values())
|
||
|
||
|
||
@router.get("/approval/all-categories-card")
|
||
async def get_all_categories_card():
|
||
"""v4.0 P0-3: 获取全量审批卡片数据(快捷申请按钮用)
|
||
|
||
返回标准化 card_data,前端纯渲染(无需前端再做匹配)。
|
||
与 ApprovalCardModal 的 CardData 接口结构一致。
|
||
"""
|
||
from app.services.approval_matcher import get_approval_matcher
|
||
matcher = get_approval_matcher()
|
||
categories = matcher.get_all_categories()
|
||
|
||
# 扁平化所有分类的 options
|
||
all_options = []
|
||
for cat in categories:
|
||
all_options.extend(cat.get("options", []))
|
||
|
||
return {
|
||
"card_type": "multiple",
|
||
"title": "审批申请",
|
||
"description": "请选择审批类型",
|
||
"options": all_options,
|
||
}
|
||
|
||
|
||
@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse)
|
||
async def get_approval_template(template_id: str):
|
||
"""获取指定审批模板详情"""
|
||
if template_id not in APPROVAL_TEMPLATES:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
return APPROVAL_TEMPLATES[template_id]
|
||
|
||
|
||
@router.get("/approval/templates/{template_id}/detail")
|
||
async def get_template_full_detail(
|
||
template_id: str,
|
||
redis: aioredis.Redis = Depends(get_redis)
|
||
):
|
||
"""获取审批模板完整详情(控件结构)"""
|
||
if template_id not in APPROVAL_TEMPLATES:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
try:
|
||
token = await get_approval_token(redis)
|
||
detail = await get_template_detail(token, template_id)
|
||
return detail
|
||
except Exception as e:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/approval/jump")
|
||
async def create_approval_jump(request: ApprovalJumpRequest):
|
||
"""生成跳转审批链接"""
|
||
template = APPROVAL_TEMPLATES.get(request.template_id)
|
||
if not template:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
if template["type"] != "jump":
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
|
||
|
||
# 生成跳转URL(企微审批链接格式)
|
||
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
|
||
|
||
return success_response(data=ApprovalJumpResponse(
|
||
url=jump_url,
|
||
template_name=template["name"],
|
||
))
|
||
|
||
|
||
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
|
||
async def submit_approval(
|
||
request: ApprovalSubmitRequest,
|
||
redis: aioredis.Redis = Depends(get_redis)
|
||
):
|
||
"""API提交审批申请"""
|
||
template = APPROVAL_TEMPLATES.get(request.template_id)
|
||
if not template:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=404, detail="模板不存在")
|
||
|
||
if template["type"] != "api":
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=400, detail="该模板不支持API提交")
|
||
|
||
try:
|
||
# 1. 获取审批token
|
||
token = await get_approval_token(redis)
|
||
|
||
# 2. 转换contents格式
|
||
contents = [item.model_dump() for item in request.contents]
|
||
|
||
# 3. 提交审批
|
||
result = await submit_approval_api(
|
||
access_token=token,
|
||
template_id=request.template_id,
|
||
creator_userid=request.employee_id,
|
||
contents=contents,
|
||
use_template_approver=request.use_template_approver
|
||
)
|
||
|
||
return ApprovalSubmitResponse(
|
||
sp_no=result["sp_no"],
|
||
template_name=template["name"],
|
||
)
|
||
|
||
except Exception as e:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/approval/callback")
|
||
async def approval_callback(
|
||
sp_no: str = Query(...),
|
||
sp_name: str = Query(...),
|
||
template_id: str = Query(...),
|
||
apply_time: int = Query(...),
|
||
applyer_userid: str = Query(...),
|
||
sp_status: int = Query(...),
|
||
status_change_event: int = Query(...),
|
||
redis: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""审批状态变化回调处理
|
||
|
||
对应企微审批回调事件: sys_approval_change
|
||
|
||
状态变化类型 (status_change_event):
|
||
1 - 提单
|
||
2 - 同意
|
||
3 - 驳回
|
||
4 - 转审
|
||
5 - 催办
|
||
6 - 撤销
|
||
8 - 通过后撤销
|
||
10 - 添加备注
|
||
|
||
审批单状态 (sp_status):
|
||
1 - 审批中
|
||
2 - 已通过
|
||
3 - 已驳回
|
||
4 - 已撤销
|
||
6 - 通过后撤销
|
||
7 - 已删除
|
||
10 - 已支付
|
||
"""
|
||
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
|
||
|
||
# TODO: 根据业务需求处理审批状态变化
|
||
# 例如:
|
||
# - 审批通过后,更新IT服务台待办状态
|
||
# - 审批驳回后,通知申请人
|
||
# - 审批撤销后,关闭相关工单
|
||
|
||
event_map = {
|
||
1: "submitted",
|
||
2: "approved",
|
||
3: "rejected",
|
||
4: "transferred",
|
||
5: "reminded",
|
||
6: "revoked",
|
||
8: "revoked_after_approved",
|
||
10: "commented"
|
||
}
|
||
|
||
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
|
||
logger.info(f"审批事件类型: {event_type}")
|
||
|
||
# IT资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应)
|
||
if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID:
|
||
logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}")
|
||
asyncio.create_task(_do_asset_urge(sp_no, redis))
|
||
|
||
return {"errcode": 0, "errmsg": "ok"}
|
||
|
||
|
||
@router.post("/approval/urge")
|
||
async def urge_approval(
|
||
request: ApprovalUrgeRequest,
|
||
redis: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""手动触发审批推送 — 资产年限核查 + 向当前审批人推送卡片消息。
|
||
|
||
用于回调未触发或需重新推送的场景。内部调用 _do_asset_urge 执行完整逻辑。
|
||
|
||
Args:
|
||
request: 包含 sp_no(审批单号)
|
||
redis: Redis 客户端
|
||
|
||
Returns:
|
||
success_response: 包含推送结果和核查详情
|
||
"""
|
||
result = await _do_asset_urge(request.sp_no, redis)
|
||
return success_response(data=result)
|
||
|
||
|
||
@router.get("/approval/keywords")
|
||
async def get_approval_keywords():
|
||
"""获取所有审批关键词(用于前端关键词检测)"""
|
||
keywords = []
|
||
for template in APPROVAL_TEMPLATES.values():
|
||
for kw in template["keywords"]:
|
||
keywords.append({
|
||
"keyword": kw,
|
||
"template_id": template["id"],
|
||
"template_name": template["name"],
|
||
"type": template["type"],
|
||
})
|
||
return success_response(data=keywords)
|
||
|
||
|