pl_driver_plan_service.py 17 KB

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