mini_deliverer.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117
  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.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.replace(day=today.day - 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. # 更新任务状态
  602. task = delivery_task_crud.update_status(
  603. db,
  604. task_id=task_id,
  605. new_status="completed",
  606. operator_id=current_user.get("user_id"),
  607. remark=remark,
  608. image_urls=image_urls,
  609. operator_real_name=current_user.get("real_name"),
  610. )
  611. # 更新任务明细状态
  612. for item_data in items_data:
  613. item_id = item_data.get("taskItemId")
  614. delivered_quantity = item_data.get("deliveredQuantity", 0)
  615. delivery_task_item_crud.update_item_status(
  616. db,
  617. item_id=item_id,
  618. delivered_quantity=delivered_quantity,
  619. status="delivered"
  620. )
  621. # 配送员送货成功后,订单配送状态变为配送完成
  622. order = order_crud.get(db, task.order_id) if task.order_id else None
  623. if order and getattr(order, "order_status", None) != "cancelled":
  624. order.delivery_status = "delivered"
  625. if not getattr(order, "delivered_time", None):
  626. order.delivered_time = datetime.now()
  627. if not getattr(order, "order_status", None):
  628. order.order_status = "pending_confirm"
  629. order.status = derive_legacy_status_from_three(
  630. order_status=order.order_status,
  631. delivery_status=order.delivery_status,
  632. pay_status=order.pay_status,
  633. )
  634. order.update_at = datetime.now()
  635. db.commit()
  636. _save_task_attachments(
  637. db,
  638. task_id=task_id,
  639. business_type="delivery_deliver",
  640. image_urls=image_urls,
  641. uploader_id=current_user.get("user_id"),
  642. uploader_name=current_user.get("real_name"),
  643. )
  644. return success_response({
  645. "id": task.id,
  646. "status": task.status,
  647. "delivererStatus": getattr(task, "deliverer_status", None),
  648. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  649. "message": "送达确认成功"
  650. })
  651. @router.get("/statistics/efficiency", response_model=dict)
  652. def get_deliverer_efficiency_statistics(
  653. db: Session = Depends(get_db),
  654. current_user: dict = Depends(require_deliverer)
  655. ):
  656. """获取配送员效率统计(小程序端工作台用)"""
  657. from app.crud.delivery import delivery_task_crud
  658. from app.crud.deliverer_review import deliverer_review_crud
  659. deliver_id = current_user.get("user_id")
  660. # 时间范围
  661. now = datetime.now()
  662. today = now.replace(hour=0, minute=0, second=0, microsecond=0)
  663. week_start = today.replace(day=today.day - today.weekday())
  664. month_start = today.replace(day=1)
  665. # 获取已完成任务(按配送员状态)
  666. completed_tasks, _ = delivery_task_crud.get_by_deliver(
  667. db, deliver_id=deliver_id, deliverer_status="delivered", skip=0, limit=5000
  668. )
  669. # 今日数据
  670. today_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= today]
  671. # 本周数据
  672. week_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= week_start]
  673. # 本月数据
  674. month_completed = [t for t in completed_tasks if t.delivery_time and t.delivery_time >= month_start]
  675. # 计算平均配送时长
  676. def calc_avg_delivery_minutes(tasks):
  677. minutes_list = []
  678. for task in tasks:
  679. if task.pickup_time and task.delivery_time:
  680. delta = task.delivery_time - task.pickup_time
  681. minutes_list.append(int(delta.total_seconds() / 60))
  682. return sum(minutes_list) / len(minutes_list) if minutes_list else 0
  683. # 计算准时率(假设承诺时长为60分钟)
  684. def calc_on_time_rate(tasks):
  685. if not tasks:
  686. return 100.0
  687. on_time_count = sum(1 for t in tasks if t.pickup_time and t.delivery_time)
  688. return round((on_time_count / len(tasks)) * 100, 1) if tasks else 100.0
  689. # 获取评价统计
  690. review_stats = deliverer_review_crud.get_deliverer_rating_stats(db, deliver_id)
  691. return success_response({
  692. "today": {
  693. "completedCount": len(today_completed),
  694. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(today_completed), 1),
  695. "onTimeRate": calc_on_time_rate(today_completed)
  696. },
  697. "week": {
  698. "completedCount": len(week_completed),
  699. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(week_completed), 1),
  700. "onTimeRate": calc_on_time_rate(week_completed)
  701. },
  702. "month": {
  703. "completedCount": len(month_completed),
  704. "avgDeliveryMinutes": round(calc_avg_delivery_minutes(month_completed), 1),
  705. "onTimeRate": calc_on_time_rate(month_completed)
  706. },
  707. "rating": round(review_stats.get("avgScore", 0), 1),
  708. "totalCompleted": len(completed_tasks),
  709. "totalDistance": len(completed_tasks) * 5 # 估算配送距离
  710. })
  711. # =========================================
  712. # 个人资料管理
  713. # =========================================
  714. @router.get("/profile", response_model=dict)
  715. def get_deliverer_profile(
  716. db: Session = Depends(get_db),
  717. current_user: dict = Depends(require_deliverer)
  718. ):
  719. """获取配送员个人资料"""
  720. user = user_crud.get(db, current_user.get("user_id"))
  721. if not user:
  722. return error_response(404, "用户不存在")
  723. region_name = None
  724. if user.region_id:
  725. region = region_crud.get(db, user.region_id)
  726. region_name = region.name if region else None
  727. return success_response({
  728. "id": user.id,
  729. "username": user.username,
  730. "realName": user.real_name,
  731. "phone": user.phone,
  732. "role": user.role,
  733. "regionId": user.region_id,
  734. "regionName": region_name,
  735. "isActive": user.is_active
  736. })
  737. @router.put("/profile", response_model=dict)
  738. def update_deliverer_profile(
  739. request: dict,
  740. db: Session = Depends(get_db),
  741. current_user: dict = Depends(require_deliverer)
  742. ):
  743. """更新配送员个人资料"""
  744. user = user_crud.get(db, current_user.get("user_id"))
  745. if not user:
  746. return error_response(404, "用户不存在")
  747. payload = _resolve_request_payload(request, current_user)
  748. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  749. user = user_crud.update_with_history(db, current_user.get("user_id"), payload, updater)
  750. return success_response({
  751. "id": user.id,
  752. "realName": user.real_name,
  753. "phone": user.phone
  754. })
  755. # =========================================
  756. # 抢单功能
  757. # =========================================
  758. @router.get("/snatch/pending", response_model=dict)
  759. def list_pending_snatch_tasks(
  760. page: int = Query(1, ge=1, description="页码"),
  761. pageSize: int = Query(10, ge=1, le=50, description="每页条数"),
  762. db: Session = Depends(get_db),
  763. current_user: dict = Depends(require_deliverer)
  764. ):
  765. """获取待抢单的任务列表
  766. 返回所有未分配给特定配送员的任务,配送员可以抢单
  767. """
  768. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  769. # 获取待抢单的任务(status=pending 且 deliver_id 为空)
  770. tasks, total = delivery_task_crud.get_pending_snatch_tasks(
  771. db,
  772. skip=(page - 1) * pageSize,
  773. limit=pageSize
  774. )
  775. result = []
  776. for task in tasks:
  777. # 获取供应商信息
  778. supplier_info = {"name": None, "phone": None, "address": None}
  779. if task.supplier_id:
  780. supplier = user_crud.get(db, task.supplier_id)
  781. if supplier:
  782. supplier_info = {
  783. "name": supplier.real_name,
  784. "phone": supplier.phone,
  785. "address": getattr(supplier, 'address', None)
  786. }
  787. # 获取任务明细
  788. items = delivery_task_item_crud.get_by_task(db, task.id)
  789. # 计算配送地址数量和商品数量
  790. client_count = len(set(item.client_id for item in items if item.client_id))
  791. item_count = len(items)
  792. total_quantity = sum(float(item.quantity or 0) for item in items)
  793. # 计算剩余时间
  794. remaining_minutes = 0
  795. if task.effective_time:
  796. remaining = task.effective_time - datetime.now()
  797. remaining_minutes = max(0, int(remaining.total_seconds() / 60))
  798. result.append({
  799. "id": task.id,
  800. "taskNo": task.task_no,
  801. "supplierName": supplier_info.get("name"),
  802. "supplierPhone": supplier_info.get("phone"),
  803. "supplierAddress": supplier_info.get("address"),
  804. "clientCount": client_count,
  805. "itemCount": item_count,
  806. "totalQuantity": total_quantity,
  807. "status": task.status,
  808. "createTime": str(task.create_at) if task.create_at else None,
  809. "pickupDeadline": str(task.effective_time) if task.effective_time else None,
  810. "remainingMinutes": remaining_minutes
  811. })
  812. return success_response({
  813. "list": result,
  814. "total": total,
  815. "page": page,
  816. "pageSize": pageSize,
  817. "totalPages": (total + pageSize - 1) // pageSize if total > 0 else 0
  818. })
  819. @router.post("/snatch/{task_id}", response_model=dict)
  820. def snatch_task(
  821. task_id: str,
  822. db: Session = Depends(get_db),
  823. current_user: dict = Depends(require_deliverer)
  824. ):
  825. """抢单
  826. 配送员抢单成功后,该任务会分配给当前配送员
  827. """
  828. from app.crud.delivery import delivery_task_crud
  829. task = delivery_task_crud.get(db, task_id)
  830. if not task:
  831. return error_response(404, "配送任务不存在")
  832. # 检查任务是否可抢
  833. if task.status != "pending":
  834. return error_response(400, "该任务已无法抢单")
  835. if task.deliver_id:
  836. return error_response(400, "该任务已被其他配送员抢单")
  837. # 执行抢单
  838. deliver_id = current_user.get("user_id")
  839. deliver_name = current_user.get("real_name") or current_user.get("username", "")
  840. success = delivery_task_crud.assign_to_deliver(
  841. db,
  842. task_id=task_id,
  843. deliver_id=deliver_id,
  844. deliver_name=deliver_name
  845. )
  846. if not success:
  847. return error_response(400, "抢单失败,请稍后重试")
  848. return success_response({
  849. "id": task_id,
  850. "message": "抢单成功"
  851. })
  852. @router.get("/snatch/my", response_model=dict)
  853. def get_my_snatched_tasks(
  854. page: int = Query(1, ge=1, description="页码"),
  855. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  856. db: Session = Depends(get_db),
  857. current_user: dict = Depends(require_deliverer)
  858. ):
  859. """获取我抢到的任务列表
  860. 返回当前配送员已抢但尚未开始配送的任务
  861. """
  862. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  863. deliver_id = current_user.get("user_id")
  864. # 获取已抢到的任务(状态为 pending/assigned 但已分配给当前配送员)
  865. tasks, total = delivery_task_crud.get_snatched_by_me(
  866. db,
  867. deliver_id=deliver_id,
  868. skip=(page - 1) * pageSize,
  869. limit=pageSize
  870. )
  871. result = []
  872. for task in tasks:
  873. # 获取供应商信息
  874. supplier_info = {"name": None, "phone": None}
  875. if task.supplier_id:
  876. supplier = user_crud.get(db, task.supplier_id)
  877. if supplier:
  878. supplier_info = {"name": supplier.real_name, "phone": supplier.phone}
  879. # 获取任务明细
  880. items = delivery_task_item_crud.get_by_task(db, task.id)
  881. # 计算剩余时间
  882. remaining_minutes = 0
  883. if task.effective_time:
  884. remaining = task.effective_time - datetime.now()
  885. remaining_minutes = max(0, int(remaining.total_seconds() / 60))
  886. result.append({
  887. "id": task.id,
  888. "taskNo": task.task_no,
  889. "supplierName": supplier_info.get("name"),
  890. "supplierPhone": supplier_info.get("phone"),
  891. "clientCount": len(set(item.client_id for item in items if item.client_id)),
  892. "itemCount": len(items),
  893. "status": task.status,
  894. "createTime": str(task.create_at) if task.create_at else None,
  895. "pickupDeadline": str(task.effective_time) if task.effective_time else None,
  896. "remainingMinutes": remaining_minutes
  897. })
  898. return success_response({
  899. "list": result,
  900. "total": total,
  901. "page": page,
  902. "pageSize": pageSize,
  903. "totalPages": (total + pageSize - 1) // pageSize if total > 0 else 0
  904. })
  905. @router.delete("/snatch/{task_id}", response_model=dict)
  906. def cancel_snatched_task(
  907. task_id: str,
  908. db: Session = Depends(get_db),
  909. current_user: dict = Depends(require_deliverer)
  910. ):
  911. """取消抢单
  912. 配送员可以取消自己抢到但尚未开始配送的任务
  913. """
  914. from app.crud.delivery import delivery_task_crud
  915. task = delivery_task_crud.get(db, task_id)
  916. if not task:
  917. return error_response(404, "配送任务不存在")
  918. # 检查权限
  919. if task.deliver_id != current_user.get("user_id"):
  920. return error_response(403, "无权操作此任务")
  921. # 检查状态
  922. if task.status not in ["pending", "assigned"]:
  923. return error_response(400, "该任务已开始配送,无法取消")
  924. # 取消抢单
  925. success = delivery_task_crud.cancel_snatch(db, task_id)
  926. if not success:
  927. return error_response(400, "取消失败,请稍后重试")
  928. return success_response({"message": "取消成功"})
  929. @router.get("/snatch/statistics", response_model=dict)
  930. def get_snatch_statistics(
  931. db: Session = Depends(get_db),
  932. current_user: dict = Depends(require_deliverer)
  933. ):
  934. """获取抢单统计"""
  935. from app.crud.delivery import delivery_task_crud
  936. deliver_id = current_user.get("user_id")
  937. # 获取今日可抢单数量
  938. pending_count = delivery_task_crud.count_pending_snatch_tasks(db)
  939. # 获取我抢到的任务数量
  940. snatched_count = delivery_task_crud.count_snatched_by_me(db, deliver_id)
  941. # 获取今日抢单成功数量
  942. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  943. today_snatched_count = delivery_task_crud.count_today_snatched(db, deliver_id, today)
  944. return success_response({
  945. "pendingCount": pending_count,
  946. "mySnatchedCount": snatched_count,
  947. "todaySnatchedCount": today_snatched_count
  948. })