public_transport.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. """运输计划司机免登录公共 API"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from datetime import date, datetime
  6. from decimal import Decimal
  7. from typing import Optional
  8. from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
  9. from sqlalchemy import and_, func, or_
  10. from sqlalchemy.orm import Session
  11. from app.core.storage import storage
  12. from app.core.attachment_biz import resolve_upload_folder
  13. from app.core.config import settings
  14. from app.core.database import get_db
  15. from app.core.response import error_response, success_response
  16. from app.crud import oss as oss_svc
  17. from app.models.crm_models import CrmProductBrand
  18. from app.models.pl_models import (
  19. PlPlan,
  20. PlPlanDelivery,
  21. PlPlanLoad,
  22. PlPlanProduct,
  23. PlPlanTransport,
  24. PlTransport,
  25. PlTransportProductBrandQty,
  26. PlTransportProductFeedback,
  27. )
  28. from app.schemas.pl.io_order import BrandOption, BrandQtyItem
  29. from app.schemas.pl.resource_snapshot import parse_resource_snapshot
  30. from app.schemas.pl.transport import (
  31. PublicTransportDriverStopFeedback,
  32. PublicTransportFeedback,
  33. PublicTransportHistoryPageRead,
  34. PublicTransportHistoryProductRead,
  35. PublicTransportHistoryRecordRead,
  36. PublicTransportHistoryStopProductRead,
  37. PublicTransportHistoryStopRead,
  38. PublicTransportPageRead,
  39. PublicTransportPersonnelRead,
  40. PublicTransportPlanProductRead,
  41. PublicTransportStopProductRead,
  42. PublicTransportStopRead,
  43. )
  44. from app.services.pl_dibang_bridge_service import plan_has_load_stop
  45. from app.services.pl_feedback_brand_qty import (
  46. persist_transport_product_feedback,
  47. require_brand_qtys_if_needed,
  48. )
  49. from app.services.pl_transport_stop_feedback_service import (
  50. PublicStopProductRow,
  51. load_driver_stop_segments,
  52. save_driver_stop_feedback,
  53. )
  54. from app.services.pl_product_display import (
  55. load_crm_product_alias_maps,
  56. resolve_product_display_names,
  57. )
  58. from app.utils.excel_export import load_dict_label_maps
  59. router = APIRouter(prefix="/public/pl/transport", tags=["运输计划-司机公共"])
  60. CRM_PRODUCT_UNIT = "crm_product_unit"
  61. def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
  62. code = (unit_code or "").strip()
  63. if not code:
  64. return "吨"
  65. return unit_map.get(code, code)
  66. def _build_stop_product_reads(
  67. *,
  68. stop_products: list[PublicStopProductRow],
  69. plan_products_by_id: dict[str, PlPlanProduct],
  70. unit_map: dict[str, str],
  71. brand_options_by_product: dict[str, list[BrandOption]],
  72. standard_by_id: dict[str, str],
  73. alias_options_by_id: dict[str, list[str]],
  74. ) -> list[PublicTransportStopProductRead]:
  75. reads: list[PublicTransportStopProductRead] = []
  76. for row in stop_products:
  77. plan_prod = plan_products_by_id.get(row.plan_product_id)
  78. product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
  79. display_name, alias_name = resolve_product_display_names(
  80. saved_product_name=row.product_name,
  81. product_id=product_id,
  82. standard_by_id=standard_by_id,
  83. alias_options_by_id=alias_options_by_id,
  84. )
  85. brand_qtys = None
  86. if row.brand_qtys:
  87. brand_qtys = [
  88. BrandQtyItem(
  89. brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
  90. brand_name=item.get("brandName") or item.get("brand_name"),
  91. qty=item.get("qty") or 0,
  92. )
  93. for item in row.brand_qtys
  94. if isinstance(item, dict)
  95. ] or None
  96. reads.append(
  97. PublicTransportStopProductRead(
  98. stop_product_id=row.stop_product_id,
  99. plan_product_id=row.plan_product_id,
  100. product_name=display_name,
  101. alias_name=alias_name,
  102. model=row.model,
  103. package_spec=row.package_spec,
  104. unit=_resolve_unit_label(unit_map, row.unit),
  105. quantity=row.plan_qty,
  106. remark=row.remark,
  107. brand_mode=plan_prod.brand_mode if plan_prod else None,
  108. brand_id=plan_prod.brand_id if plan_prod else None,
  109. brand_name=plan_prod.brand_name if plan_prod else None,
  110. brand_options=brand_options_by_product.get(product_id) or None,
  111. actual_qty=row.actual_qty,
  112. brand_qtys=brand_qtys,
  113. )
  114. )
  115. return reads
  116. def _get_transport_by_token(db: Session, token: str) -> PlTransport:
  117. transport = (
  118. db.query(PlTransport)
  119. .filter(
  120. PlTransport.driver_access_token == token,
  121. PlTransport.del_flag == 0,
  122. )
  123. .first()
  124. )
  125. if not transport:
  126. raise HTTPException(status_code=404, detail="链接无效或已失效")
  127. return transport
  128. def _parse_feedback_images(raw: Optional[str]) -> list[str]:
  129. if not raw:
  130. return []
  131. try:
  132. parsed = json.loads(raw)
  133. if isinstance(parsed, list):
  134. return [str(item) for item in parsed if item]
  135. except (TypeError, json.JSONDecodeError):
  136. pass
  137. return [part.strip() for part in raw.split(",") if part.strip()]
  138. def _format_plan_date(value) -> Optional[str]:
  139. if value is None:
  140. return None
  141. if isinstance(value, datetime):
  142. return value.strftime("%Y-%m-%d")
  143. if isinstance(value, date):
  144. return value.isoformat()
  145. return str(value)
  146. def _normalize_main_plate(value: Optional[str]) -> str:
  147. return (value or "").strip()
  148. def _parse_history_date(raw: Optional[str]) -> Optional[date]:
  149. text = (raw or "").strip()
  150. if not text:
  151. return None
  152. try:
  153. return date.fromisoformat(text)
  154. except ValueError:
  155. return None
  156. def _as_date(value) -> Optional[date]:
  157. if value is None:
  158. return None
  159. if isinstance(value, datetime):
  160. return value.date()
  161. if isinstance(value, date):
  162. return value
  163. return None
  164. def _history_matches_date(pickup_time, delivery_time, day: date) -> bool:
  165. return _as_date(pickup_time) == day or _as_date(delivery_time) == day
  166. def _history_status_label(plan_voided: bool, plan_archived: bool) -> str:
  167. if plan_voided:
  168. return "已作废"
  169. if plan_archived:
  170. return "已归档"
  171. return "进行中"
  172. def _build_history_stop_reads(
  173. db: Session,
  174. *,
  175. tenant_no: str,
  176. plan: PlPlan,
  177. transport: PlTransport,
  178. ) -> list[PublicTransportHistoryStopRead]:
  179. if not plan_has_load_stop(db, tenant_no, plan.id):
  180. return []
  181. plan_products = (
  182. db.query(PlPlanProduct)
  183. .filter(
  184. PlPlanProduct.plan_id == plan.id,
  185. PlPlanProduct.tenant_no == tenant_no,
  186. PlPlanProduct.del_flag == 0,
  187. )
  188. .all()
  189. )
  190. plan_products_by_id = {row.id: row for row in plan_products}
  191. crm_product_ids = {
  192. (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip()
  193. }
  194. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  195. db, tenant_no=tenant_no, product_ids=crm_product_ids
  196. )
  197. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  198. CRM_PRODUCT_UNIT, {}
  199. )
  200. load_row = (
  201. db.query(PlPlanLoad)
  202. .filter(
  203. PlPlanLoad.plan_id == plan.id,
  204. PlPlanLoad.tenant_no == tenant_no,
  205. PlPlanLoad.del_flag == 0,
  206. )
  207. .first()
  208. )
  209. delivery_rows = (
  210. db.query(PlPlanDelivery)
  211. .filter(
  212. PlPlanDelivery.plan_id == plan.id,
  213. PlPlanDelivery.tenant_no == tenant_no,
  214. PlPlanDelivery.del_flag == 0,
  215. )
  216. .all()
  217. )
  218. delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows}
  219. load_time = load_row.load_time if load_row else None
  220. segments = load_driver_stop_segments(
  221. db,
  222. tenant_no=tenant_no,
  223. plan_id=plan.id,
  224. transport_id=transport.id,
  225. )
  226. reads: list[PublicTransportHistoryStopRead] = []
  227. for segment in segments:
  228. stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id)
  229. stop_products: list[PublicTransportHistoryStopProductRead] = []
  230. for row in segment.products:
  231. plan_prod = plan_products_by_id.get(row.plan_product_id)
  232. product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
  233. display_name, _alias_name = resolve_product_display_names(
  234. saved_product_name=row.product_name,
  235. product_id=product_id,
  236. standard_by_id=standard_by_id,
  237. alias_options_by_id=alias_options_by_id,
  238. )
  239. stop_products.append(
  240. PublicTransportHistoryStopProductRead(
  241. product_name=display_name or row.product_name or "—",
  242. model=row.model,
  243. package_spec=row.package_spec,
  244. quantity=row.plan_qty,
  245. unit=_resolve_unit_label(unit_map, row.unit),
  246. )
  247. )
  248. reads.append(
  249. PublicTransportHistoryStopRead(
  250. stop_type=segment.stop_type,
  251. seq=segment.seq,
  252. label=segment.label,
  253. customer_name=segment.customer_name,
  254. address=segment.address,
  255. stop_time=stop_time,
  256. products=stop_products,
  257. )
  258. )
  259. return reads
  260. def _build_driver_url(token: str) -> str:
  261. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  262. return f"{origin}/#/public/pl/transport/{token}"
  263. def _resolve_driver_link_flags(
  264. plan: Optional[PlPlan], transport: PlTransport
  265. ) -> tuple[bool, bool]:
  266. """返回 (作废, 归档)。"""
  267. voided = (
  268. (plan is not None and plan.follow_status == "cancelled")
  269. or transport.transport_status == "cancelled"
  270. )
  271. archived = bool(plan is not None and plan.archive_status == "archived")
  272. return voided, archived
  273. def _resolve_other_personnel(transport: PlTransport, plan: Optional[PlPlan]) -> list[PublicTransportPersonnelRead]:
  274. raw = transport.resource_snapshot or (plan.resource_snapshot if plan else None)
  275. snapshot = parse_resource_snapshot(raw)
  276. if not snapshot or not snapshot.personnel_list:
  277. return []
  278. selected_driver_id = (snapshot.selected_driver_id or transport.driver_id or "").strip()
  279. driver_name = (transport.driver_name or "").strip()
  280. rows: list[PublicTransportPersonnelRead] = []
  281. for person in snapshot.personnel_list:
  282. personnel_id = (person.personnel_id or "").strip()
  283. personnel_name = (person.personnel_name or "").strip()
  284. is_primary_driver = person.role == "driver" and (
  285. person.is_default == 1
  286. or (selected_driver_id and personnel_id == selected_driver_id)
  287. or (driver_name and personnel_name == driver_name)
  288. )
  289. if is_primary_driver:
  290. continue
  291. rows.append(
  292. PublicTransportPersonnelRead(
  293. personnel_name=personnel_name or "—",
  294. phone_no=person.phone_no,
  295. role=person.role,
  296. )
  297. )
  298. return rows
  299. def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportPageRead:
  300. link = (
  301. db.query(PlPlanTransport)
  302. .filter(
  303. PlPlanTransport.transport_id == transport.id,
  304. PlPlanTransport.del_flag == 0,
  305. )
  306. .order_by(PlPlanTransport.create_time.desc())
  307. .first()
  308. )
  309. plan = None
  310. products: list[PlPlanProduct] = []
  311. if link:
  312. plan = (
  313. db.query(PlPlan)
  314. .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
  315. .first()
  316. )
  317. if plan:
  318. products = (
  319. db.query(PlPlanProduct)
  320. .filter(PlPlanProduct.plan_id == plan.id, PlPlanProduct.del_flag == 0)
  321. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  322. .all()
  323. )
  324. crm_product_ids = {(p.product_id or "").strip() for p in products if (p.product_id or "").strip()}
  325. brand_options_by_product: dict[str, list[BrandOption]] = {}
  326. if crm_product_ids:
  327. brand_rows = (
  328. db.query(CrmProductBrand)
  329. .filter(
  330. CrmProductBrand.product_id.in_(list(crm_product_ids)),
  331. CrmProductBrand.tenant_no == transport.tenant_no,
  332. CrmProductBrand.status == "on",
  333. CrmProductBrand.del_flag == 0,
  334. )
  335. .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
  336. .all()
  337. )
  338. for row in brand_rows:
  339. brand_options_by_product.setdefault(row.product_id, []).append(
  340. BrandOption(brand_id=row.id, brand_name=row.brand_name or "")
  341. )
  342. feedback_by_plan_product: dict[str, PlTransportProductFeedback] = {}
  343. brand_qtys_by_plan_product: dict[str, list[BrandQtyItem]] = {}
  344. feedback_rows = (
  345. db.query(PlTransportProductFeedback)
  346. .filter(
  347. PlTransportProductFeedback.transport_id == transport.id,
  348. PlTransportProductFeedback.tenant_no == transport.tenant_no,
  349. PlTransportProductFeedback.del_flag == 0,
  350. )
  351. .order_by(PlTransportProductFeedback.order_num.asc())
  352. .all()
  353. )
  354. for fb in feedback_rows:
  355. feedback_by_plan_product[fb.plan_product_id] = fb
  356. if feedback_rows:
  357. feedback_ids = [fb.id for fb in feedback_rows]
  358. brand_qty_rows = (
  359. db.query(PlTransportProductBrandQty)
  360. .filter(
  361. PlTransportProductBrandQty.transport_id == transport.id,
  362. PlTransportProductBrandQty.tenant_no == transport.tenant_no,
  363. PlTransportProductBrandQty.del_flag == 0,
  364. PlTransportProductBrandQty.feedback_id.in_(feedback_ids),
  365. )
  366. .order_by(PlTransportProductBrandQty.order_num.asc())
  367. .all()
  368. )
  369. for row in brand_qty_rows:
  370. brand_qtys_by_plan_product.setdefault(row.plan_product_id, []).append(
  371. BrandQtyItem(
  372. brand_id=row.brand_id or "",
  373. brand_name=row.brand_name,
  374. qty=row.qty or 0,
  375. )
  376. )
  377. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  378. db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
  379. )
  380. product_reads = []
  381. unit_map = load_dict_label_maps(
  382. db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
  383. ).get(CRM_PRODUCT_UNIT, {})
  384. for prod in products:
  385. product_id = (prod.product_id or "").strip()
  386. options = brand_options_by_product.get(product_id) or None
  387. fb = feedback_by_plan_product.get(prod.id)
  388. brand_qtys = brand_qtys_by_plan_product.get(prod.id) or None
  389. display_name, alias_name = resolve_product_display_names(
  390. saved_product_name=prod.product_name,
  391. product_id=product_id,
  392. standard_by_id=standard_by_id,
  393. alias_options_by_id=alias_options_by_id,
  394. )
  395. product_reads.append(
  396. PublicTransportPlanProductRead(
  397. plan_product_id=prod.id,
  398. product_name=display_name,
  399. alias_name=alias_name,
  400. model=prod.model,
  401. package_spec=prod.package_spec,
  402. unit=_resolve_unit_label(unit_map, prod.unit),
  403. quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
  404. remark=(prod.remark or "").strip() or None,
  405. brand_mode=prod.brand_mode,
  406. brand_id=prod.brand_id,
  407. brand_name=prod.brand_name,
  408. brand_options=options,
  409. actual_qty=fb.actual_qty if fb else None,
  410. brand_qtys=brand_qtys,
  411. )
  412. )
  413. other_personnel = _resolve_other_personnel(transport, plan)
  414. plan_voided, plan_archived = _resolve_driver_link_flags(plan, transport)
  415. stop_reads: list[PublicTransportStopRead] = []
  416. if plan and plan_has_load_stop(db, transport.tenant_no, plan.id):
  417. plan_products_by_id = {p.id: p for p in products}
  418. stop_reads = [
  419. PublicTransportStopRead(
  420. stop_type=segment.stop_type,
  421. stop_id=segment.stop_id,
  422. seq=segment.seq,
  423. label=segment.label,
  424. customer_name=segment.customer_name,
  425. address=segment.address,
  426. contact=segment.contact,
  427. notes=segment.notes,
  428. lng=segment.lng,
  429. lat=segment.lat,
  430. business_type=segment.business_type,
  431. plan_qty=segment.plan_qty,
  432. products=_build_stop_product_reads(
  433. stop_products=segment.products,
  434. plan_products_by_id=plan_products_by_id,
  435. unit_map=unit_map,
  436. brand_options_by_product=brand_options_by_product,
  437. standard_by_id=standard_by_id,
  438. alias_options_by_id=alias_options_by_id,
  439. ),
  440. attachment_urls=segment.attachment_urls,
  441. remark=segment.remark,
  442. feedback_time=segment.feedback_time,
  443. )
  444. for segment in load_driver_stop_segments(
  445. db,
  446. tenant_no=transport.tenant_no,
  447. plan_id=plan.id,
  448. transport_id=transport.id,
  449. )
  450. ]
  451. return PublicTransportPageRead(
  452. plan_no=plan.plan_no if plan else transport.transport_no,
  453. plan_date=_format_plan_date(plan.plan_date if plan else None),
  454. plan_type=plan.plan_type if plan else "",
  455. pickup_address=plan.pickup_address if plan else transport.start_point,
  456. pickup_lng=plan.pickup_lng if plan else None,
  457. pickup_lat=plan.pickup_lat if plan else None,
  458. pickup_time=plan.pickup_time if plan else None,
  459. pickup_contact=plan.pickup_contact if plan else None,
  460. pickup_notes=plan.pickup_notes if plan else None,
  461. destination_name=plan.destination_name if plan else None,
  462. destination_address=(
  463. (plan.destination_address or plan.destination_name) if plan else transport.end_point
  464. ),
  465. destination_lng=plan.destination_lng if plan else None,
  466. destination_lat=plan.destination_lat if plan else None,
  467. delivery_time=plan.delivery_time if plan else None,
  468. delivery_contact=plan.delivery_contact if plan else None,
  469. destination_notes=plan.destination_notes if plan else None,
  470. license_plate=transport.license_plate,
  471. driver_name=transport.driver_name,
  472. driver_phone=transport.driver_phone,
  473. other_personnel=other_personnel,
  474. products=product_reads,
  475. stops=stop_reads,
  476. feedback_remark=transport.feedback_remark,
  477. feedback_images=_parse_feedback_images(transport.feedback_images),
  478. feedback_qty=transport.feedback_qty,
  479. feedback_time=transport.feedback_time,
  480. plan_voided=plan_voided,
  481. plan_archived=plan_archived,
  482. )
  483. @router.get("/{token}")
  484. def get_public_transport(token: str, db: Session = Depends(get_db)):
  485. transport = _get_transport_by_token(db, token)
  486. page = _build_public_page(db, transport)
  487. return success_response(page.model_dump(by_alias=True))
  488. @router.get("/{token}/history")
  489. def get_public_transport_history(
  490. token: str,
  491. date: str = Query(..., description="筛选日 YYYY-MM-DD"),
  492. db: Session = Depends(get_db),
  493. ):
  494. transport = _get_transport_by_token(db, token)
  495. day = _parse_history_date(date)
  496. if day is None:
  497. return error_response(400, "日期格式无效,请使用 YYYY-MM-DD")
  498. plate = _normalize_main_plate(transport.license_plate)
  499. if not plate:
  500. page = PublicTransportHistoryPageRead(
  501. license_plate=None, date=day.isoformat(), records=[]
  502. )
  503. return success_response(page.model_dump(by_alias=True))
  504. rows = (
  505. db.query(PlPlan, PlTransport)
  506. .join(
  507. PlPlanTransport,
  508. and_(
  509. PlPlanTransport.plan_id == PlPlan.id,
  510. PlPlanTransport.del_flag == 0,
  511. ),
  512. )
  513. .join(
  514. PlTransport,
  515. and_(
  516. PlTransport.id == PlPlanTransport.transport_id,
  517. PlTransport.del_flag == 0,
  518. ),
  519. )
  520. .filter(
  521. PlPlan.tenant_no == transport.tenant_no,
  522. PlPlan.del_flag == 0,
  523. PlPlan.transport_status == "arranged",
  524. PlTransport.tenant_no == transport.tenant_no,
  525. PlTransport.license_plate == plate,
  526. or_(
  527. and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
  528. and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
  529. ),
  530. )
  531. .order_by(
  532. PlPlan.pickup_time.desc(),
  533. PlPlan.plan_date.desc(),
  534. PlPlan.create_time.desc(),
  535. )
  536. .all()
  537. )
  538. seen_plan: set[str] = set()
  539. plan_rows: list[tuple[PlPlan, PlTransport]] = []
  540. for plan, tr in rows:
  541. if plan.id in seen_plan:
  542. continue
  543. seen_plan.add(plan.id)
  544. plan_rows.append((plan, tr))
  545. products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
  546. if plan_rows:
  547. plan_ids = [plan.id for plan, _ in plan_rows]
  548. product_rows = (
  549. db.query(PlPlanProduct)
  550. .filter(
  551. PlPlanProduct.plan_id.in_(plan_ids),
  552. PlPlanProduct.del_flag == 0,
  553. )
  554. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  555. .all()
  556. )
  557. crm_product_ids = {
  558. (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
  559. }
  560. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  561. db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
  562. )
  563. unit_map = load_dict_label_maps(
  564. db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
  565. ).get(CRM_PRODUCT_UNIT, {})
  566. for prod in product_rows:
  567. product_id = (prod.product_id or "").strip()
  568. display_name, _alias_name = resolve_product_display_names(
  569. saved_product_name=prod.product_name,
  570. product_id=product_id,
  571. standard_by_id=standard_by_id,
  572. alias_options_by_id=alias_options_by_id,
  573. )
  574. products_by_plan.setdefault(prod.plan_id, []).append(
  575. PublicTransportHistoryProductRead(
  576. product_name=display_name or prod.product_name or "—",
  577. model=prod.model,
  578. package_spec=prod.package_spec,
  579. quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
  580. unit=_resolve_unit_label(unit_map, prod.unit),
  581. )
  582. )
  583. records: list[PublicTransportHistoryRecordRead] = []
  584. for plan, tr in plan_rows:
  585. voided, archived = _resolve_driver_link_flags(plan, tr)
  586. tok = (tr.driver_access_token or "").strip()
  587. records.append(
  588. PublicTransportHistoryRecordRead(
  589. plan_id=plan.id,
  590. plan_no=plan.plan_no or "",
  591. transport_id=tr.id,
  592. driver_access_token=tok or None,
  593. driver_url=_build_driver_url(tok) if tok else None,
  594. license_plate=tr.license_plate or plate,
  595. pickup_time=plan.pickup_time,
  596. delivery_time=plan.delivery_time,
  597. pickup_address=plan.pickup_address,
  598. destination_address=plan.destination_address or plan.destination_name,
  599. products=products_by_plan.get(plan.id) or [],
  600. stops=_build_history_stop_reads(
  601. db, tenant_no=transport.tenant_no, plan=plan, transport=tr
  602. ),
  603. plan_voided=voided,
  604. plan_archived=archived,
  605. status_label=_history_status_label(voided, archived),
  606. )
  607. )
  608. page = PublicTransportHistoryPageRead(
  609. license_plate=plate,
  610. date=day.isoformat(),
  611. records=records,
  612. )
  613. return success_response(page.model_dump(by_alias=True))
  614. @router.post("/{token}/feedback")
  615. def save_public_feedback(
  616. token: str,
  617. payload: PublicTransportFeedback,
  618. db: Session = Depends(get_db),
  619. ):
  620. transport = _get_transport_by_token(db, token)
  621. page = _build_public_page(db, transport)
  622. if page.plan_voided:
  623. return error_response(400, "运输计划已作废,无法提交回执")
  624. if page.plan_archived:
  625. return error_response(400, "运输计划已归档,无法提交回执")
  626. if payload.feedback_images is not None:
  627. transport.feedback_images = json.dumps(payload.feedback_images)
  628. if payload.feedback_remark is not None:
  629. transport.feedback_remark = payload.feedback_remark
  630. if payload.products:
  631. link = (
  632. db.query(PlPlanTransport)
  633. .filter(
  634. PlPlanTransport.transport_id == transport.id,
  635. PlPlanTransport.del_flag == 0,
  636. )
  637. .order_by(PlPlanTransport.create_time.desc())
  638. .first()
  639. )
  640. if not link or not link.plan_id:
  641. return error_response(400, "未找到关联计划,无法保存产品回传")
  642. products_payload = [
  643. {
  644. "planProductId": item.plan_product_id,
  645. "actualQty": item.actual_qty,
  646. "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
  647. }
  648. for item in payload.products
  649. ]
  650. try:
  651. persist_transport_product_feedback(
  652. db,
  653. tenant_no=transport.tenant_no,
  654. transport=transport,
  655. plan_id=link.plan_id,
  656. products=products_payload,
  657. user_id=None,
  658. enforce_brand=True,
  659. )
  660. except ValueError as exc:
  661. return error_response(400, str(exc))
  662. elif payload.feedback_qty is not None:
  663. transport.feedback_qty = payload.feedback_qty
  664. transport.feedback_time = datetime.now()
  665. db.commit()
  666. return success_response({"success": True, "message": "保存成功"})
  667. @router.post("/{token}/driver-feedback")
  668. def save_public_driver_stop_feedback(
  669. token: str,
  670. payload: PublicTransportDriverStopFeedback,
  671. db: Session = Depends(get_db),
  672. ):
  673. transport = _get_transport_by_token(db, token)
  674. page = _build_public_page(db, transport)
  675. if page.plan_voided:
  676. return error_response(400, "运输计划已作废,无法提交回执")
  677. if page.plan_archived:
  678. return error_response(400, "运输计划已归档,无法提交回执")
  679. link = (
  680. db.query(PlPlanTransport)
  681. .filter(
  682. PlPlanTransport.transport_id == transport.id,
  683. PlPlanTransport.del_flag == 0,
  684. )
  685. .order_by(PlPlanTransport.create_time.desc())
  686. .first()
  687. )
  688. if not link or not link.plan_id:
  689. return error_response(400, "未找到关联计划,无法保存段附件")
  690. stop_type = (payload.stop_type or "").strip()
  691. stop_id = (payload.stop_id or "").strip()
  692. if stop_type not in ("load", "delivery") or not stop_id:
  693. return error_response(400, "停靠段参数无效")
  694. allowed = {
  695. (segment.stop_type, segment.stop_id)
  696. for segment in load_driver_stop_segments(
  697. db,
  698. tenant_no=transport.tenant_no,
  699. plan_id=link.plan_id,
  700. transport_id=transport.id,
  701. )
  702. }
  703. if (stop_type, stop_id) not in allowed:
  704. return error_response(400, "停靠段不存在或不可回传")
  705. segment = next(
  706. (
  707. row
  708. for row in load_driver_stop_segments(
  709. db,
  710. tenant_no=transport.tenant_no,
  711. plan_id=link.plan_id,
  712. transport_id=transport.id,
  713. )
  714. if row.stop_type == stop_type and row.stop_id == stop_id
  715. ),
  716. None,
  717. )
  718. if not segment:
  719. return error_response(400, "停靠段不存在或不可回传")
  720. products_payload: Optional[list[dict]] = None
  721. if payload.products is not None:
  722. allowed_products = {row.stop_product_id: row for row in segment.products}
  723. plan_products = (
  724. db.query(PlPlanProduct)
  725. .filter(
  726. PlPlanProduct.plan_id == link.plan_id,
  727. PlPlanProduct.tenant_no == transport.tenant_no,
  728. PlPlanProduct.del_flag == 0,
  729. )
  730. .all()
  731. )
  732. plan_products_by_id = {row.id: row for row in plan_products}
  733. products_payload = []
  734. for item in payload.products:
  735. stop_product_id = (item.stop_product_id or "").strip()
  736. stop_row = allowed_products.get(stop_product_id)
  737. if not stop_row:
  738. return error_response(400, "产品不属于当前停靠段")
  739. actual_qty = Decimal(str(item.actual_qty or 0))
  740. brand_qtys = [
  741. b.model_dump(by_alias=True) for b in (item.brand_qtys or [])
  742. ]
  743. plan_product = plan_products_by_id.get(stop_row.plan_product_id)
  744. try:
  745. require_brand_qtys_if_needed(
  746. brand_mode=plan_product.brand_mode if plan_product else None,
  747. required_brand_id=plan_product.brand_id if plan_product else None,
  748. actual_qty=actual_qty,
  749. rows=brand_qtys,
  750. enforce=True,
  751. )
  752. except ValueError as exc:
  753. return error_response(400, str(exc))
  754. row_payload: dict = {
  755. "stopProductId": stop_product_id,
  756. "actualQty": float(actual_qty),
  757. }
  758. if brand_qtys:
  759. row_payload["brandQtys"] = brand_qtys
  760. products_payload.append(row_payload)
  761. save_driver_stop_feedback(
  762. db,
  763. tenant_no=transport.tenant_no,
  764. transport_id=transport.id,
  765. plan_id=link.plan_id,
  766. stop_type=stop_type,
  767. stop_id=stop_id,
  768. attachment_urls=payload.attachment_urls or [],
  769. products=products_payload,
  770. remark=payload.remark,
  771. )
  772. db.commit()
  773. return success_response({"success": True, "message": "保存成功"})
  774. def _format_file_size(size_bytes: int) -> str:
  775. if size_bytes < 1024:
  776. return f"{size_bytes} B"
  777. if size_bytes < 1024 * 1024:
  778. return f"{size_bytes / 1024:.1f} KB"
  779. return f"{size_bytes / (1024 * 1024):.1f} MB"
  780. @router.post("/{token}/attachments")
  781. def upload_public_attachment(
  782. token: str,
  783. file: UploadFile = File(...),
  784. folder: Optional[str] = Form(""),
  785. db: Session = Depends(get_db),
  786. ):
  787. transport = _get_transport_by_token(db, token)
  788. page = _build_public_page(db, transport)
  789. if page.plan_voided:
  790. return error_response(400, "运输计划已作废,无法上传附件")
  791. if page.plan_archived:
  792. return error_response(400, "运输计划已归档,无法上传附件")
  793. if not file or not file.filename:
  794. return error_response(400, "请选择文件")
  795. try:
  796. effective_folder = resolve_upload_folder(folder=folder or "", biz_type="pl_transport_feedback")
  797. file_content = file.file.read()
  798. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  799. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  800. if base_url and full_url.startswith(base_url + "/"):
  801. file_url = full_url[len(base_url) + 1 :]
  802. else:
  803. file_url = full_url
  804. record = oss_svc.create_oss(
  805. db,
  806. tenant_no=transport.tenant_no,
  807. url=file_url,
  808. original_name=file.filename,
  809. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  810. file_size=len(file_content),
  811. file_type=file.content_type or "",
  812. biz_type="pl_transport_feedback",
  813. user_id=None,
  814. ext1=effective_folder or None,
  815. )
  816. db.commit()
  817. return success_response(
  818. {
  819. "id": record.id,
  820. "fileName": record.original_name,
  821. "fileUrl": record.url,
  822. "fileSize": record.file_size,
  823. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  824. "fileType": record.file_type,
  825. }
  826. )
  827. except Exception as exc:
  828. db.rollback()
  829. return error_response(500, f"上传失败: {str(exc)}")