mini_deliverer.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124
  1. """配送员端接口"""
  2. from typing import Optional
  3. from datetime import datetime, timedelta
  4. from fastapi import APIRouter, Depends, Query
  5. from sqlalchemy.orm import Session
  6. from app.core.database import get_db
  7. from app.core.auth import get_current_user
  8. from app.core.response import success_response, error_response
  9. from app.core.request_payload import resolve_request_payload
  10. from app.crud.region import region_crud
  11. from app.crud.user import user_crud
  12. from app.crud.order import order_crud, order_item_crud
  13. from app.crud.product import product_crud
  14. from app.crud.unit import unit_crud
  15. from app.crud.address import address_crud
  16. from app.crud.attachment import attachment_crud
  17. router = APIRouter(prefix="/api/mini/deliverer", tags=["小程序端-配送员接口"])
  18. def _resolve_request_payload(request_body: dict, current_user: dict) -> dict:
  19. return resolve_request_payload(request_body, current_user, source="mini_deliverer")
  20. def _supplier_pickup_address_block(db: Session, supplier_id: Optional[str]) -> dict:
  21. """供应商默认取货地址;无默认时取第一条 pickup 类型地址。"""
  22. empty = {"fullAddress": None, "contactPerson": None, "contactPhone": None}
  23. if not supplier_id:
  24. return empty
  25. addr = address_crud.get_default(db, supplier_id, "pickup")
  26. if not addr:
  27. rows = address_crud.get_by_user_and_type(db, supplier_id, "pickup")
  28. addr = rows[0] if rows else None
  29. if not addr:
  30. return empty
  31. segs = [
  32. getattr(addr, "province", None),
  33. getattr(addr, "city", None),
  34. getattr(addr, "district", None),
  35. getattr(addr, "detail_address", None),
  36. ]
  37. full = "".join(s for s in segs if s)
  38. return {
  39. "fullAddress": full or None,
  40. "contactPerson": addr.contact_person,
  41. "contactPhone": addr.contact_phone,
  42. }
  43. def _numeric_to_float_optional(v) -> Optional[float]:
  44. if v is None:
  45. return None
  46. try:
  47. return float(v)
  48. except (TypeError, ValueError):
  49. return None
  50. def _normalize_image_urls(raw) -> list[str]:
  51. out: list[str] = []
  52. if isinstance(raw, list):
  53. for x in raw:
  54. s = str(x or "").strip()
  55. if s:
  56. out.append(s)
  57. return out
  58. def _save_task_attachments(
  59. db: Session,
  60. *,
  61. task_id: str,
  62. business_type: str,
  63. image_urls: list[str],
  64. uploader_id: Optional[str],
  65. uploader_name: Optional[str],
  66. ):
  67. for idx, url in enumerate(image_urls, start=1):
  68. attachment_crud.create_attachment(
  69. db=db,
  70. business_type=business_type,
  71. business_id=task_id,
  72. file_name=f"{business_type}_{idx}.jpg",
  73. file_url=url,
  74. file_type="image/jpeg",
  75. upload_by=uploader_id,
  76. upload_by_name=uploader_name,
  77. )
  78. def require_deliverer(current_user: dict = Depends(get_current_user)):
  79. """验证配送员权限"""
  80. if current_user.get("role") != "deliver":
  81. return error_response(403, "需要配送员权限")
  82. return current_user
  83. # 配送任务状态映射
  84. DELIVERY_STATUS_MAP = {
  85. "pending": "待取货",
  86. "assigned": "已分配",
  87. "picking": "取货中",
  88. "delivering": "配送中",
  89. "completed": "已完成",
  90. "cancelled": "已取消"
  91. }
  92. @router.get("/products/{product_id}", response_model=dict)
  93. def get_deliverer_product_detail(
  94. product_id: str,
  95. db: Session = Depends(get_db),
  96. current_user: dict = Depends(require_deliverer),
  97. ):
  98. """配送员查看商品详情:轮播、基础信息、参数、富文本、详情媒体(与供应商端一致,不依赖客户价目表/区域绑定)"""
  99. from app.crud.product_banner import product_banner
  100. from app.crud.product_parameter_value import product_parameter_value
  101. from app.crud.product_detail_media import product_detail_media
  102. product = product_crud.get(db, product_id)
  103. if not product or getattr(product, "is_del", 0) == 1:
  104. return error_response(404, "商品不存在")
  105. unit = unit_crud.get(db, product.unit_id) if product.unit_id else None
  106. banners_raw = product_banner.get_by_product(db, product_id)
  107. banners = [{
  108. "mediaType": b.media_type,
  109. "fileUrl": b.file_url,
  110. "thumbnailUrl": b.thumbnail_url,
  111. "sortOrder": b.sort_order,
  112. } for b in banners_raw]
  113. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  114. parameter_values = []
  115. for row in pv_rows:
  116. val = row.get("param_value")
  117. if val is None or str(val).strip() == "":
  118. continue
  119. parameter_values.append({
  120. "parameterId": row.get("parameter_id"),
  121. "parameterName": row.get("parameter_name") or "",
  122. "paramValue": str(val),
  123. })
  124. media_raw = product_detail_media.get_by_product(db, product_id)
  125. detail_media = []
  126. for m in media_raw:
  127. if m.content_type not in ("image", "video"):
  128. continue
  129. if not m.file_url:
  130. continue
  131. detail_media.append({
  132. "id": m.id,
  133. "contentType": m.content_type,
  134. "fileUrl": m.file_url,
  135. "thumbnailUrl": m.thumbnail_url,
  136. "description": m.description,
  137. "sortOrder": m.sort_order,
  138. })
  139. return success_response({
  140. "product": {
  141. "id": product.id,
  142. "name": product.name,
  143. "unitId": product.unit_id,
  144. "unitName": unit.name if unit else None,
  145. "measureValue": float(product.measure_value) if product.measure_value is not None else None,
  146. "detailContent": product.detail_content or "",
  147. },
  148. "banners": banners,
  149. "parameterValues": parameter_values,
  150. "detailMedia": detail_media,
  151. })
  152. @router.get("/tasks", response_model=dict)
  153. def list_deliverer_tasks(
  154. status: Optional[str] = Query(None, description="状态"),
  155. page: int = Query(1, ge=1, description="页码"),
  156. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  157. db: Session = Depends(get_db),
  158. current_user: dict = Depends(require_deliverer)
  159. ):
  160. """获取配送员的任务列表"""
  161. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  162. deliver_id = current_user.get("user_id")
  163. # 小程序 Tab:按配送员状态 deliverer_status 筛选(与 statistics 口径一致)
  164. deliverer_st = None
  165. legacy_st = None
  166. if status in (None, "", "pending", "assigned"):
  167. deliverer_st = "pending_pickup"
  168. elif status == "delivering":
  169. deliverer_st = "delivering"
  170. elif status == "completed":
  171. deliverer_st = "delivered"
  172. else:
  173. legacy_st = status
  174. tasks, total = delivery_task_crud.get_by_deliver(
  175. db,
  176. deliver_id=deliver_id,
  177. status=legacy_st,
  178. deliverer_status=deliverer_st,
  179. skip=(page - 1) * pageSize,
  180. limit=pageSize
  181. )
  182. result = []
  183. for task in tasks:
  184. # 获取供应商信息
  185. supplier_info = {"name": None, "phone": None}
  186. if task.supplier_id:
  187. supplier = user_crud.get(db, task.supplier_id)
  188. if supplier:
  189. supplier_info = {"name": supplier.real_name, "phone": supplier.phone}
  190. pickup_addr = _supplier_pickup_address_block(db, task.supplier_id)
  191. # 获取订单信息(配送员端不返回金额)
  192. order_info: dict = {}
  193. if task.order_id:
  194. order = order_crud.get(db, task.order_id)
  195. if order:
  196. order_info = {"orderNo": order.order_no}
  197. # 获取任务明细(卡片展示取货/送货摘要)
  198. items = delivery_task_item_crud.get_by_task(db, task.id)
  199. total_quantity = sum(float(item.quantity or 0) for item in items)
  200. item_preview = []
  201. for item in items:
  202. item_preview.append({
  203. "clientName": item.contact_person,
  204. "clientPhone": item.contact_phone,
  205. "clientAddress": item.delivery_address,
  206. })
  207. # 计算剩余时间
  208. remaining_minutes = 0
  209. if task.effective_time:
  210. remaining = task.effective_time - datetime.now()
  211. remaining_minutes = max(0, int(remaining.total_seconds() / 60))
  212. result.append({
  213. "id": task.id,
  214. "taskNo": task.task_no,
  215. "orderNo": order_info.get("orderNo"),
  216. "supplierName": supplier_info.get("name"),
  217. "supplierPhone": supplier_info.get("phone"),
  218. "supplierAddress": pickup_addr.get("fullAddress"),
  219. "supplierPickupContact": pickup_addr.get("contactPerson"),
  220. "supplierPickupPhone": pickup_addr.get("contactPhone"),
  221. "clientCount": len(set(item.client_id for item in items if item.client_id)),
  222. "itemCount": len(items),
  223. "totalQuantity": total_quantity,
  224. "items": item_preview,
  225. "status": task.status,
  226. "delivererStatus": getattr(task, "deliverer_status", None),
  227. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  228. "createTime": str(task.create_at) if task.create_at else None,
  229. "pickupDeadline": str(task.effective_time) if task.effective_time else None,
  230. "remainingMinutes": remaining_minutes
  231. })
  232. return success_response({
  233. "list": result,
  234. "total": total,
  235. "page": page,
  236. "pageSize": pageSize,
  237. "totalPages": (total + pageSize - 1) // pageSize
  238. })
  239. # 以下静态路径必须注册在 /tasks/{task_id} 之前,否则 statistics、history 等会被当成 task_id
  240. @router.get("/tasks/statistics", response_model=dict)
  241. def get_deliverer_statistics(
  242. db: Session = Depends(get_db),
  243. current_user: dict = Depends(require_deliverer)
  244. ):
  245. """获取配送员统计"""
  246. from app.crud.delivery import delivery_task_crud
  247. deliver_id = current_user.get("user_id")
  248. from app.crud.deliverer_review import deliverer_review_crud
  249. # 获取今日数据
  250. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  251. # 待取货:与列表「待取货」Tab 一致——全部 deliverer_status=pending_pickup(含历史创建未取货),不按创建日截断
  252. pending_tasks, _ = delivery_task_crud.get_by_deliver(
  253. db,
  254. deliver_id=deliver_id,
  255. deliverer_status="pending_pickup",
  256. skip=0,
  257. limit=500
  258. )
  259. # 配送中数量(配送员状态)
  260. delivering_tasks, _ = delivery_task_crud.get_by_deliver(
  261. db, deliver_id=deliver_id, deliverer_status="delivering", skip=0, limit=500
  262. )
  263. # 今日已完成(配送员已送达)
  264. completed_tasks, _ = delivery_task_crud.get_by_deliver(
  265. db, deliver_id=deliver_id, deliverer_status="delivered", skip=0, limit=2000
  266. )
  267. today_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= today]
  268. # 本周已完成
  269. week_start = today - timedelta(days=today.weekday())
  270. week_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= week_start]
  271. # 本月已完成
  272. month_start = today.replace(day=1)
  273. month_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= month_start]
  274. review_stats = deliverer_review_crud.get_deliverer_rating_stats(db, deliver_id)
  275. return success_response({
  276. "todayPending": len(pending_tasks),
  277. "todayDelivering": len(delivering_tasks),
  278. "todayCompleted": len(today_completed),
  279. "weekCompleted": len(week_completed),
  280. "monthCompleted": len(month_completed),
  281. "rating": review_stats.get("avgScore", 0),
  282. "ratingCount": review_stats.get("total", 0)
  283. })
  284. @router.get("/tasks/history", response_model=dict)
  285. def get_deliverer_task_history(
  286. startDate: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
  287. endDate: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
  288. page: int = Query(1, ge=1, description="页码"),
  289. pageSize: int = Query(10, ge=1, le=50, description="每页条数"),
  290. db: Session = Depends(get_db),
  291. current_user: dict = Depends(require_deliverer)
  292. ):
  293. """获取配送员历史任务列表"""
  294. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  295. deliver_id = current_user.get("user_id")
  296. tasks, total = delivery_task_crud.get_history_by_deliver(
  297. db,
  298. deliver_id=deliver_id,
  299. start_date=startDate,
  300. end_date=endDate,
  301. skip=(page - 1) * pageSize,
  302. limit=pageSize
  303. )
  304. result = []
  305. for task in tasks:
  306. # 获取供应商信息
  307. supplier_info = {"name": None, "phone": None}
  308. if task.supplier_id:
  309. supplier = user_crud.get(db, task.supplier_id)
  310. if supplier:
  311. supplier_info = {"name": supplier.real_name, "phone": supplier.phone}
  312. # 获取任务项统计
  313. items = delivery_task_item_crud.get_by_task(db, task.id)
  314. total_quantity = sum(float(item.quantity or 0) for item in items)
  315. delivered_quantity = sum(float(item.delivered_quantity or 0) for item in items)
  316. # 计算配送时效(分钟)
  317. delivery_minutes = None
  318. if task.pickup_time and task.delivery_time:
  319. delta = task.delivery_time - task.pickup_time
  320. delivery_minutes = int(delta.total_seconds() / 60)
  321. result.append({
  322. "id": task.id,
  323. "taskNo": task.task_no,
  324. "supplierName": supplier_info.get("name"),
  325. "supplierPhone": supplier_info.get("phone"),
  326. "status": task.status,
  327. "statusText": "已完成" if task.status == "completed" else "已取消" if task.status == "cancelled" else task.status,
  328. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  329. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  330. "totalQuantity": total_quantity,
  331. "deliveredQuantity": delivered_quantity,
  332. "deliveryMinutes": delivery_minutes,
  333. "createAt": str(task.create_at) if task.create_at else None
  334. })
  335. return success_response({
  336. "list": result,
  337. "total": total,
  338. "page": page,
  339. "pageSize": pageSize
  340. })
  341. @router.get("/tasks/warnings", response_model=dict)
  342. def list_deliverer_task_warnings(
  343. db: Session = Depends(get_db),
  344. current_user: dict = Depends(require_deliverer)
  345. ):
  346. """配送员关联任务的时效预警列表"""
  347. from app.models.models import BizDeliveryTask, BizDeliveryWarning
  348. from sqlalchemy import and_
  349. did = current_user.get("user_id")
  350. task_ids = [
  351. t[0]
  352. for t in db.query(BizDeliveryTask.id)
  353. .filter(and_(BizDeliveryTask.deliver_id == did, BizDeliveryTask.is_del == 0))
  354. .all()
  355. ]
  356. if not task_ids:
  357. return success_response({"list": []})
  358. rows = (
  359. db.query(BizDeliveryWarning)
  360. .filter(
  361. and_(
  362. BizDeliveryWarning.task_id.in_(task_ids),
  363. BizDeliveryWarning.is_del == 0,
  364. BizDeliveryWarning.is_resolved == 0,
  365. )
  366. )
  367. .order_by(BizDeliveryWarning.create_at.desc())
  368. .limit(50)
  369. .all()
  370. )
  371. out = [
  372. {
  373. "id": r.id,
  374. "taskId": r.task_id,
  375. "warningType": r.warning_type,
  376. "urgencyLevel": r.urgency_level,
  377. "remainingMinutes": r.remaining_minutes,
  378. "message": r.message,
  379. "createAt": str(r.create_at) if r.create_at else None,
  380. }
  381. for r in rows
  382. ]
  383. return success_response({"list": out})
  384. @router.get("/tasks/expiring", response_model=dict)
  385. def list_deliverer_expiring_tasks(
  386. db: Session = Depends(get_db),
  387. current_user: dict = Depends(require_deliverer),
  388. withinMinutes: int = Query(60, ge=1, le=24 * 60, description="多少分钟内视为即将超时"),
  389. ):
  390. """即将超时的配送任务(基于 effective_time)"""
  391. from app.crud.delivery import delivery_task_crud
  392. did = current_user.get("user_id")
  393. now = datetime.now()
  394. tasks, _ = delivery_task_crud.get_by_deliver(db, deliver_id=did, status=None, skip=0, limit=200)
  395. result = []
  396. for t in tasks:
  397. if t.status in ("completed", "cancelled"):
  398. continue
  399. if (getattr(t, "deliverer_status", None) or "") == "delivered":
  400. continue
  401. eff = getattr(t, "effective_time", None)
  402. if not eff:
  403. continue
  404. remaining = (eff - now).total_seconds() / 60
  405. if 0 <= remaining <= withinMinutes:
  406. result.append({
  407. "id": t.id,
  408. "taskNo": t.task_no,
  409. "status": t.status,
  410. "effectiveTime": str(eff) if eff else None,
  411. "remainingMinutes": max(0, int(remaining)),
  412. })
  413. return success_response({"list": result})
  414. @router.get("/tasks/{task_id}", response_model=dict)
  415. def get_deliverer_task(
  416. task_id: str,
  417. db: Session = Depends(get_db),
  418. current_user: dict = Depends(require_deliverer)
  419. ):
  420. """获取配送员任务详情"""
  421. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  422. task = delivery_task_crud.get(db, task_id)
  423. if not task:
  424. return error_response(404, "配送任务不存在")
  425. if task.deliver_id != current_user.get("user_id"):
  426. return error_response(403, "无权查看此任务")
  427. # 获取供应商信息
  428. supplier_info = {"name": None, "phone": None}
  429. if task.supplier_id:
  430. supplier = user_crud.get(db, task.supplier_id)
  431. if supplier:
  432. supplier_info = {"name": supplier.real_name, "phone": supplier.phone}
  433. pickup_addr = _supplier_pickup_address_block(db, task.supplier_id)
  434. # 获取订单信息(配送员端不返回金额)
  435. order_info: dict = {}
  436. if task.order_id:
  437. order = order_crud.get(db, task.order_id)
  438. if order:
  439. order_info = {
  440. "orderNo": order.order_no,
  441. "contactPerson": order.contact_person,
  442. "contactPhone": order.contact_phone,
  443. "deliveryAddress": order.delivery_address,
  444. "orderRemark": order.remark,
  445. }
  446. # 获取任务明细(对齐运营端商品展示字段,不含价格)
  447. items = delivery_task_item_crud.get_by_task(db, task_id)
  448. item_details = []
  449. total_quantity = 0.0
  450. for item in items:
  451. product = product_crud.get(db, item.product_id)
  452. order_line = order_item_crud.get(db, item.order_item_id) if item.order_item_id else None
  453. unit_row = None
  454. if order_line and order_line.unit_id:
  455. unit_row = unit_crud.get(db, order_line.unit_id)
  456. elif product and product.unit_id:
  457. unit_row = unit_crud.get(db, product.unit_id)
  458. image_url = getattr(product, "image_url", None) if product else None
  459. measure_value = _numeric_to_float_optional(getattr(product, "measure_value", None)) if product else None
  460. qty = float(item.quantity) if item.quantity else 0
  461. total_quantity += qty
  462. item_details.append({
  463. "id": item.id,
  464. "productId": item.product_id,
  465. "productName": product.name if product else None,
  466. "imageUrl": image_url,
  467. "measureValue": measure_value,
  468. "unitName": unit_row.name if unit_row else None,
  469. "productUnitName": unit_row.name if unit_row else None,
  470. "quantity": qty,
  471. "deliveredQuantity": float(item.delivered_quantity) if item.delivered_quantity else 0,
  472. "clientName": item.contact_person,
  473. "clientPhone": item.contact_phone,
  474. "clientAddress": item.delivery_address,
  475. "status": item.status
  476. })
  477. # 处理图片
  478. pickup_images = []
  479. if task.pickup_image_url:
  480. pickup_images = task.pickup_image_url.split(",")
  481. delivery_images = []
  482. if task.delivery_image_url:
  483. delivery_images = task.delivery_image_url.split(",")
  484. supplier_prepare_images = [
  485. a.file_url
  486. for a in attachment_crud.get_by_business(db, "delivery_prepare", task_id)
  487. if getattr(a, "file_url", None)
  488. ]
  489. return success_response({
  490. "id": task.id,
  491. "taskNo": task.task_no,
  492. "orderId": task.order_id,
  493. "orderNo": order_info.get("orderNo"),
  494. "orderRemark": order_info.get("orderRemark"),
  495. "supplierId": task.supplier_id,
  496. "supplierName": supplier_info.get("name"),
  497. "supplierPhone": supplier_info.get("phone"),
  498. "supplierAddress": pickup_addr.get("fullAddress"),
  499. "supplierPickup": {
  500. "address": pickup_addr.get("fullAddress"),
  501. "contactPerson": pickup_addr.get("contactPerson"),
  502. "contactPhone": pickup_addr.get("contactPhone"),
  503. },
  504. "customerDelivery": {
  505. "address": order_info.get("deliveryAddress"),
  506. "contactPerson": order_info.get("contactPerson"),
  507. "contactPhone": order_info.get("contactPhone"),
  508. },
  509. "status": task.status,
  510. "delivererStatus": getattr(task, "deliverer_status", None),
  511. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  512. "totalQuantity": total_quantity,
  513. "createTime": str(task.create_at) if task.create_at else None,
  514. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  515. "pickupRemark": task.pickup_remark,
  516. "pickupImages": pickup_images,
  517. "supplierPrepareImages": supplier_prepare_images,
  518. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  519. "deliveryRemark": task.delivery_remark,
  520. "deliveryImages": delivery_images,
  521. "expireTime": str(task.effective_time) if task.effective_time else None,
  522. "dispatchRemark": task.dispatch_remark,
  523. "items": item_details
  524. })
  525. @router.post("/tasks/{task_id}/pickup", response_model=dict)
  526. def confirm_pickup(
  527. task_id: str,
  528. request: dict,
  529. db: Session = Depends(get_db),
  530. current_user: dict = Depends(require_deliverer)
  531. ):
  532. """确认取货"""
  533. from app.crud.delivery import delivery_task_crud
  534. task = delivery_task_crud.get(db, task_id)
  535. if not task:
  536. return error_response(404, "配送任务不存在")
  537. if task.deliver_id != current_user.get("user_id"):
  538. return error_response(403, "无权操作此任务")
  539. supplier_prepare_status = getattr(task, "supplier_prepare_status", None) or "pending_prepare"
  540. deliverer_status = getattr(task, "deliverer_status", None) or "pending_pickup"
  541. if deliverer_status != "pending_pickup" or supplier_prepare_status != "prepared":
  542. return error_response(400, "当前状态不支持取货(需供应商完成备货)")
  543. payload = _resolve_request_payload(request, current_user)
  544. remark = payload.get("remark")
  545. image_urls = _normalize_image_urls(payload.get("imageUrls", []))
  546. if not image_urls:
  547. return error_response(400, "请上传取货凭证照片")
  548. task = delivery_task_crud.update_status(
  549. db,
  550. task_id=task_id,
  551. new_status="picking",
  552. operator_id=current_user.get("user_id"),
  553. remark=remark,
  554. image_urls=image_urls,
  555. operator_real_name=current_user.get("real_name"),
  556. )
  557. if not task:
  558. return error_response(400, "操作失败")
  559. _save_task_attachments(
  560. db,
  561. task_id=task_id,
  562. business_type="delivery_pickup",
  563. image_urls=image_urls,
  564. uploader_id=current_user.get("user_id"),
  565. uploader_name=current_user.get("real_name"),
  566. )
  567. return success_response({
  568. "id": task.id,
  569. "status": task.status,
  570. "delivererStatus": getattr(task, "deliverer_status", None),
  571. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  572. "message": "取货成功"
  573. })
  574. @router.post("/tasks/{task_id}/deliver", response_model=dict)
  575. def confirm_deliver(
  576. task_id: str,
  577. request: dict,
  578. db: Session = Depends(get_db),
  579. current_user: dict = Depends(require_deliverer)
  580. ):
  581. """确认送达"""
  582. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  583. from app.crud.order import order_crud
  584. from app.core.order_status_compat import derive_legacy_status_from_three
  585. task = delivery_task_crud.get(db, task_id)
  586. if not task:
  587. return error_response(404, "配送任务不存在")
  588. if task.deliver_id != current_user.get("user_id"):
  589. return error_response(403, "无权操作此任务")
  590. ds = (getattr(task, "deliverer_status", None) or "").strip() or "pending_pickup"
  591. ss = (getattr(task, "supplier_prepare_status", None) or "").strip()
  592. # 正常:已取货后为 delivering;兼容历史 bug:pending_pickup + 备货侧已 picked_up 仍允许送达
  593. if ds != "delivering" and not (ds == "pending_pickup" and ss == "picked_up"):
  594. return error_response(400, "当前状态不支持送达操作(请先确认取货)")
  595. payload = _resolve_request_payload(request, current_user)
  596. items_data = payload.get("items", [])
  597. remark = payload.get("remark")
  598. image_urls = _normalize_image_urls(payload.get("imageUrls", []))
  599. if not image_urls:
  600. return error_response(400, "请上传送达凭证照片")
  601. order = order_crud.get(db, task.order_id) if task.order_id else None
  602. if order:
  603. from app.core.wechat_order_shipping import sync_trade_managed_shipping_for_delivered_order
  604. wx_ship_err = sync_trade_managed_shipping_for_delivered_order(db, order)
  605. if wx_ship_err:
  606. return error_response(502, wx_ship_err)
  607. # 更新任务状态
  608. task = delivery_task_crud.update_status(
  609. db,
  610. task_id=task_id,
  611. new_status="completed",
  612. operator_id=current_user.get("user_id"),
  613. remark=remark,
  614. image_urls=image_urls,
  615. operator_real_name=current_user.get("real_name"),
  616. )
  617. # 更新任务明细状态
  618. for item_data in items_data:
  619. item_id = item_data.get("taskItemId")
  620. delivered_quantity = item_data.get("deliveredQuantity", 0)
  621. delivery_task_item_crud.update_item_status(
  622. db,
  623. item_id=item_id,
  624. delivered_quantity=delivered_quantity,
  625. status="delivered"
  626. )
  627. # 配送员送货成功后,订单配送状态变为配送完成
  628. if order and getattr(order, "order_status", None) != "cancelled":
  629. order.delivery_status = "delivered"
  630. if not getattr(order, "delivered_time", None):
  631. order.delivered_time = datetime.now()
  632. if not getattr(order, "order_status", None):
  633. order.order_status = "pending_confirm"
  634. order.status = derive_legacy_status_from_three(
  635. order_status=order.order_status,
  636. delivery_status=order.delivery_status,
  637. pay_status=order.pay_status,
  638. )
  639. order.update_at = datetime.now()
  640. db.commit()
  641. _save_task_attachments(
  642. db,
  643. task_id=task_id,
  644. business_type="delivery_deliver",
  645. image_urls=image_urls,
  646. uploader_id=current_user.get("user_id"),
  647. uploader_name=current_user.get("real_name"),
  648. )
  649. return success_response({
  650. "id": task.id,
  651. "status": task.status,
  652. "delivererStatus": getattr(task, "deliverer_status", None),
  653. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  654. "message": "送达确认成功"
  655. })
  656. @router.get("/statistics/efficiency", response_model=dict)
  657. def get_deliverer_efficiency_statistics(
  658. db: Session = Depends(get_db),
  659. current_user: dict = Depends(require_deliverer)
  660. ):
  661. """获取配送员效率统计(小程序端工作台用)"""
  662. from app.crud.delivery import delivery_task_crud
  663. from app.crud.deliverer_review import deliverer_review_crud
  664. deliver_id = current_user.get("user_id")
  665. # 时间范围
  666. now = datetime.now()
  667. today = now.replace(hour=0, minute=0, second=0, microsecond=0)
  668. week_start = today - timedelta(days=today.weekday())
  669. month_start = today.replace(day=1)
  670. # 获取已完成任务(按配送员状态)
  671. completed_tasks, _ = delivery_task_crud.get_by_deliver(
  672. db, deliver_id=deliver_id, deliverer_status="delivered", skip=0, limit=5000
  673. )
  674. # 今日数据
  675. today_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= today]
  676. # 本周数据
  677. week_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= week_start]
  678. # 本月数据
  679. month_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= month_start]
  680. # 计算平均配送时长
  681. def calc_avg_delivery_minutes(tasks):
  682. minutes_list = []
  683. for task in tasks:
  684. if task.pickup_time and task.delivery_time:
  685. delta = task.delivery_time - task.pickup_time
  686. minutes_list.append(int(delta.total_seconds() / 60))
  687. return sum(minutes_list) / len(minutes_list) if minutes_list else 0
  688. # 计算准时率(假设承诺时长为60分钟)
  689. def calc_on_time_rate(tasks):
  690. if not tasks:
  691. return 100.0
  692. on_time_count = sum(1 for t in tasks if t.pickup_time and t.delivery_time)
  693. return round((on_time_count / len(tasks)) * 100, 1) if tasks else 100.0
  694. # 获取评价统计
  695. review_stats = deliverer_review_crud.get_deliverer_rating_stats(db, deliver_id)
  696. return success_response({
  697. "today": {
  698. "completedCount": len(today_completed),
  699. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(today_completed), 1),
  700. "onTimeRate": calc_on_time_rate(today_completed)
  701. },
  702. "week": {
  703. "completedCount": len(week_completed),
  704. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(week_completed), 1),
  705. "onTimeRate": calc_on_time_rate(week_completed)
  706. },
  707. "month": {
  708. "completedCount": len(month_completed),
  709. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(month_completed), 1),
  710. "onTimeRate": calc_on_time_rate(month_completed)
  711. },
  712. "rating": round(review_stats.get("avgScore", 0), 1),
  713. "totalCompleted": len(completed_tasks),
  714. "totalDistance": len(completed_tasks) * 5 # 估算配送距离
  715. })
  716. # =========================================
  717. # 个人资料管理
  718. # =========================================
  719. @router.get("/profile", response_model=dict)
  720. def get_deliverer_profile(
  721. db: Session = Depends(get_db),
  722. current_user: dict = Depends(require_deliverer)
  723. ):
  724. """获取配送员个人资料"""
  725. user = user_crud.get(db, current_user.get("user_id"))
  726. if not user:
  727. return error_response(404, "用户不存在")
  728. region_name = None
  729. if user.region_id:
  730. region = region_crud.get(db, user.region_id)
  731. region_name = region.name if region else None
  732. return success_response({
  733. "id": user.id,
  734. "username": user.username,
  735. "realName": user.real_name,
  736. "phone": user.phone,
  737. "role": user.role,
  738. "regionId": user.region_id,
  739. "regionName": region_name,
  740. "isActive": user.is_active
  741. })
  742. @router.put("/profile", response_model=dict)
  743. def update_deliverer_profile(
  744. request: dict,
  745. db: Session = Depends(get_db),
  746. current_user: dict = Depends(require_deliverer)
  747. ):
  748. """更新配送员个人资料"""
  749. user = user_crud.get(db, current_user.get("user_id"))
  750. if not user:
  751. return error_response(404, "用户不存在")
  752. payload = _resolve_request_payload(request, current_user)
  753. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  754. user = user_crud.update_with_history(db, current_user.get("user_id"), payload, updater)
  755. return success_response({
  756. "id": user.id,
  757. "realName": user.real_name,
  758. "phone": user.phone
  759. })
  760. # =========================================
  761. # 抢单功能
  762. # =========================================
  763. @router.get("/snatch/pending", response_model=dict)
  764. def list_pending_snatch_tasks(
  765. page: int = Query(1, ge=1, description="页码"),
  766. pageSize: int = Query(10, ge=1, le=50, description="每页条数"),
  767. db: Session = Depends(get_db),
  768. current_user: dict = Depends(require_deliverer)
  769. ):
  770. """获取待抢单的任务列表
  771. 返回所有未分配给特定配送员的任务,配送员可以抢单
  772. """
  773. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  774. # 获取待抢单的任务(status=pending 且 deliver_id 为空)
  775. tasks, total = delivery_task_crud.get_pending_snatch_tasks(
  776. db,
  777. skip=(page - 1) * pageSize,
  778. limit=pageSize
  779. )
  780. result = []
  781. for task in tasks:
  782. # 获取供应商信息
  783. supplier_info = {"name": None, "phone": None, "address": None}
  784. if task.supplier_id:
  785. supplier = user_crud.get(db, task.supplier_id)
  786. if supplier:
  787. supplier_info = {
  788. "name": supplier.real_name,
  789. "phone": supplier.phone,
  790. "address": getattr(supplier, 'address', None)
  791. }
  792. # 获取任务明细
  793. items = delivery_task_item_crud.get_by_task(db, task.id)
  794. # 计算配送地址数量和商品数量
  795. client_count = len(set(item.client_id for item in items if item.client_id))
  796. item_count = len(items)
  797. total_quantity = sum(float(item.quantity or 0) for item in items)
  798. # 计算剩余时间
  799. remaining_minutes = 0
  800. if task.effective_time:
  801. remaining = task.effective_time - datetime.now()
  802. remaining_minutes = max(0, int(remaining.total_seconds() / 60))
  803. result.append({
  804. "id": task.id,
  805. "taskNo": task.task_no,
  806. "supplierName": supplier_info.get("name"),
  807. "supplierPhone": supplier_info.get("phone"),
  808. "supplierAddress": supplier_info.get("address"),
  809. "clientCount": client_count,
  810. "itemCount": item_count,
  811. "totalQuantity": total_quantity,
  812. "status": task.status,
  813. "createTime": str(task.create_at) if task.create_at else None,
  814. "pickupDeadline": str(task.effective_time) if task.effective_time else None,
  815. "remainingMinutes": remaining_minutes
  816. })
  817. return success_response({
  818. "list": result,
  819. "total": total,
  820. "page": page,
  821. "pageSize": pageSize,
  822. "totalPages": (total + pageSize - 1) // pageSize if total > 0 else 0
  823. })
  824. @router.post("/snatch/{task_id}", response_model=dict)
  825. def snatch_task(
  826. task_id: str,
  827. db: Session = Depends(get_db),
  828. current_user: dict = Depends(require_deliverer)
  829. ):
  830. """抢单
  831. 配送员抢单成功后,该任务会分配给当前配送员
  832. """
  833. from app.crud.delivery import delivery_task_crud
  834. task = delivery_task_crud.get(db, task_id)
  835. if not task:
  836. return error_response(404, "配送任务不存在")
  837. # 检查任务是否可抢
  838. if task.status != "pending":
  839. return error_response(400, "该任务已无法抢单")
  840. if task.deliver_id:
  841. return error_response(400, "该任务已被其他配送员抢单")
  842. # 执行抢单
  843. deliver_id = current_user.get("user_id")
  844. deliver_name = current_user.get("real_name") or current_user.get("username", "")
  845. success = delivery_task_crud.assign_to_deliver(
  846. db,
  847. task_id=task_id,
  848. deliver_id=deliver_id,
  849. deliver_name=deliver_name
  850. )
  851. if not success:
  852. return error_response(400, "抢单失败,请稍后重试")
  853. return success_response({
  854. "id": task_id,
  855. "message": "抢单成功"
  856. })
  857. @router.get("/snatch/my", response_model=dict)
  858. def get_my_snatched_tasks(
  859. page: int = Query(1, ge=1, description="页码"),
  860. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  861. db: Session = Depends(get_db),
  862. current_user: dict = Depends(require_deliverer)
  863. ):
  864. """获取我抢到的任务列表
  865. 返回当前配送员已抢但尚未开始配送的任务
  866. """
  867. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  868. deliver_id = current_user.get("user_id")
  869. # 获取已抢到的任务(状态为 pending/assigned 但已分配给当前配送员)
  870. tasks, total = delivery_task_crud.get_snatched_by_me(
  871. db,
  872. deliver_id=deliver_id,
  873. skip=(page - 1) * pageSize,
  874. limit=pageSize
  875. )
  876. result = []
  877. for task in tasks:
  878. # 获取供应商信息
  879. supplier_info = {"name": None, "phone": None}
  880. if task.supplier_id:
  881. supplier = user_crud.get(db, task.supplier_id)
  882. if supplier:
  883. supplier_info = {"name": supplier.real_name, "phone": supplier.phone}
  884. # 获取任务明细
  885. items = delivery_task_item_crud.get_by_task(db, task.id)
  886. # 计算剩余时间
  887. remaining_minutes = 0
  888. if task.effective_time:
  889. remaining = task.effective_time - datetime.now()
  890. remaining_minutes = max(0, int(remaining.total_seconds() / 60))
  891. result.append({
  892. "id": task.id,
  893. "taskNo": task.task_no,
  894. "supplierName": supplier_info.get("name"),
  895. "supplierPhone": supplier_info.get("phone"),
  896. "clientCount": len(set(item.client_id for item in items if item.client_id)),
  897. "itemCount": len(items),
  898. "status": task.status,
  899. "createTime": str(task.create_at) if task.create_at else None,
  900. "pickupDeadline": str(task.effective_time) if task.effective_time else None,
  901. "remainingMinutes": remaining_minutes
  902. })
  903. return success_response({
  904. "list": result,
  905. "total": total,
  906. "page": page,
  907. "pageSize": pageSize,
  908. "totalPages": (total + pageSize - 1) // pageSize if total > 0 else 0
  909. })
  910. @router.delete("/snatch/{task_id}", response_model=dict)
  911. def cancel_snatched_task(
  912. task_id: str,
  913. db: Session = Depends(get_db),
  914. current_user: dict = Depends(require_deliverer)
  915. ):
  916. """取消抢单
  917. 配送员可以取消自己抢到但尚未开始配送的任务
  918. """
  919. from app.crud.delivery import delivery_task_crud
  920. task = delivery_task_crud.get(db, task_id)
  921. if not task:
  922. return error_response(404, "配送任务不存在")
  923. # 检查权限
  924. if task.deliver_id != current_user.get("user_id"):
  925. return error_response(403, "无权操作此任务")
  926. # 检查状态
  927. if task.status not in ["pending", "assigned"]:
  928. return error_response(400, "该任务已开始配送,无法取消")
  929. # 取消抢单
  930. success = delivery_task_crud.cancel_snatch(db, task_id)
  931. if not success:
  932. return error_response(400, "取消失败,请稍后重试")
  933. return success_response({"message": "取消成功"})
  934. @router.get("/snatch/statistics", response_model=dict)
  935. def get_snatch_statistics(
  936. db: Session = Depends(get_db),
  937. current_user: dict = Depends(require_deliverer)
  938. ):
  939. """获取抢单统计"""
  940. from app.crud.delivery import delivery_task_crud
  941. deliver_id = current_user.get("user_id")
  942. # 获取今日可抢单数量
  943. pending_count = delivery_task_crud.count_pending_snatch_tasks(db)
  944. # 获取我抢到的任务数量
  945. snatched_count = delivery_task_crud.count_snatched_by_me(db, deliver_id)
  946. # 获取今日抢单成功数量
  947. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  948. today_snatched_count = delivery_task_crud.count_today_snatched(db, deliver_id, today)
  949. return success_response({
  950. "pendingCount": pending_count,
  951. "mySnatchedCount": snatched_count,
  952. "todaySnatchedCount": today_snatched_count
  953. })