public_transport_site.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. """运输计划 · 现场按段免登录公共 API(一车多卸)"""
  2. from __future__ import annotations
  3. import os
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from typing import Optional
  7. from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
  8. from sqlalchemy import and_, func, or_
  9. from sqlalchemy.orm import Session
  10. from app.api.pl.public_transport import (
  11. CRM_PRODUCT_UNIT,
  12. _history_status_label,
  13. _normalize_main_plate,
  14. _parse_history_date,
  15. _resolve_driver_link_flags,
  16. _resolve_unit_label,
  17. )
  18. from app.core.attachment_biz import resolve_upload_folder
  19. from app.core.config import settings
  20. from app.core.database import get_db
  21. from app.core.response import error_response, success_response
  22. from app.core.storage import storage
  23. from app.crud import oss as oss_svc
  24. from app.models.pl_models import (
  25. PlPlan,
  26. PlPlanDelivery,
  27. PlPlanLoad,
  28. PlPlanProduct,
  29. PlPlanTransport,
  30. PlTransport,
  31. )
  32. from app.schemas.pl.transport import (
  33. PublicTransportHistoryPageRead,
  34. PublicTransportHistoryProductRead,
  35. PublicTransportHistoryRecordRead,
  36. PublicTransportHistoryStopProductRead,
  37. PublicTransportHistoryStopRead,
  38. PublicTransportMultiStopSitePageRead,
  39. PublicTransportSiteStopFeedback,
  40. )
  41. from app.services.pl_product_display import (
  42. load_crm_product_alias_maps,
  43. resolve_product_display_names,
  44. )
  45. from app.services.pl_transport_stop_feedback_service import (
  46. build_public_stop_reads_for_segments,
  47. load_site_stop_segments,
  48. save_site_stop_feedback,
  49. )
  50. from app.utils.excel_export import load_dict_label_maps
  51. router = APIRouter(prefix="/public/pl/transport-site", tags=["运输计划-现场按段公共"])
  52. def _get_transport_by_site_token(db: Session, token: str) -> PlTransport:
  53. transport = (
  54. db.query(PlTransport)
  55. .filter(
  56. PlTransport.site_access_token == token,
  57. PlTransport.del_flag == 0,
  58. )
  59. .first()
  60. )
  61. if not transport:
  62. raise HTTPException(status_code=404, detail="链接无效或已失效")
  63. return transport
  64. def _format_plan_date(value) -> Optional[str]:
  65. if value is None:
  66. return None
  67. if isinstance(value, datetime):
  68. return value.strftime("%Y-%m-%d")
  69. if isinstance(value, date):
  70. return value.isoformat()
  71. return str(value)
  72. def _resolve_plan_flags(plan: Optional[PlPlan], transport: PlTransport) -> tuple[bool, bool]:
  73. voided = (
  74. (plan is not None and plan.follow_status == "cancelled")
  75. or transport.transport_status == "cancelled"
  76. )
  77. archived = bool(plan is not None and plan.archive_status == "archived")
  78. return voided, archived
  79. def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportMultiStopSitePageRead:
  80. link = (
  81. db.query(PlPlanTransport)
  82. .filter(
  83. PlPlanTransport.transport_id == transport.id,
  84. PlPlanTransport.del_flag == 0,
  85. )
  86. .order_by(PlPlanTransport.create_time.desc())
  87. .first()
  88. )
  89. plan = None
  90. if link:
  91. plan = (
  92. db.query(PlPlan)
  93. .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
  94. .first()
  95. )
  96. plan_voided, plan_archived = _resolve_plan_flags(plan, transport)
  97. stop_reads: list[dict] = []
  98. if plan:
  99. segments = load_site_stop_segments(
  100. db,
  101. tenant_no=transport.tenant_no,
  102. plan_id=plan.id,
  103. transport_id=transport.id,
  104. )
  105. stop_reads = build_public_stop_reads_for_segments(
  106. db,
  107. tenant_no=transport.tenant_no,
  108. plan_id=plan.id,
  109. segments=segments,
  110. )
  111. return PublicTransportMultiStopSitePageRead(
  112. plan_no=plan.plan_no if plan else transport.transport_no,
  113. plan_date=_format_plan_date(plan.plan_date if plan else None),
  114. license_plate=transport.license_plate,
  115. vehicle_no=transport.license_plate,
  116. plan_voided=plan_voided,
  117. plan_archived=plan_archived,
  118. stops=stop_reads,
  119. )
  120. def _build_site_url(token: str) -> str:
  121. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  122. return f"{origin}/#/public/pl/transport-site/{token}"
  123. def _build_site_history_stop_reads(
  124. db: Session,
  125. *,
  126. tenant_no: str,
  127. plan: PlPlan,
  128. transport: PlTransport,
  129. ) -> list[PublicTransportHistoryStopRead]:
  130. plan_products = (
  131. db.query(PlPlanProduct)
  132. .filter(
  133. PlPlanProduct.plan_id == plan.id,
  134. PlPlanProduct.tenant_no == tenant_no,
  135. PlPlanProduct.del_flag == 0,
  136. )
  137. .all()
  138. )
  139. plan_products_by_id = {row.id: row for row in plan_products}
  140. crm_product_ids = {
  141. (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip()
  142. }
  143. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  144. db, tenant_no=tenant_no, product_ids=crm_product_ids
  145. )
  146. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  147. CRM_PRODUCT_UNIT, {}
  148. )
  149. load_row = (
  150. db.query(PlPlanLoad)
  151. .filter(
  152. PlPlanLoad.plan_id == plan.id,
  153. PlPlanLoad.tenant_no == tenant_no,
  154. PlPlanLoad.del_flag == 0,
  155. )
  156. .first()
  157. )
  158. delivery_rows = (
  159. db.query(PlPlanDelivery)
  160. .filter(
  161. PlPlanDelivery.plan_id == plan.id,
  162. PlPlanDelivery.tenant_no == tenant_no,
  163. PlPlanDelivery.del_flag == 0,
  164. )
  165. .all()
  166. )
  167. delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows}
  168. load_time = load_row.load_time if load_row else None
  169. segments = load_site_stop_segments(
  170. db,
  171. tenant_no=tenant_no,
  172. plan_id=plan.id,
  173. transport_id=transport.id,
  174. )
  175. if not segments:
  176. return []
  177. reads: list[PublicTransportHistoryStopRead] = []
  178. for segment in segments:
  179. stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id)
  180. stop_products: list[PublicTransportHistoryStopProductRead] = []
  181. for row in segment.products:
  182. plan_prod = plan_products_by_id.get(row.plan_product_id)
  183. product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
  184. display_name, _alias_name = resolve_product_display_names(
  185. saved_product_name=row.product_name,
  186. product_id=product_id,
  187. standard_by_id=standard_by_id,
  188. alias_options_by_id=alias_options_by_id,
  189. )
  190. stop_products.append(
  191. PublicTransportHistoryStopProductRead(
  192. product_name=display_name or row.product_name or "—",
  193. model=row.model,
  194. package_spec=row.package_spec,
  195. quantity=row.plan_qty,
  196. unit=_resolve_unit_label(unit_map, row.unit),
  197. )
  198. )
  199. reads.append(
  200. PublicTransportHistoryStopRead(
  201. stop_type=segment.stop_type,
  202. seq=segment.seq,
  203. label=segment.label,
  204. customer_name=segment.customer_name,
  205. address=segment.address,
  206. stop_time=stop_time,
  207. products=stop_products,
  208. )
  209. )
  210. return reads
  211. @router.get("/{token}/history")
  212. def get_public_transport_site_history(
  213. token: str,
  214. date: str = Query(..., description="筛选日 YYYY-MM-DD"),
  215. q: Optional[str] = Query(None, description="车牌号/产品名称模糊"),
  216. db: Session = Depends(get_db),
  217. ):
  218. transport = _get_transport_by_site_token(db, token)
  219. day = _parse_history_date(date)
  220. if day is None:
  221. return error_response(400, "日期格式无效,请使用 YYYY-MM-DD")
  222. kw = (q or "").strip()
  223. query = (
  224. db.query(PlPlan, PlTransport)
  225. .join(
  226. PlPlanTransport,
  227. and_(
  228. PlPlanTransport.plan_id == PlPlan.id,
  229. PlPlanTransport.del_flag == 0,
  230. ),
  231. )
  232. .join(
  233. PlTransport,
  234. and_(
  235. PlTransport.id == PlPlanTransport.transport_id,
  236. PlTransport.del_flag == 0,
  237. ),
  238. )
  239. .filter(
  240. PlPlan.tenant_no == transport.tenant_no,
  241. PlPlan.del_flag == 0,
  242. PlPlan.transport_status == "arranged",
  243. PlTransport.tenant_no == transport.tenant_no,
  244. PlTransport.site_access_token.isnot(None),
  245. PlTransport.site_access_token != "",
  246. or_(
  247. PlPlan.plan_date == day,
  248. and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
  249. and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
  250. ),
  251. )
  252. )
  253. if kw:
  254. like = f"%{kw}%"
  255. product_match = (
  256. db.query(PlPlanProduct.id)
  257. .filter(
  258. PlPlanProduct.plan_id == PlPlan.id,
  259. PlPlanProduct.del_flag == 0,
  260. PlPlanProduct.product_name.like(like),
  261. )
  262. .exists()
  263. )
  264. query = query.filter(or_(PlTransport.license_plate.like(like), product_match))
  265. rows = query.order_by(
  266. PlPlan.pickup_time.desc(),
  267. PlPlan.plan_date.desc(),
  268. PlPlan.create_time.desc(),
  269. ).all()
  270. seen_plan: set[str] = set()
  271. plan_rows: list[tuple[PlPlan, PlTransport, list[PublicTransportHistoryStopRead]]] = []
  272. for plan, tr in rows:
  273. if plan.id in seen_plan:
  274. continue
  275. site_tok = (tr.site_access_token or "").strip()
  276. if not site_tok:
  277. continue
  278. site_stops = _build_site_history_stop_reads(
  279. db, tenant_no=transport.tenant_no, plan=plan, transport=tr
  280. )
  281. if not site_stops:
  282. continue
  283. seen_plan.add(plan.id)
  284. plan_rows.append((plan, tr, site_stops))
  285. products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
  286. if plan_rows:
  287. plan_ids = [plan.id for plan, _, _ in plan_rows]
  288. product_rows = (
  289. db.query(PlPlanProduct)
  290. .filter(
  291. PlPlanProduct.plan_id.in_(plan_ids),
  292. PlPlanProduct.del_flag == 0,
  293. )
  294. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  295. .all()
  296. )
  297. crm_product_ids = {
  298. (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
  299. }
  300. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  301. db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
  302. )
  303. unit_map = load_dict_label_maps(
  304. db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
  305. ).get(CRM_PRODUCT_UNIT, {})
  306. for prod in product_rows:
  307. product_id = (prod.product_id or "").strip()
  308. display_name, _alias_name = resolve_product_display_names(
  309. saved_product_name=prod.product_name,
  310. product_id=product_id,
  311. standard_by_id=standard_by_id,
  312. alias_options_by_id=alias_options_by_id,
  313. )
  314. products_by_plan.setdefault(prod.plan_id, []).append(
  315. PublicTransportHistoryProductRead(
  316. product_name=display_name or prod.product_name or "—",
  317. model=prod.model,
  318. package_spec=prod.package_spec,
  319. quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
  320. unit=_resolve_unit_label(unit_map, prod.unit),
  321. )
  322. )
  323. records: list[PublicTransportHistoryRecordRead] = []
  324. for plan, tr, site_stops in plan_rows:
  325. voided, archived = _resolve_driver_link_flags(plan, tr)
  326. site_tok = (tr.site_access_token or "").strip()
  327. records.append(
  328. PublicTransportHistoryRecordRead(
  329. plan_id=plan.id,
  330. plan_no=plan.plan_no or "",
  331. transport_id=tr.id,
  332. site_access_token=site_tok or None,
  333. site_url=_build_site_url(site_tok) if site_tok else None,
  334. license_plate=tr.license_plate,
  335. pickup_time=plan.pickup_time,
  336. delivery_time=plan.delivery_time,
  337. pickup_address=plan.pickup_address,
  338. destination_address=plan.destination_address or plan.destination_name,
  339. products=products_by_plan.get(plan.id) or [],
  340. stops=site_stops,
  341. plan_voided=voided,
  342. plan_archived=archived,
  343. status_label=_history_status_label(voided, archived),
  344. )
  345. )
  346. page = PublicTransportHistoryPageRead(
  347. license_plate=_normalize_main_plate(transport.license_plate) or None,
  348. date=day.isoformat(),
  349. keyword=kw or None,
  350. records=records,
  351. )
  352. return success_response(page.model_dump(by_alias=True))
  353. @router.get("/{token}")
  354. def get_public_transport_site(token: str, db: Session = Depends(get_db)):
  355. transport = _get_transport_by_site_token(db, token)
  356. page = _build_public_page(db, transport)
  357. return success_response(page.model_dump(by_alias=True))
  358. @router.post("/{token}/feedback")
  359. def save_public_site_stop_feedback(
  360. token: str,
  361. payload: PublicTransportSiteStopFeedback,
  362. db: Session = Depends(get_db),
  363. ):
  364. transport = _get_transport_by_site_token(db, token)
  365. page = _build_public_page(db, transport)
  366. if page.plan_voided:
  367. return error_response(400, "运输计划已作废,无法提交回执")
  368. if page.plan_archived:
  369. return error_response(400, "运输计划已归档,无法提交回执")
  370. link = (
  371. db.query(PlPlanTransport)
  372. .filter(
  373. PlPlanTransport.transport_id == transport.id,
  374. PlPlanTransport.del_flag == 0,
  375. )
  376. .order_by(PlPlanTransport.create_time.desc())
  377. .first()
  378. )
  379. if not link or not link.plan_id:
  380. return error_response(400, "未找到关联计划,无法保存段回传")
  381. stop_type = (payload.stop_type or "").strip()
  382. stop_id = (payload.stop_id or "").strip()
  383. if stop_type not in ("load", "delivery") or not stop_id:
  384. return error_response(400, "停靠段参数无效")
  385. segments = load_site_stop_segments(
  386. db,
  387. tenant_no=transport.tenant_no,
  388. plan_id=link.plan_id,
  389. transport_id=transport.id,
  390. )
  391. segment_map = {(item.stop_type, item.stop_id): item for item in segments}
  392. segment = segment_map.get((stop_type, stop_id))
  393. if not segment:
  394. return error_response(400, "该段不在现场回传范围内")
  395. try:
  396. save_site_stop_feedback(
  397. db,
  398. tenant_no=transport.tenant_no,
  399. transport_id=transport.id,
  400. plan_id=link.plan_id,
  401. stop_type=stop_type,
  402. stop_id=stop_id,
  403. business_type=segment.business_type or "入库",
  404. quantity=payload.quantity,
  405. attachment_urls=payload.attachment_urls,
  406. remark=payload.remark,
  407. )
  408. except ValueError as exc:
  409. return error_response(400, str(exc))
  410. db.commit()
  411. return success_response({"success": True, "message": "保存成功"})
  412. def _format_file_size(size_bytes: int) -> str:
  413. if size_bytes < 1024:
  414. return f"{size_bytes} B"
  415. if size_bytes < 1024 * 1024:
  416. return f"{size_bytes / 1024:.1f} KB"
  417. return f"{size_bytes / (1024 * 1024):.1f} MB"
  418. @router.post("/{token}/attachments")
  419. def upload_public_site_attachment(
  420. token: str,
  421. file: UploadFile = File(...),
  422. folder: Optional[str] = Form(""),
  423. db: Session = Depends(get_db),
  424. ):
  425. transport = _get_transport_by_site_token(db, token)
  426. page = _build_public_page(db, transport)
  427. if page.plan_voided:
  428. return error_response(400, "运输计划已作废,无法上传附件")
  429. if page.plan_archived:
  430. return error_response(400, "运输计划已归档,无法上传附件")
  431. if not file or not file.filename:
  432. return error_response(400, "请选择文件")
  433. try:
  434. effective_folder = resolve_upload_folder(
  435. folder=folder or "", biz_type="pl_transport_site_stop_feedback"
  436. )
  437. file_content = file.file.read()
  438. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  439. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  440. if base_url and full_url.startswith(base_url + "/"):
  441. file_url = full_url[len(base_url) + 1 :]
  442. else:
  443. file_url = full_url
  444. record = oss_svc.create_oss(
  445. db,
  446. tenant_no=transport.tenant_no,
  447. url=file_url,
  448. original_name=file.filename,
  449. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  450. file_size=len(file_content),
  451. file_type=file.content_type or "",
  452. biz_type="pl_transport_site_stop_feedback",
  453. user_id=None,
  454. ext1=effective_folder or None,
  455. )
  456. db.commit()
  457. return success_response(
  458. {
  459. "id": record.id,
  460. "fileName": record.original_name,
  461. "fileUrl": record.url,
  462. "fileSize": record.file_size,
  463. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  464. "fileType": record.file_type,
  465. }
  466. )
  467. except Exception as exc:
  468. db.rollback()
  469. return error_response(500, f"上传失败: {str(exc)}")