mini_deliverer.py 39 KB

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