Files
wecom_it_smart_desk/backend/app/api/approval.py
T

989 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 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/Textareavalue.text)和 Selectorvalue.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)
# v4.0 批次4GET /approval/keywords 端点已删除
# (前端 getApprovalKeywords 调用已随 v3.0 ApprovalCardModal 重构移除,
# 仅剩 .bak 备份文件引用;关键词匹配现由 ApprovalMatcher 后端统一处理)