delivery_warning.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. """配送时效预警 API"""
  2. from typing import Optional, List, Dict
  3. from fastapi import APIRouter, Depends, Query
  4. from sqlalchemy.orm import Session
  5. from sqlalchemy import and_
  6. from pydantic import BaseModel, Field
  7. from app.core.database import get_db
  8. from app.core.auth import get_current_user
  9. from app.core.response import success_response, error_response
  10. from app.core.utils import generate_id
  11. from app.models.models import BizDeliveryTask, BizDeliveryWarning
  12. from app.crud.base import CRUDBase
  13. from app.crud.user import user_crud
  14. from app.crud.order import order_crud
  15. # 注意:该模块提供两套入口:
  16. # - 新入口:/web/admin/delivery-warning/*
  17. # - 兼容入口:/api/web/admin/delivery-warning/*(仅在新入口前加 /api 前缀)
  18. # 这里不能把 legacy_router 的 prefix 写成 /api/web/admin/delivery-warning,
  19. # 否则 include_router(main_router) 会把 main_router 的 prefix 再拼一次,导致路径重复而 404。
  20. main_router = APIRouter(prefix="/web/admin/delivery-warning", tags=["配送时效预警"])
  21. legacy_router = APIRouter(prefix="/api", tags=["配送时效预警(兼容)"])
  22. router = APIRouter()
  23. class DeliveryWarningCRUD(CRUDBase[BizDeliveryWarning]):
  24. """配送时效预警CRUD"""
  25. def __init__(self):
  26. super().__init__(BizDeliveryWarning)
  27. def create_warning(
  28. self,
  29. db: Session,
  30. task_id: str,
  31. task_no: str,
  32. warning_type: str,
  33. urgency_level: str,
  34. remaining_minutes: int,
  35. message: str,
  36. create_by: str = None,
  37. create_name: str = None
  38. ) -> BizDeliveryWarning:
  39. """创建预警记录"""
  40. data = {
  41. "id": generate_id(),
  42. "task_id": task_id,
  43. "warning_type": warning_type,
  44. "urgency_level": urgency_level,
  45. "remaining_minutes": remaining_minutes,
  46. "message": message,
  47. # 历史表由代码侧维护,这里只落基础预警字段
  48. "is_sent": 0,
  49. "is_resolved": 0,
  50. "create_by": create_by,
  51. "create_name": create_name
  52. }
  53. return self.create(db, data)
  54. def get_by_task(self, db: Session, task_id: str) -> List[BizDeliveryWarning]:
  55. """获取配送任务的预警记录"""
  56. return db.query(self.model).filter(
  57. self.model.task_id == task_id,
  58. self.model.is_del == 0
  59. ).order_by(self.model.create_at.desc()).all()
  60. def mark_handled(
  61. self,
  62. db: Session,
  63. warning_id: str,
  64. handle_type: str,
  65. remark: str = None,
  66. operator_id: str = None,
  67. operator_name: str = None
  68. ) -> BizDeliveryWarning:
  69. """记录处理过程(不结案,结案由配送完成/取消自动触发)"""
  70. warning = self.get(db, warning_id)
  71. if not warning:
  72. return None
  73. now = datetime.now()
  74. operator_display = operator_name or operator_id or "未知操作人"
  75. handle_display = handle_type or "manual"
  76. remark_display = remark or "已处理"
  77. process_line = f"{now.strftime('%Y-%m-%d %H:%M:%S')}|{operator_display}|{handle_display}|{remark_display}"
  78. # 仅记录过程,不变更是否已解决
  79. old_note = (warning.resolved_note or "").strip()
  80. warning.resolved_note = f"{old_note}\n{process_line}".strip() if old_note else process_line
  81. warning.update_at = now
  82. warning.update_by = operator_id
  83. db.commit()
  84. db.refresh(warning)
  85. return warning
  86. from datetime import datetime
  87. delivery_warning_crud = DeliveryWarningCRUD()
  88. @main_router.get("/statistics", response_model=dict)
  89. def get_warning_statistics(
  90. db: Session = Depends(get_db),
  91. current_user: dict = Depends(get_current_user)
  92. ):
  93. """获取预警统计"""
  94. try:
  95. total = db.query(delivery_warning_crud.model).filter(
  96. delivery_warning_crud.model.is_del == 0
  97. ).count()
  98. critical = db.query(delivery_warning_crud.model).filter(
  99. and_(
  100. delivery_warning_crud.model.is_del == 0,
  101. delivery_warning_crud.model.warning_type == "critical",
  102. delivery_warning_crud.model.is_resolved == 0
  103. )
  104. ).count()
  105. urgent = db.query(delivery_warning_crud.model).filter(
  106. and_(
  107. delivery_warning_crud.model.is_del == 0,
  108. delivery_warning_crud.model.warning_type == "urgent",
  109. delivery_warning_crud.model.is_resolved == 0
  110. )
  111. ).count()
  112. warning = db.query(delivery_warning_crud.model).filter(
  113. and_(
  114. delivery_warning_crud.model.is_del == 0,
  115. delivery_warning_crud.model.warning_type == "timeout",
  116. delivery_warning_crud.model.is_resolved == 0
  117. )
  118. ).count()
  119. return success_response({
  120. "total": total,
  121. "critical": critical,
  122. "urgent": urgent,
  123. "warning": warning
  124. })
  125. except Exception as e:
  126. return error_response(500, f"获取统计数据失败: {str(e)}")
  127. @main_router.get("/list", response_model=dict)
  128. def get_warning_list(
  129. page: int = Query(1, ge=1, description="页码"),
  130. pageSize: int = Query(20, ge=1, le=100, description="每页条数", alias="pageSize"),
  131. warningType: Optional[str] = Query(None, description="预警类型", alias="warningType"),
  132. regionId: Optional[str] = Query(None, description="区域ID", alias="regionId"),
  133. status: Optional[str] = Query(None, description="状态(pending/handled)"),
  134. isResolved: Optional[bool] = Query(None, description="是否已解决", alias="isResolved"),
  135. db: Session = Depends(get_db),
  136. current_user: dict = Depends(get_current_user)
  137. ):
  138. """获取预警列表"""
  139. try:
  140. query = db.query(delivery_warning_crud.model).filter(
  141. delivery_warning_crud.model.is_del == 0
  142. )
  143. if warningType:
  144. query = query.filter(delivery_warning_crud.model.warning_type == warningType)
  145. if regionId:
  146. query = query.join(
  147. BizDeliveryTask,
  148. and_(
  149. BizDeliveryTask.id == delivery_warning_crud.model.task_id,
  150. BizDeliveryTask.is_del == 0
  151. )
  152. ).filter(BizDeliveryTask.region_id == regionId)
  153. # 兼容前端传入的 isResolved 参数
  154. if isResolved is not None:
  155. query = query.filter(delivery_warning_crud.model.is_resolved == (1 if isResolved else 0))
  156. elif status:
  157. # 前端传入 pending/handled 时做简单映射
  158. if status in ["pending", "unhandled"]:
  159. query = query.filter(delivery_warning_crud.model.is_resolved == 0)
  160. else:
  161. query = query.filter(delivery_warning_crud.model.is_resolved == 1)
  162. else:
  163. # 默认展示全部(未处理 + 已处理)
  164. pass
  165. # 单任务单预警:同一 task 仅保留最新一条(兼容历史重复数据)
  166. raw_items = query.order_by(
  167. delivery_warning_crud.model.update_at.desc(),
  168. delivery_warning_crud.model.create_at.desc()
  169. ).all()
  170. dedup_map: Dict[str, BizDeliveryWarning] = {}
  171. for it in raw_items:
  172. key = it.task_id or it.id
  173. if key not in dedup_map:
  174. dedup_map[key] = it
  175. dedup_items = list(dedup_map.values())
  176. total = len(dedup_items)
  177. start = (page - 1) * pageSize
  178. end = start + pageSize
  179. items = dedup_items[start:end]
  180. # 批量补齐:配送任务、订单、客户、配送员
  181. task_ids = [it.task_id for it in items if getattr(it, "task_id", None)]
  182. tasks = (
  183. db.query(BizDeliveryTask).filter(and_(BizDeliveryTask.id.in_(task_ids), BizDeliveryTask.is_del == 0)).all()
  184. if task_ids
  185. else []
  186. )
  187. task_map: Dict[str, BizDeliveryTask] = {t.id: t for t in tasks}
  188. order_ids = []
  189. for t in tasks:
  190. if getattr(t, "order_id", None):
  191. order_ids.append(t.order_id)
  192. order_ids = list(dict.fromkeys(order_ids))
  193. order_map: Dict[str, any] = {}
  194. if order_ids:
  195. rows = db.query(order_crud.model).filter(and_(order_crud.model.id.in_(order_ids), order_crud.model.is_del == 0)).all()
  196. order_map = {o.id: o for o in rows}
  197. deliver_ids = list(
  198. dict.fromkeys([t.deliver_id for t in tasks if getattr(t, "deliver_id", None)])
  199. )
  200. deliver_map: Dict[str, any] = {}
  201. if deliver_ids:
  202. rows = db.query(user_crud.model).filter(and_(user_crud.model.id.in_(deliver_ids), user_crud.model.is_del == 0)).all()
  203. deliver_map = {u.id: u for u in rows}
  204. client_ids = list(
  205. dict.fromkeys([getattr(order_map.get(t.order_id), "client_id", None) for t in tasks if t.order_id in order_map])
  206. )
  207. client_ids = [cid for cid in client_ids if cid]
  208. client_map: Dict[str, any] = {}
  209. if client_ids:
  210. rows = db.query(user_crud.model).filter(and_(user_crud.model.id.in_(client_ids), user_crud.model.is_del == 0)).all()
  211. client_map = {u.id: u for u in rows}
  212. result = []
  213. for item in items:
  214. warning_data = _to_response(item)
  215. task = task_map.get(item.task_id)
  216. if task:
  217. warning_data["deliveryId"] = task.id
  218. warning_data["taskNo"] = task.task_no
  219. warning_data["deliverId"] = task.deliver_id
  220. d = deliver_map.get(task.deliver_id) if task.deliver_id else None
  221. warning_data["deliverName"] = getattr(d, "real_name", None) if d else None
  222. warning_data["deliverPhone"] = getattr(d, "phone", None) if d else None
  223. warning_data["deliveryDeadlineAt"] = str(task.effective_time) if task.effective_time else None
  224. warning_data["expireTime"] = str(task.effective_time) if task.effective_time else None
  225. warning_data["dispatchRemark"] = task.dispatch_remark
  226. order = order_map.get(task.order_id) if task.order_id else None
  227. if order:
  228. warning_data["orderId"] = order.id
  229. warning_data["orderNo"] = order.order_no
  230. warning_data["clientId"] = order.client_id
  231. c = client_map.get(order.client_id) if order.client_id else None
  232. warning_data["clientName"] = getattr(c, "real_name", None) if c else None
  233. warning_data["contactPerson"] = order.contact_person
  234. warning_data["contactPhone"] = order.contact_phone
  235. warning_data["deliveryAddress"] = order.delivery_address
  236. result.append(warning_data)
  237. return success_response({
  238. "list": result,
  239. "total": total,
  240. "page": page,
  241. "pageSize": pageSize,
  242. "totalPages": (total + pageSize - 1) // pageSize
  243. })
  244. except Exception as e:
  245. return error_response(500, f"获取预警列表失败: {str(e)}")
  246. @main_router.get("/detail/{warning_id}", response_model=dict)
  247. def get_warning_detail(
  248. warning_id: str,
  249. db: Session = Depends(get_db),
  250. current_user: dict = Depends(get_current_user)
  251. ):
  252. """获取预警详情"""
  253. try:
  254. warning = delivery_warning_crud.get(db, warning_id)
  255. if not warning or warning.is_del == 1:
  256. return error_response(404, "预警不存在")
  257. return success_response(_to_response(warning))
  258. except Exception as e:
  259. return error_response(500, f"获取预警详情失败: {str(e)}")
  260. @main_router.post("/handle/{warning_id}", response_model=dict)
  261. def handle_warning(
  262. warning_id: str,
  263. body: dict = None,
  264. handle_type: str = None,
  265. remark: str = None,
  266. note: str = None,
  267. db: Session = Depends(get_db),
  268. current_user: dict = Depends(get_current_user)
  269. ):
  270. """处理预警"""
  271. try:
  272. # 兼容前端 JSON body 与 query 参数多种传参方式
  273. payload = body or {}
  274. handle_type = payload.get("handleType") or payload.get("handle_type") or handle_type
  275. remark = payload.get("remark") or payload.get("note") or remark or note
  276. if not handle_type:
  277. handle_type = "manual" # 默认手动处理
  278. if not remark:
  279. remark = "手动处理"
  280. warning = delivery_warning_crud.mark_handled(
  281. db=db,
  282. warning_id=warning_id,
  283. handle_type=handle_type,
  284. remark=remark,
  285. operator_id=current_user.get("user_id"),
  286. operator_name=current_user.get("real_name")
  287. )
  288. if not warning:
  289. return error_response(404, "预警不存在")
  290. return success_response({"message": "处理记录已保存"})
  291. except Exception as e:
  292. return error_response(500, f"处理预警失败: {str(e)}")
  293. @main_router.post("/notify/{warning_id}", response_model=dict)
  294. def notify_deliver(
  295. warning_id: str,
  296. db: Session = Depends(get_db),
  297. current_user: dict = Depends(get_current_user)
  298. ):
  299. """提醒配送员"""
  300. try:
  301. warning = delivery_warning_crud.get(db, warning_id)
  302. if not warning:
  303. return error_response(404, "预警不存在")
  304. task = db.query(BizDeliveryTask).filter(BizDeliveryTask.id == warning.task_id).first()
  305. if not task or not task.deliver_id:
  306. return error_response(400, "配送任务未分配配送员")
  307. # TODO: 发送微信模板消息提醒配送员
  308. notification_sent = True
  309. if notification_sent:
  310. return success_response({"message": "已发送提醒"})
  311. else:
  312. return error_response(500, "发送提醒失败")
  313. except Exception as e:
  314. return error_response(500, f"提醒配送员失败: {str(e)}")
  315. def _to_response(warning: BizDeliveryWarning) -> dict:
  316. """转换为响应格式"""
  317. resolved_note = warning.resolved_note or ""
  318. handle_type = None
  319. remark = None
  320. process_logs = []
  321. for line in [s.strip() for s in resolved_note.split("\n") if s.strip()]:
  322. parts = line.split("|", 3)
  323. if len(parts) == 4:
  324. t, operator, h_type, note = parts
  325. process_logs.append({
  326. "time": t,
  327. "operator": operator,
  328. "handleType": h_type,
  329. "note": note
  330. })
  331. handle_type = h_type
  332. remark = note
  333. elif ":" in line:
  334. # 兼容旧格式 handleType:remark
  335. h_type, note = line.split(":", 1)
  336. process_logs.append({
  337. "time": None,
  338. "operator": None,
  339. "handleType": h_type,
  340. "note": note
  341. })
  342. handle_type = h_type
  343. remark = note
  344. else:
  345. process_logs.append({
  346. "time": None,
  347. "operator": None,
  348. "handleType": "manual",
  349. "note": line
  350. })
  351. handle_type = "manual"
  352. remark = line
  353. status = "handled" if warning.is_resolved else "pending"
  354. # 兼容:同时保留 snake_case(历史)与 camelCase(前端列表展示)
  355. return {
  356. "id": warning.id,
  357. "taskId": warning.task_id,
  358. "orderId": warning.order_id,
  359. "warningType": warning.warning_type,
  360. "urgencyLevel": warning.urgency_level,
  361. "remainingMinutes": warning.remaining_minutes,
  362. "message": warning.message,
  363. "status": status,
  364. "isResolved": True if warning.is_resolved else False,
  365. "resolveNote": remark,
  366. "resolveTime": str(warning.resolved_time) if warning.resolved_time else None,
  367. "processLogs": process_logs,
  368. "createAt": str(warning.create_at) if warning.create_at else None,
  369. # legacy keys
  370. "task_id": warning.task_id,
  371. "order_id": warning.order_id,
  372. "warning_type": warning.warning_type,
  373. "urgency_level": warning.urgency_level,
  374. "remaining_minutes": warning.remaining_minutes,
  375. "create_at": str(warning.create_at) if warning.create_at else None,
  376. "handle_type": handle_type,
  377. "remark": remark,
  378. "handle_by": warning.resolved_by,
  379. "handle_time": str(warning.resolved_time) if warning.resolved_time else None,
  380. }
  381. @main_router.post("/trigger-check", response_model=dict)
  382. def trigger_warning_check(
  383. db: Session = Depends(get_db),
  384. current_user: dict = Depends(get_current_user)
  385. ):
  386. """手动触发预警检查(供运维使用)"""
  387. try:
  388. from app.tasks.delivery_warning import check_delivery_warnings
  389. result = check_delivery_warnings(db)
  390. return success_response({
  391. "message": "检查完成",
  392. "result": {
  393. "checked": result.get("checked", 0),
  394. "warnings_created": result.get("warnings_created", 0),
  395. "notifications_sent": result.get("notifications_sent", 0),
  396. "errors": result.get("errors", [])
  397. }
  398. })
  399. except Exception as e:
  400. return error_response(500, f"检查失败: {str(e)}")
  401. # 将同一套路由挂到兼容前缀下
  402. legacy_router.include_router(main_router)
  403. router.include_router(main_router)
  404. router.include_router(legacy_router)