Part A: 数据层
- 新增 WechatCardState 模型(order_id ↔ alarm_id 映射 + response_code)
- 新建 models_iot.py(IoT 工单只读 ORM:ops_order + security_ext + clean_ext)
- config.py 新增 IOT_DATABASE_URL 配置
Part B: 企微解耦(alarm_id → order_id)
- wechat_service: response_code 存储迁移到 wechat_card_state,集中 helper
- 卡片发送/更新方法改用 order_id,按钮 key: confirm_{order_id}
- wechat_callback: 按钮解析改 order_id,反查 alarm_id(可空)
- wechat_notify_api: send-card/sync-status 以 orderId 为主键
- yudao_aiot_alarm: 卡片操作改用 order_id,删重复 helper
Part C: Agent 工具全面改为工单驱动
- 新建 order_query.py(查 IoT ops_order,支持安保+保洁工单)
- 新建 order_action.py(操作工单状态 + 提交处理结果)
- 更新 prompts.py 为工单助手
- 更新工具注册(__init__.py)
Part D: 日报改为工单驱动
- daily_report_service 从查 alarm_event 改为查 IoT ops_order + 扩展表
- 支持安保+保洁工单统计
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
||
工单查询工具:统计、列表、详情(查 IoT ops_order + 扩展表)
|
||
支持安保工单(SECURITY)和保洁工单(CLEAN)
|
||
"""
|
||
|
||
import json
|
||
from datetime import timedelta
|
||
from typing import Optional
|
||
from langchain_core.tools import tool
|
||
from langchain_core.runnables import RunnableConfig
|
||
|
||
from app.utils.logger import logger
|
||
from app.utils.timezone import beijing_now
|
||
|
||
|
||
# 告警类型中文映射
|
||
ALARM_TYPE_NAMES = {
|
||
"leave_post": "人员离岗", "intrusion": "周界入侵",
|
||
"illegal_parking": "车辆违停", "vehicle_congestion": "车辆拥堵",
|
||
}
|
||
|
||
# 工单状态映射
|
||
ORDER_STATUS_NAMES = {
|
||
"PENDING": "待处理", "ASSIGNED": "已派单", "ARRIVED": "已到岗",
|
||
"PAUSED": "已暂停", "COMPLETED": "已完成", "CANCELLED": "已取消",
|
||
}
|
||
|
||
# 工单优先级映射
|
||
PRIORITY_NAMES = {0: "低", 1: "中", 2: "高"}
|
||
|
||
# 保洁类型映射
|
||
CLEANING_TYPE_NAMES = {
|
||
"ROUTINE": "日常保洁", "DEEP": "深度保洁",
|
||
"SPOT": "点状保洁", "EMERGENCY": "应急保洁",
|
||
}
|
||
|
||
|
||
def _parse_time_range(time_range: str):
|
||
"""解析时间范围,返回 (start_time, label)"""
|
||
now = beijing_now()
|
||
if time_range == "week":
|
||
start = now - timedelta(days=now.weekday())
|
||
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return start, "本周"
|
||
elif time_range == "month":
|
||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
return start, "本月"
|
||
elif time_range == "yesterday":
|
||
start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return start, "昨日"
|
||
else:
|
||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return start, "今日"
|
||
|
||
|
||
def _get_camera_display_name(device_id: str) -> str:
|
||
"""同步获取摄像头显示名称"""
|
||
try:
|
||
import asyncio
|
||
from app.services.camera_name_service import get_camera_name_service
|
||
camera_service = get_camera_name_service()
|
||
loop = asyncio.get_event_loop()
|
||
if loop.is_running():
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||
cam_info = pool.submit(
|
||
asyncio.run, camera_service.get_camera_info(device_id)
|
||
).result(timeout=5)
|
||
else:
|
||
cam_info = asyncio.run(camera_service.get_camera_info(device_id))
|
||
return camera_service.format_display_name(device_id, cam_info)
|
||
except Exception:
|
||
return device_id
|
||
|
||
|
||
def _query_orders(
|
||
order_type: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
start_time=None,
|
||
end_time=None,
|
||
limit: int = 100,
|
||
assignee_name: Optional[str] = None,
|
||
):
|
||
"""查询 IoT 工单(跨库只读)"""
|
||
from app.models_iot import get_iot_session, IotOpsOrder
|
||
db = get_iot_session()
|
||
try:
|
||
q = db.query(IotOpsOrder).filter(IotOpsOrder.deleted == 0)
|
||
if order_type and order_type != "ALL":
|
||
q = q.filter(IotOpsOrder.order_type == order_type)
|
||
if status:
|
||
q = q.filter(IotOpsOrder.status == status)
|
||
if start_time:
|
||
q = q.filter(IotOpsOrder.create_time >= start_time)
|
||
if end_time:
|
||
q = q.filter(IotOpsOrder.create_time < end_time)
|
||
if assignee_name:
|
||
q = q.filter(IotOpsOrder.assignee_name.contains(assignee_name))
|
||
|
||
total = q.count()
|
||
orders = q.order_by(IotOpsOrder.create_time.desc()).limit(limit).all()
|
||
|
||
# 提取所有 order_id 用于关联查询扩展表
|
||
order_ids = [o.id for o in orders]
|
||
|
||
# 批量查安保扩展
|
||
sec_ext_map = {}
|
||
if order_ids:
|
||
from app.models_iot import IotOpsOrderSecurityExt
|
||
sec_exts = db.query(IotOpsOrderSecurityExt).filter(
|
||
IotOpsOrderSecurityExt.ops_order_id.in_(order_ids),
|
||
IotOpsOrderSecurityExt.deleted == 0,
|
||
).all()
|
||
sec_ext_map = {e.ops_order_id: e for e in sec_exts}
|
||
|
||
# 批量查保洁扩展
|
||
clean_ext_map = {}
|
||
if order_ids:
|
||
from app.models_iot import IotOpsOrderCleanExt
|
||
clean_exts = db.query(IotOpsOrderCleanExt).filter(
|
||
IotOpsOrderCleanExt.ops_order_id.in_(order_ids),
|
||
IotOpsOrderCleanExt.deleted == 0,
|
||
).all()
|
||
clean_ext_map = {e.ops_order_id: e for e in clean_exts}
|
||
|
||
return orders, total, sec_ext_map, clean_ext_map
|
||
except Exception as e:
|
||
logger.error(f"查询IoT工单失败: {e}", exc_info=True)
|
||
return [], 0, {}, {}
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
@tool
|
||
def query_order_stats(time_range: str = "today", order_type: str = "ALL") -> str:
|
||
"""查询工单统计数据(总数、按状态分布、按类型分布)
|
||
|
||
Args:
|
||
time_range: 时间范围 today=今日 week=本周 month=本月 yesterday=昨日
|
||
order_type: 工单类型筛选 SECURITY=安保 CLEAN=保洁 ALL=全部
|
||
"""
|
||
start, range_label = _parse_time_range(time_range)
|
||
now = beijing_now()
|
||
end = now
|
||
if time_range == "yesterday":
|
||
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
orders, total, sec_ext_map, clean_ext_map = _query_orders(
|
||
order_type=order_type if order_type != "ALL" else None,
|
||
start_time=start, end_time=end, limit=10000,
|
||
)
|
||
|
||
# 按状态统计
|
||
status_count = {}
|
||
for o in orders:
|
||
s = o.status or "PENDING"
|
||
status_count[s] = status_count.get(s, 0) + 1
|
||
|
||
# 按类型统计
|
||
type_count = {"SECURITY": 0, "CLEAN": 0}
|
||
alarm_type_count = {}
|
||
for o in orders:
|
||
ot = o.order_type or "SECURITY"
|
||
type_count[ot] = type_count.get(ot, 0) + 1
|
||
# 安保工单细分告警类型
|
||
sec_ext = sec_ext_map.get(o.id)
|
||
if sec_ext and sec_ext.alarm_type:
|
||
alarm_type_count[sec_ext.alarm_type] = alarm_type_count.get(sec_ext.alarm_type, 0) + 1
|
||
|
||
# 误报统计(安保特有)
|
||
false_alarm_count = sum(
|
||
1 for e in sec_ext_map.values() if e.false_alarm == 1
|
||
)
|
||
|
||
result = {
|
||
"range": range_label,
|
||
"total": total,
|
||
"by_status": {ORDER_STATUS_NAMES.get(s, s): c for s, c in status_count.items()},
|
||
"by_order_type": {k: v for k, v in type_count.items() if v > 0},
|
||
"by_alarm_type": {ALARM_TYPE_NAMES.get(t, t): c for t, c in alarm_type_count.items()} if alarm_type_count else {},
|
||
"false_alarm_count": false_alarm_count,
|
||
}
|
||
return json.dumps(result, ensure_ascii=False)
|
||
|
||
|
||
@tool
|
||
def list_orders(
|
||
time_range: str = "today",
|
||
order_type: str = "ALL",
|
||
status: str = "",
|
||
limit: int = 10,
|
||
assigned_to_me: bool = False,
|
||
config: RunnableConfig = None,
|
||
) -> str:
|
||
"""查询工单列表,返回最近的工单记录
|
||
|
||
Args:
|
||
time_range: 时间范围 today/week/month/yesterday
|
||
order_type: 工单类型 SECURITY=安保 CLEAN=保洁 ALL=全部
|
||
status: 状态筛选 PENDING/ASSIGNED/ARRIVED/PAUSED/COMPLETED/CANCELLED
|
||
limit: 返回条数,默认10,最多20
|
||
assigned_to_me: 是否只看我的工单
|
||
"""
|
||
start, range_label = _parse_time_range(time_range)
|
||
now = beijing_now()
|
||
end = now
|
||
if time_range == "yesterday":
|
||
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
limit = min(limit, 20)
|
||
|
||
# 获取当前用户
|
||
user_id = ""
|
||
if config and assigned_to_me:
|
||
user_id = config.get("configurable", {}).get("user_id", "")
|
||
|
||
orders, total, sec_ext_map, clean_ext_map = _query_orders(
|
||
order_type=order_type if order_type != "ALL" else None,
|
||
status=status or None,
|
||
start_time=start, end_time=end, limit=limit,
|
||
)
|
||
|
||
items = []
|
||
for o in orders:
|
||
create_time = ""
|
||
if o.create_time:
|
||
try:
|
||
create_time = o.create_time.strftime("%m-%d %H:%M")
|
||
except Exception:
|
||
create_time = str(o.create_time)[:16]
|
||
|
||
item = {
|
||
"order_id": str(o.id),
|
||
"order_code": o.order_code or "",
|
||
"type": o.order_type or "SECURITY",
|
||
"title": o.title or "",
|
||
"status": ORDER_STATUS_NAMES.get(o.status, o.status or "待处理"),
|
||
"priority": PRIORITY_NAMES.get(o.priority, "中"),
|
||
"assignee": o.assignee_name or "",
|
||
"time": create_time,
|
||
}
|
||
|
||
# 安保扩展
|
||
sec_ext = sec_ext_map.get(o.id)
|
||
if sec_ext:
|
||
item["alarm_type"] = ALARM_TYPE_NAMES.get(sec_ext.alarm_type, sec_ext.alarm_type or "")
|
||
item["camera"] = sec_ext.camera_name or _get_camera_display_name(sec_ext.camera_id) if sec_ext.camera_id else ""
|
||
item["false_alarm"] = bool(sec_ext.false_alarm)
|
||
|
||
# 保洁扩展
|
||
clean_ext = clean_ext_map.get(o.id)
|
||
if clean_ext:
|
||
item["cleaning_type"] = CLEANING_TYPE_NAMES.get(clean_ext.cleaning_type, clean_ext.cleaning_type or "")
|
||
item["difficulty"] = clean_ext.difficulty_level
|
||
|
||
items.append(item)
|
||
|
||
result = {"range": range_label, "total": total, "items": items}
|
||
return json.dumps(result, ensure_ascii=False)
|
||
|
||
|
||
@tool
|
||
def get_order_detail(order_id: str, config: RunnableConfig) -> str:
|
||
"""查询单条工单的详细信息
|
||
|
||
Args:
|
||
order_id: 工单ID(ops_order.id)
|
||
"""
|
||
from app.models_iot import get_iot_session, IotOpsOrder, IotOpsOrderSecurityExt, IotOpsOrderCleanExt
|
||
db = get_iot_session()
|
||
try:
|
||
order = db.query(IotOpsOrder).filter(
|
||
IotOpsOrder.id == int(order_id),
|
||
IotOpsOrder.deleted == 0,
|
||
).first()
|
||
if not order:
|
||
return json.dumps({"error": f"未找到工单: {order_id}"}, ensure_ascii=False)
|
||
|
||
result = {
|
||
"order_id": str(order.id),
|
||
"order_code": order.order_code or "",
|
||
"order_type": order.order_type or "",
|
||
"title": order.title or "",
|
||
"description": order.description or "",
|
||
"status": ORDER_STATUS_NAMES.get(order.status, order.status or ""),
|
||
"priority": PRIORITY_NAMES.get(order.priority, "中"),
|
||
"assignee": order.assignee_name or "",
|
||
"location": order.location or "",
|
||
"create_time": order.create_time.strftime("%Y-%m-%d %H:%M:%S") if order.create_time else "",
|
||
}
|
||
|
||
# 安保扩展
|
||
sec_ext = db.query(IotOpsOrderSecurityExt).filter(
|
||
IotOpsOrderSecurityExt.ops_order_id == order.id,
|
||
IotOpsOrderSecurityExt.deleted == 0,
|
||
).first()
|
||
if sec_ext:
|
||
result["alarm_id"] = sec_ext.alarm_id or ""
|
||
result["alarm_type"] = ALARM_TYPE_NAMES.get(sec_ext.alarm_type, sec_ext.alarm_type or "")
|
||
result["camera_name"] = sec_ext.camera_name or (
|
||
_get_camera_display_name(sec_ext.camera_id) if sec_ext.camera_id else ""
|
||
)
|
||
result["has_image"] = bool(sec_ext.image_url)
|
||
result["false_alarm"] = bool(sec_ext.false_alarm)
|
||
result["result"] = sec_ext.result or ""
|
||
if sec_ext.dispatched_time:
|
||
result["dispatched_time"] = sec_ext.dispatched_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if sec_ext.confirmed_time:
|
||
result["confirmed_time"] = sec_ext.confirmed_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if sec_ext.completed_time:
|
||
result["completed_time"] = sec_ext.completed_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
# 保洁扩展
|
||
clean_ext = db.query(IotOpsOrderCleanExt).filter(
|
||
IotOpsOrderCleanExt.ops_order_id == order.id,
|
||
IotOpsOrderCleanExt.deleted == 0,
|
||
).first()
|
||
if clean_ext:
|
||
result["cleaning_type"] = CLEANING_TYPE_NAMES.get(clean_ext.cleaning_type, clean_ext.cleaning_type or "")
|
||
result["difficulty"] = clean_ext.difficulty_level
|
||
result["expected_duration"] = clean_ext.expected_duration
|
||
result["is_auto"] = bool(clean_ext.is_auto)
|
||
if clean_ext.arrived_time:
|
||
result["arrived_time"] = clean_ext.arrived_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
if clean_ext.completed_time:
|
||
result["clean_completed_time"] = clean_ext.completed_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
return json.dumps(result, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"查询工单详情失败: {e}", exc_info=True)
|
||
return json.dumps({"error": f"查询失败: {e}"}, ensure_ascii=False)
|
||
finally:
|
||
db.close()
|