pl_driver_plan_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. """运输计划 · 司机/押运登录态(history / page / feedback / upload)"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from datetime import datetime
  6. from decimal import Decimal
  7. from typing import Any, Optional, Tuple
  8. from fastapi import UploadFile
  9. from sqlalchemy import and_, func, or_
  10. from sqlalchemy.orm import Session
  11. from app.core.attachment_biz import resolve_upload_folder
  12. from app.core.config import settings
  13. from app.core.storage import storage
  14. from app.crud import oss as oss_svc
  15. from app.models.pl_models import (
  16. PlPersonnel,
  17. PlPlan,
  18. PlPlanProduct,
  19. PlPlanTransport,
  20. PlTransport,
  21. )
  22. from app.schemas.pl.transport import (
  23. PublicTransportDriverStopFeedback,
  24. PublicTransportFeedback,
  25. PublicTransportHistoryPageRead,
  26. PublicTransportHistoryProductRead,
  27. PublicTransportHistoryRecordRead,
  28. PublicTransportPageRead,
  29. )
  30. from app.services.pl_feedback_brand_qty import (
  31. persist_transport_product_feedback,
  32. require_brand_qtys_if_needed,
  33. )
  34. from app.services.pl_product_display import (
  35. load_crm_product_alias_maps,
  36. resolve_product_display_names,
  37. )
  38. from app.services.pl_public_transport_helpers import (
  39. CRM_PRODUCT_UNIT,
  40. _accumulate_calendar_day_counts,
  41. _history_status_label,
  42. _parse_calendar_month,
  43. _parse_history_date,
  44. _resolve_driver_link_flags,
  45. _resolve_unit_label,
  46. )
  47. from app.services.pl_transport_stop_feedback_service import (
  48. load_driver_stop_segments,
  49. save_driver_stop_feedback,
  50. )
  51. from app.utils.excel_export import load_dict_label_maps
  52. def transport_visible_to_personnel(transport: PlTransport, personnel_id: str) -> bool:
  53. pid = (personnel_id or "").strip()
  54. if not pid:
  55. return False
  56. return (transport.driver_id or "").strip() == pid or (transport.escort_id or "").strip() == pid
  57. def resolve_personnel_for_user(
  58. db: Session, *, tenant_no: str, user_id: str
  59. ) -> Optional[PlPersonnel]:
  60. uid = (user_id or "").strip()
  61. if not uid:
  62. return None
  63. return (
  64. db.query(PlPersonnel)
  65. .filter(
  66. PlPersonnel.tenant_no == tenant_no,
  67. PlPersonnel.sys_user_id == uid,
  68. PlPersonnel.del_flag == 0,
  69. )
  70. .first()
  71. )
  72. def resolve_transport_for_driver_plan(
  73. db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
  74. ) -> Tuple[Optional[PlPlan], Optional[PlTransport]]:
  75. """本租户计划 + 运输,且当前人员是司机或押运。"""
  76. plan = (
  77. db.query(PlPlan)
  78. .filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no, PlPlan.del_flag == 0)
  79. .first()
  80. )
  81. if not plan:
  82. return None, None
  83. link = (
  84. db.query(PlPlanTransport)
  85. .filter(PlPlanTransport.plan_id == plan.id, PlPlanTransport.del_flag == 0)
  86. .order_by(PlPlanTransport.create_time.desc())
  87. .first()
  88. )
  89. if not link or not link.transport_id:
  90. return plan, None
  91. transport = (
  92. db.query(PlTransport)
  93. .filter(
  94. PlTransport.id == link.transport_id,
  95. PlTransport.tenant_no == tenant_no,
  96. PlTransport.del_flag == 0,
  97. )
  98. .first()
  99. )
  100. if not transport or not transport_visible_to_personnel(transport, personnel_id):
  101. return plan, None
  102. return plan, transport
  103. def _build_driver_url(token: str) -> str:
  104. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  105. return f"{origin}/#/public/pl/transport/{token}"
  106. def _format_file_size(size_bytes: int) -> str:
  107. if size_bytes < 1024:
  108. return f"{size_bytes} B"
  109. if size_bytes < 1024 * 1024:
  110. return f"{size_bytes / 1024:.1f} KB"
  111. return f"{size_bytes / (1024 * 1024):.1f} MB"
  112. def build_driver_history_page(
  113. db: Session,
  114. *,
  115. tenant_no: str,
  116. user_id: str,
  117. date_str: str,
  118. ) -> Tuple[Optional[PublicTransportHistoryPageRead], Optional[str]]:
  119. day = _parse_history_date(date_str)
  120. if day is None:
  121. return None, "日期格式无效,请使用 YYYY-MM-DD"
  122. personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
  123. if not personnel:
  124. return (
  125. PublicTransportHistoryPageRead(
  126. license_plate=None, date=day.isoformat(), records=[]
  127. ),
  128. None,
  129. )
  130. personnel_id = personnel.id
  131. rows = (
  132. db.query(PlPlan, PlTransport)
  133. .join(
  134. PlPlanTransport,
  135. and_(
  136. PlPlanTransport.plan_id == PlPlan.id,
  137. PlPlanTransport.del_flag == 0,
  138. ),
  139. )
  140. .join(
  141. PlTransport,
  142. and_(
  143. PlTransport.id == PlPlanTransport.transport_id,
  144. PlTransport.del_flag == 0,
  145. ),
  146. )
  147. .filter(
  148. PlPlan.tenant_no == tenant_no,
  149. PlPlan.del_flag == 0,
  150. PlPlan.transport_status == "arranged",
  151. PlTransport.tenant_no == tenant_no,
  152. or_(
  153. PlTransport.driver_id == personnel_id,
  154. PlTransport.escort_id == personnel_id,
  155. ),
  156. or_(
  157. and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
  158. and_(
  159. PlPlan.delivery_time.isnot(None),
  160. func.date(PlPlan.delivery_time) == day,
  161. ),
  162. ),
  163. )
  164. .order_by(
  165. PlPlan.pickup_time.desc(),
  166. PlPlan.plan_date.desc(),
  167. PlPlan.create_time.desc(),
  168. )
  169. .all()
  170. )
  171. seen_plan: set[str] = set()
  172. plan_rows: list[tuple[PlPlan, PlTransport]] = []
  173. for plan, tr in rows:
  174. if plan.id in seen_plan:
  175. continue
  176. seen_plan.add(plan.id)
  177. plan_rows.append((plan, tr))
  178. # Lazy import to reuse public history stop builder without circular import at module load.
  179. from app.api.pl.public_transport import _build_history_stop_reads
  180. products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
  181. if plan_rows:
  182. plan_ids = [plan.id for plan, _ in plan_rows]
  183. product_rows = (
  184. db.query(PlPlanProduct)
  185. .filter(
  186. PlPlanProduct.plan_id.in_(plan_ids),
  187. PlPlanProduct.del_flag == 0,
  188. )
  189. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  190. .all()
  191. )
  192. crm_product_ids = {
  193. (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
  194. }
  195. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  196. db, tenant_no=tenant_no, product_ids=crm_product_ids
  197. )
  198. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  199. CRM_PRODUCT_UNIT, {}
  200. )
  201. for prod in product_rows:
  202. product_id = (prod.product_id or "").strip()
  203. display_name, _alias_name = resolve_product_display_names(
  204. saved_product_name=prod.product_name,
  205. product_id=product_id,
  206. standard_by_id=standard_by_id,
  207. alias_options_by_id=alias_options_by_id,
  208. )
  209. products_by_plan.setdefault(prod.plan_id, []).append(
  210. PublicTransportHistoryProductRead(
  211. product_name=display_name or prod.product_name or "—",
  212. model=prod.model,
  213. package_spec=prod.package_spec,
  214. quantity=prod.quantity
  215. or prod.inbound_estimate_qty
  216. or prod.outbound_estimate_qty,
  217. unit=_resolve_unit_label(unit_map, prod.unit),
  218. )
  219. )
  220. from app.services.db.pound_qr_public import build_pound_qrcode_items_for_plan
  221. records: list[PublicTransportHistoryRecordRead] = []
  222. for plan, tr in plan_rows:
  223. voided, archived = _resolve_driver_link_flags(plan, tr)
  224. tok = (tr.driver_access_token or "").strip()
  225. pound_items = build_pound_qrcode_items_for_plan(
  226. db,
  227. tenant_no=tenant_no,
  228. plan_id=plan.id,
  229. transport_id=tr.id,
  230. )
  231. records.append(
  232. PublicTransportHistoryRecordRead(
  233. plan_id=plan.id,
  234. plan_no=plan.plan_no or "",
  235. transport_id=tr.id,
  236. driver_access_token=tok or None,
  237. driver_url=_build_driver_url(tok) if tok else None,
  238. license_plate=tr.license_plate,
  239. pickup_time=plan.pickup_time,
  240. delivery_time=plan.delivery_time,
  241. pickup_address=plan.pickup_address,
  242. destination_address=plan.destination_address or plan.destination_name,
  243. products=products_by_plan.get(plan.id) or [],
  244. stops=_build_history_stop_reads(
  245. db, tenant_no=tenant_no, plan=plan, transport=tr
  246. ),
  247. plan_voided=voided,
  248. plan_archived=archived,
  249. status_label=_history_status_label(voided, archived),
  250. pound_qrcode_items=pound_items,
  251. )
  252. )
  253. return (
  254. PublicTransportHistoryPageRead(
  255. license_plate=None,
  256. date=day.isoformat(),
  257. records=records,
  258. ),
  259. None,
  260. )
  261. def build_driver_calendar_counts(
  262. db: Session,
  263. *,
  264. tenant_no: str,
  265. user_id: str,
  266. month_str: str,
  267. ) -> Tuple[Optional[dict[str, int]], Optional[str]]:
  268. """按月统计司机可见计划条数,键为 YYYY-MM-DD。"""
  269. bounds = _parse_calendar_month(month_str)
  270. if bounds is None:
  271. return None, "月份格式无效,请使用 YYYY-MM"
  272. month_start, month_end = bounds
  273. personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
  274. if not personnel:
  275. return {}, None
  276. personnel_id = personnel.id
  277. rows = (
  278. db.query(PlPlan)
  279. .join(
  280. PlPlanTransport,
  281. and_(
  282. PlPlanTransport.plan_id == PlPlan.id,
  283. PlPlanTransport.del_flag == 0,
  284. ),
  285. )
  286. .join(
  287. PlTransport,
  288. and_(
  289. PlTransport.id == PlPlanTransport.transport_id,
  290. PlTransport.del_flag == 0,
  291. ),
  292. )
  293. .filter(
  294. PlPlan.tenant_no == tenant_no,
  295. PlPlan.del_flag == 0,
  296. PlPlan.transport_status == "arranged",
  297. PlTransport.tenant_no == tenant_no,
  298. or_(
  299. PlTransport.driver_id == personnel_id,
  300. PlTransport.escort_id == personnel_id,
  301. ),
  302. or_(
  303. and_(
  304. PlPlan.pickup_time.isnot(None),
  305. func.date(PlPlan.pickup_time) >= month_start,
  306. func.date(PlPlan.pickup_time) <= month_end,
  307. ),
  308. and_(
  309. PlPlan.delivery_time.isnot(None),
  310. func.date(PlPlan.delivery_time) >= month_start,
  311. func.date(PlPlan.delivery_time) <= month_end,
  312. ),
  313. ),
  314. )
  315. .all()
  316. )
  317. seen: set[str] = set()
  318. plans: list[PlPlan] = []
  319. for plan in rows:
  320. if plan.id in seen:
  321. continue
  322. seen.add(plan.id)
  323. plans.append(plan)
  324. return (
  325. _accumulate_calendar_day_counts(
  326. plans,
  327. month_start=month_start,
  328. month_end=month_end,
  329. include_plan_date=False,
  330. ),
  331. None,
  332. )
  333. def build_driver_page_for_plan(
  334. db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
  335. ) -> Tuple[Optional[PublicTransportPageRead], Optional[str]]:
  336. plan, transport = resolve_transport_for_driver_plan(
  337. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  338. )
  339. if not plan:
  340. return None, "计划不存在"
  341. if not transport:
  342. return None, "无权限查看该运输计划或未安排运输"
  343. from app.api.pl.public_transport import _build_public_page
  344. return _build_public_page(db, transport), None
  345. def save_driver_feedback_for_plan(
  346. db: Session,
  347. *,
  348. tenant_no: str,
  349. plan_id: str,
  350. personnel_id: str,
  351. payload: PublicTransportFeedback,
  352. ) -> Tuple[bool, Optional[str]]:
  353. plan, transport = resolve_transport_for_driver_plan(
  354. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  355. )
  356. if not plan:
  357. return False, "计划不存在"
  358. if not transport:
  359. return False, "无权限操作该运输计划"
  360. from app.api.pl.public_transport import _build_public_page
  361. page = _build_public_page(db, transport)
  362. if page.plan_voided:
  363. return False, "运输计划已作废,无法提交回执"
  364. if page.plan_archived:
  365. return False, "运输计划已归档,无法提交回执"
  366. if payload.feedback_images is not None:
  367. transport.feedback_images = json.dumps(payload.feedback_images)
  368. if payload.feedback_remark is not None:
  369. transport.feedback_remark = payload.feedback_remark
  370. if payload.products:
  371. products_payload = [
  372. {
  373. "planProductId": item.plan_product_id,
  374. "actualQty": item.actual_qty,
  375. "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
  376. }
  377. for item in payload.products
  378. ]
  379. try:
  380. persist_transport_product_feedback(
  381. db,
  382. tenant_no=transport.tenant_no,
  383. transport=transport,
  384. plan_id=plan.id,
  385. products=products_payload,
  386. user_id=None,
  387. enforce_brand=True,
  388. )
  389. except ValueError as exc:
  390. return False, str(exc)
  391. elif payload.feedback_qty is not None:
  392. transport.feedback_qty = payload.feedback_qty
  393. transport.feedback_time = datetime.now()
  394. return True, None
  395. def save_driver_stop_feedback_for_plan(
  396. db: Session,
  397. *,
  398. tenant_no: str,
  399. plan_id: str,
  400. personnel_id: str,
  401. payload: PublicTransportDriverStopFeedback,
  402. ) -> Tuple[bool, Optional[str]]:
  403. plan, transport = resolve_transport_for_driver_plan(
  404. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  405. )
  406. if not plan:
  407. return False, "计划不存在"
  408. if not transport:
  409. return False, "无权限操作该运输计划"
  410. from app.api.pl.public_transport import _build_public_page
  411. page = _build_public_page(db, transport)
  412. if page.plan_voided:
  413. return False, "运输计划已作废,无法提交回执"
  414. if page.plan_archived:
  415. return False, "运输计划已归档,无法提交回执"
  416. stop_type = (payload.stop_type or "").strip()
  417. stop_id = (payload.stop_id or "").strip()
  418. if stop_type not in ("load", "delivery") or not stop_id:
  419. return False, "停靠段参数无效"
  420. segments = load_driver_stop_segments(
  421. db,
  422. tenant_no=transport.tenant_no,
  423. plan_id=plan.id,
  424. transport_id=transport.id,
  425. )
  426. segment = next(
  427. (row for row in segments if row.stop_type == stop_type and row.stop_id == stop_id),
  428. None,
  429. )
  430. if not segment:
  431. return False, "停靠段不存在或不可回传"
  432. products_payload: Optional[list[dict]] = None
  433. if payload.products is not None:
  434. allowed_products = {row.stop_product_id: row for row in segment.products}
  435. plan_products = (
  436. db.query(PlPlanProduct)
  437. .filter(
  438. PlPlanProduct.plan_id == plan.id,
  439. PlPlanProduct.tenant_no == transport.tenant_no,
  440. PlPlanProduct.del_flag == 0,
  441. )
  442. .all()
  443. )
  444. plan_products_by_id = {row.id: row for row in plan_products}
  445. products_payload = []
  446. for item in payload.products:
  447. stop_product_id = (item.stop_product_id or "").strip()
  448. stop_row = allowed_products.get(stop_product_id)
  449. if not stop_row:
  450. return False, "产品不属于当前停靠段"
  451. actual_qty = Decimal(str(item.actual_qty or 0))
  452. brand_qtys = [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])]
  453. plan_product = plan_products_by_id.get(stop_row.plan_product_id)
  454. try:
  455. require_brand_qtys_if_needed(
  456. brand_mode=plan_product.brand_mode if plan_product else None,
  457. required_brand_id=plan_product.brand_id if plan_product else None,
  458. actual_qty=actual_qty,
  459. rows=brand_qtys,
  460. enforce=True,
  461. )
  462. except ValueError as exc:
  463. return False, str(exc)
  464. row_payload: dict = {
  465. "stopProductId": stop_product_id,
  466. "actualQty": float(actual_qty),
  467. }
  468. if brand_qtys:
  469. row_payload["brandQtys"] = brand_qtys
  470. products_payload.append(row_payload)
  471. save_driver_stop_feedback(
  472. db,
  473. tenant_no=transport.tenant_no,
  474. transport_id=transport.id,
  475. plan_id=plan.id,
  476. stop_type=stop_type,
  477. stop_id=stop_id,
  478. attachment_urls=payload.attachment_urls or [],
  479. products=products_payload,
  480. remark=payload.remark,
  481. )
  482. return True, None
  483. def upload_driver_attachment_for_plan(
  484. db: Session,
  485. *,
  486. tenant_no: str,
  487. plan_id: str,
  488. personnel_id: str,
  489. file: UploadFile,
  490. folder: str = "",
  491. ) -> Tuple[Optional[dict[str, Any]], Optional[Tuple[int, str]]]:
  492. plan, transport = resolve_transport_for_driver_plan(
  493. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  494. )
  495. if not plan:
  496. return None, (404, "计划不存在")
  497. if not transport:
  498. return None, (403, "无权限操作该运输计划")
  499. from app.api.pl.public_transport import _build_public_page
  500. page = _build_public_page(db, transport)
  501. if page.plan_voided:
  502. return None, (400, "运输计划已作废,无法上传附件")
  503. if page.plan_archived:
  504. return None, (400, "运输计划已归档,无法上传附件")
  505. if not file or not file.filename:
  506. return None, (400, "请选择文件")
  507. try:
  508. effective_folder = resolve_upload_folder(
  509. folder=folder or "", biz_type="pl_transport_feedback"
  510. )
  511. file_content = file.file.read()
  512. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  513. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  514. if base_url and full_url.startswith(base_url + "/"):
  515. file_url = full_url[len(base_url) + 1 :]
  516. else:
  517. file_url = full_url
  518. record = oss_svc.create_oss(
  519. db,
  520. tenant_no=transport.tenant_no,
  521. url=file_url,
  522. original_name=file.filename,
  523. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  524. file_size=len(file_content),
  525. file_type=file.content_type or "",
  526. biz_type="pl_transport_feedback",
  527. user_id=None,
  528. ext1=effective_folder or None,
  529. )
  530. db.commit()
  531. return {
  532. "url": full_url,
  533. "name": file.filename,
  534. "size": _format_file_size(len(file_content)),
  535. "ossId": record.id if record else None,
  536. }, None
  537. except Exception as exc: # noqa: BLE001
  538. db.rollback()
  539. return None, (500, f"上传失败: {exc}")