pl_driver_plan_service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  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 allocate_or_get_plan_copy_seq(
  104. db: Session, *, tenant_no: str, plan_id: str
  105. ) -> Optional[int]:
  106. """
  107. 返回计划的 copy_seq_no;若为空则按租户 MAX(含软删)+1 写入后返回。
  108. 调用方负责 commit。计划不存在返回 None。
  109. """
  110. plan = (
  111. db.query(PlPlan)
  112. .filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no)
  113. .with_for_update()
  114. .first()
  115. )
  116. if not plan:
  117. return None
  118. if plan.copy_seq_no is not None:
  119. return int(plan.copy_seq_no)
  120. max_seq = (
  121. db.query(func.max(PlPlan.copy_seq_no))
  122. .filter(PlPlan.tenant_no == tenant_no)
  123. .with_for_update()
  124. .scalar()
  125. )
  126. next_seq = int(max_seq or 0) + 1
  127. plan.copy_seq_no = next_seq
  128. db.flush()
  129. return next_seq
  130. def ensure_copy_seq_for_driver(
  131. db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
  132. ) -> Tuple[Optional[int], Optional[str]]:
  133. """
  134. 司机可见性与 GET 详情一致;通过后分配/返回 copy_seq_no。
  135. 成功返回 (seq, None);失败返回 (None, err)。
  136. """
  137. plan, transport = resolve_transport_for_driver_plan(
  138. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  139. )
  140. if not plan:
  141. return None, "计划不存在"
  142. if not transport:
  143. return None, "无权限查看该运输计划或未安排运输"
  144. seq = allocate_or_get_plan_copy_seq(db, tenant_no=tenant_no, plan_id=plan.id)
  145. if seq is None:
  146. return None, "计划不存在"
  147. return seq, None
  148. def _build_driver_url(token: str) -> str:
  149. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  150. return f"{origin}/#/public/pl/transport/{token}"
  151. def _format_file_size(size_bytes: int) -> str:
  152. if size_bytes < 1024:
  153. return f"{size_bytes} B"
  154. if size_bytes < 1024 * 1024:
  155. return f"{size_bytes / 1024:.1f} KB"
  156. return f"{size_bytes / (1024 * 1024):.1f} MB"
  157. def build_driver_history_page(
  158. db: Session,
  159. *,
  160. tenant_no: str,
  161. user_id: str,
  162. date_str: str,
  163. ) -> Tuple[Optional[PublicTransportHistoryPageRead], Optional[str]]:
  164. day = _parse_history_date(date_str)
  165. if day is None:
  166. return None, "日期格式无效,请使用 YYYY-MM-DD"
  167. personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
  168. if not personnel:
  169. return (
  170. PublicTransportHistoryPageRead(
  171. license_plate=None, date=day.isoformat(), records=[]
  172. ),
  173. None,
  174. )
  175. personnel_id = personnel.id
  176. rows = (
  177. db.query(PlPlan, PlTransport)
  178. .join(
  179. PlPlanTransport,
  180. and_(
  181. PlPlanTransport.plan_id == PlPlan.id,
  182. PlPlanTransport.del_flag == 0,
  183. ),
  184. )
  185. .join(
  186. PlTransport,
  187. and_(
  188. PlTransport.id == PlPlanTransport.transport_id,
  189. PlTransport.del_flag == 0,
  190. ),
  191. )
  192. .filter(
  193. PlPlan.tenant_no == tenant_no,
  194. PlPlan.del_flag == 0,
  195. PlPlan.transport_status == "arranged",
  196. PlTransport.tenant_no == tenant_no,
  197. or_(
  198. PlTransport.driver_id == personnel_id,
  199. PlTransport.escort_id == personnel_id,
  200. ),
  201. or_(
  202. and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
  203. and_(
  204. PlPlan.delivery_time.isnot(None),
  205. func.date(PlPlan.delivery_time) == day,
  206. ),
  207. ),
  208. )
  209. .order_by(
  210. PlPlan.pickup_time.desc(),
  211. PlPlan.plan_date.desc(),
  212. PlPlan.create_time.desc(),
  213. )
  214. .all()
  215. )
  216. seen_plan: set[str] = set()
  217. plan_rows: list[tuple[PlPlan, PlTransport]] = []
  218. for plan, tr in rows:
  219. if plan.id in seen_plan:
  220. continue
  221. seen_plan.add(plan.id)
  222. plan_rows.append((plan, tr))
  223. # Lazy import to reuse public history stop builder without circular import at module load.
  224. from app.api.pl.public_transport import _build_history_stop_reads
  225. products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
  226. if plan_rows:
  227. plan_ids = [plan.id for plan, _ in plan_rows]
  228. product_rows = (
  229. db.query(PlPlanProduct)
  230. .filter(
  231. PlPlanProduct.plan_id.in_(plan_ids),
  232. PlPlanProduct.del_flag == 0,
  233. )
  234. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  235. .all()
  236. )
  237. crm_product_ids = {
  238. (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
  239. }
  240. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  241. db, tenant_no=tenant_no, product_ids=crm_product_ids
  242. )
  243. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  244. CRM_PRODUCT_UNIT, {}
  245. )
  246. for prod in product_rows:
  247. product_id = (prod.product_id or "").strip()
  248. display_name, _alias_name = resolve_product_display_names(
  249. saved_product_name=prod.product_name,
  250. product_id=product_id,
  251. standard_by_id=standard_by_id,
  252. alias_options_by_id=alias_options_by_id,
  253. )
  254. products_by_plan.setdefault(prod.plan_id, []).append(
  255. PublicTransportHistoryProductRead(
  256. product_name=display_name or prod.product_name or "—",
  257. model=prod.model,
  258. package_spec=prod.package_spec,
  259. quantity=prod.quantity
  260. or prod.inbound_estimate_qty
  261. or prod.outbound_estimate_qty,
  262. unit=_resolve_unit_label(unit_map, prod.unit),
  263. )
  264. )
  265. from app.services.db.pound_qr_public import build_pound_qrcode_items_for_plan
  266. records: list[PublicTransportHistoryRecordRead] = []
  267. for plan, tr in plan_rows:
  268. voided, archived = _resolve_driver_link_flags(plan, tr)
  269. tok = (tr.driver_access_token or "").strip()
  270. pound_items = build_pound_qrcode_items_for_plan(
  271. db,
  272. tenant_no=tenant_no,
  273. plan_id=plan.id,
  274. transport_id=tr.id,
  275. )
  276. records.append(
  277. PublicTransportHistoryRecordRead(
  278. plan_id=plan.id,
  279. plan_no=plan.plan_no or "",
  280. transport_id=tr.id,
  281. driver_access_token=tok or None,
  282. driver_url=_build_driver_url(tok) if tok else None,
  283. license_plate=tr.license_plate,
  284. pickup_time=plan.pickup_time,
  285. delivery_time=plan.delivery_time,
  286. pickup_address=plan.pickup_address,
  287. destination_address=plan.destination_address or plan.destination_name,
  288. products=products_by_plan.get(plan.id) or [],
  289. stops=_build_history_stop_reads(
  290. db, tenant_no=tenant_no, plan=plan, transport=tr
  291. ),
  292. plan_voided=voided,
  293. plan_archived=archived,
  294. status_label=_history_status_label(voided, archived),
  295. pound_qrcode_items=pound_items,
  296. )
  297. )
  298. return (
  299. PublicTransportHistoryPageRead(
  300. license_plate=None,
  301. date=day.isoformat(),
  302. records=records,
  303. ),
  304. None,
  305. )
  306. def build_driver_calendar_counts(
  307. db: Session,
  308. *,
  309. tenant_no: str,
  310. user_id: str,
  311. month_str: str,
  312. ) -> Tuple[Optional[dict[str, int]], Optional[str]]:
  313. """按月统计司机可见计划条数,键为 YYYY-MM-DD。"""
  314. bounds = _parse_calendar_month(month_str)
  315. if bounds is None:
  316. return None, "月份格式无效,请使用 YYYY-MM"
  317. month_start, month_end = bounds
  318. personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
  319. if not personnel:
  320. return {}, None
  321. personnel_id = personnel.id
  322. rows = (
  323. db.query(PlPlan)
  324. .join(
  325. PlPlanTransport,
  326. and_(
  327. PlPlanTransport.plan_id == PlPlan.id,
  328. PlPlanTransport.del_flag == 0,
  329. ),
  330. )
  331. .join(
  332. PlTransport,
  333. and_(
  334. PlTransport.id == PlPlanTransport.transport_id,
  335. PlTransport.del_flag == 0,
  336. ),
  337. )
  338. .filter(
  339. PlPlan.tenant_no == tenant_no,
  340. PlPlan.del_flag == 0,
  341. PlPlan.transport_status == "arranged",
  342. PlTransport.tenant_no == tenant_no,
  343. or_(
  344. PlTransport.driver_id == personnel_id,
  345. PlTransport.escort_id == personnel_id,
  346. ),
  347. or_(
  348. and_(
  349. PlPlan.pickup_time.isnot(None),
  350. func.date(PlPlan.pickup_time) >= month_start,
  351. func.date(PlPlan.pickup_time) <= month_end,
  352. ),
  353. and_(
  354. PlPlan.delivery_time.isnot(None),
  355. func.date(PlPlan.delivery_time) >= month_start,
  356. func.date(PlPlan.delivery_time) <= month_end,
  357. ),
  358. ),
  359. )
  360. .all()
  361. )
  362. seen: set[str] = set()
  363. plans: list[PlPlan] = []
  364. for plan in rows:
  365. if plan.id in seen:
  366. continue
  367. seen.add(plan.id)
  368. plans.append(plan)
  369. return (
  370. _accumulate_calendar_day_counts(
  371. plans,
  372. month_start=month_start,
  373. month_end=month_end,
  374. include_plan_date=False,
  375. ),
  376. None,
  377. )
  378. def build_driver_page_for_plan(
  379. db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
  380. ) -> Tuple[Optional[PublicTransportPageRead], Optional[str]]:
  381. plan, transport = resolve_transport_for_driver_plan(
  382. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  383. )
  384. if not plan:
  385. return None, "计划不存在"
  386. if not transport:
  387. return None, "无权限查看该运输计划或未安排运输"
  388. from app.api.pl.public_transport import _build_public_page
  389. return _build_public_page(db, transport), None
  390. def save_driver_feedback_for_plan(
  391. db: Session,
  392. *,
  393. tenant_no: str,
  394. plan_id: str,
  395. personnel_id: str,
  396. payload: PublicTransportFeedback,
  397. ) -> Tuple[bool, Optional[str]]:
  398. plan, transport = resolve_transport_for_driver_plan(
  399. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  400. )
  401. if not plan:
  402. return False, "计划不存在"
  403. if not transport:
  404. return False, "无权限操作该运输计划"
  405. from app.api.pl.public_transport import _build_public_page
  406. page = _build_public_page(db, transport)
  407. if page.plan_voided:
  408. return False, "运输计划已作废,无法提交回执"
  409. if page.plan_archived:
  410. return False, "运输计划已归档,无法提交回执"
  411. if payload.feedback_images is not None:
  412. transport.feedback_images = json.dumps(payload.feedback_images)
  413. if payload.feedback_remark is not None:
  414. transport.feedback_remark = payload.feedback_remark
  415. if payload.products:
  416. products_payload = [
  417. {
  418. "planProductId": item.plan_product_id,
  419. "actualQty": item.actual_qty,
  420. "brandQtys": ([b.model_dump(by_alias=True) for b in item.brand_qtys] if item.brand_qtys is not None else None),
  421. }
  422. for item in payload.products
  423. ]
  424. try:
  425. persist_transport_product_feedback(
  426. db,
  427. tenant_no=transport.tenant_no,
  428. transport=transport,
  429. plan_id=plan.id,
  430. products=products_payload,
  431. user_id=None,
  432. enforce_brand=True,
  433. )
  434. except ValueError as exc:
  435. return False, str(exc)
  436. elif payload.feedback_qty is not None:
  437. transport.feedback_qty = payload.feedback_qty
  438. transport.feedback_time = datetime.now()
  439. return True, None
  440. def save_driver_stop_feedback_for_plan(
  441. db: Session,
  442. *,
  443. tenant_no: str,
  444. plan_id: str,
  445. personnel_id: str,
  446. payload: PublicTransportDriverStopFeedback,
  447. ) -> Tuple[bool, Optional[str]]:
  448. plan, transport = resolve_transport_for_driver_plan(
  449. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  450. )
  451. if not plan:
  452. return False, "计划不存在"
  453. if not transport:
  454. return False, "无权限操作该运输计划"
  455. from app.api.pl.public_transport import _build_public_page
  456. page = _build_public_page(db, transport)
  457. if page.plan_voided:
  458. return False, "运输计划已作废,无法提交回执"
  459. if page.plan_archived:
  460. return False, "运输计划已归档,无法提交回执"
  461. stop_type = (payload.stop_type or "").strip()
  462. stop_id = (payload.stop_id or "").strip()
  463. if stop_type not in ("load", "delivery") or not stop_id:
  464. return False, "停靠段参数无效"
  465. segments = load_driver_stop_segments(
  466. db,
  467. tenant_no=transport.tenant_no,
  468. plan_id=plan.id,
  469. transport_id=transport.id,
  470. )
  471. segment = next(
  472. (row for row in segments if row.stop_type == stop_type and row.stop_id == stop_id),
  473. None,
  474. )
  475. if not segment:
  476. return False, "停靠段不存在或不可回传"
  477. products_payload: Optional[list[dict]] = None
  478. if payload.products is not None:
  479. allowed_products = {row.stop_product_id: row for row in segment.products}
  480. plan_products = (
  481. db.query(PlPlanProduct)
  482. .filter(
  483. PlPlanProduct.plan_id == plan.id,
  484. PlPlanProduct.tenant_no == transport.tenant_no,
  485. PlPlanProduct.del_flag == 0,
  486. )
  487. .all()
  488. )
  489. plan_products_by_id = {row.id: row for row in plan_products}
  490. products_payload = []
  491. for item in payload.products:
  492. stop_product_id = (item.stop_product_id or "").strip()
  493. stop_row = allowed_products.get(stop_product_id)
  494. if not stop_row:
  495. return False, "产品不属于当前停靠段"
  496. actual_qty = Decimal(str(item.actual_qty or 0))
  497. brand_qtys = ([b.model_dump(by_alias=True) for b in item.brand_qtys]
  498. if item.brand_qtys is not None else (stop_row.brand_qtys or []))
  499. plan_product = plan_products_by_id.get(stop_row.plan_product_id)
  500. try:
  501. require_brand_qtys_if_needed(
  502. brand_mode=plan_product.brand_mode if plan_product else None,
  503. required_brand_id=plan_product.brand_id if plan_product else None,
  504. actual_qty=actual_qty,
  505. rows=brand_qtys,
  506. enforce=True,
  507. )
  508. except ValueError as exc:
  509. return False, str(exc)
  510. row_payload: dict = {
  511. "stopProductId": stop_product_id,
  512. "actualQty": float(actual_qty),
  513. }
  514. if item.brand_qtys is not None:
  515. row_payload["brandQtys"] = brand_qtys
  516. products_payload.append(row_payload)
  517. save_driver_stop_feedback(
  518. db,
  519. tenant_no=transport.tenant_no,
  520. transport_id=transport.id,
  521. plan_id=plan.id,
  522. stop_type=stop_type,
  523. stop_id=stop_id,
  524. attachment_urls=payload.attachment_urls or [],
  525. products=products_payload,
  526. remark=payload.remark,
  527. )
  528. return True, None
  529. def upload_driver_attachment_for_plan(
  530. db: Session,
  531. *,
  532. tenant_no: str,
  533. plan_id: str,
  534. personnel_id: str,
  535. file: UploadFile,
  536. folder: str = "",
  537. ) -> Tuple[Optional[dict[str, Any]], Optional[Tuple[int, str]]]:
  538. plan, transport = resolve_transport_for_driver_plan(
  539. db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
  540. )
  541. if not plan:
  542. return None, (404, "计划不存在")
  543. if not transport:
  544. return None, (403, "无权限操作该运输计划")
  545. from app.api.pl.public_transport import _build_public_page
  546. page = _build_public_page(db, transport)
  547. if page.plan_voided:
  548. return None, (400, "运输计划已作废,无法上传附件")
  549. if page.plan_archived:
  550. return None, (400, "运输计划已归档,无法上传附件")
  551. if not file or not file.filename:
  552. return None, (400, "请选择文件")
  553. try:
  554. effective_folder = resolve_upload_folder(
  555. folder=folder or "", biz_type="pl_transport_feedback"
  556. )
  557. file_content = file.file.read()
  558. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  559. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  560. if base_url and full_url.startswith(base_url + "/"):
  561. file_url = full_url[len(base_url) + 1 :]
  562. else:
  563. file_url = full_url
  564. record = oss_svc.create_oss(
  565. db,
  566. tenant_no=transport.tenant_no,
  567. url=file_url,
  568. original_name=file.filename,
  569. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  570. file_size=len(file_content),
  571. file_type=file.content_type or "",
  572. biz_type="pl_transport_feedback",
  573. user_id=None,
  574. ext1=effective_folder or None,
  575. )
  576. db.commit()
  577. return {
  578. "url": full_url,
  579. "name": file.filename,
  580. "size": _format_file_size(len(file_content)),
  581. "ossId": record.id if record else None,
  582. }, None
  583. except Exception as exc: # noqa: BLE001
  584. db.rollback()
  585. return None, (500, f"上传失败: {exc}")