delivery_warning.py 21 KB

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