transport.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. """运输计划管理 - 运输 API"""
  2. from datetime import date, datetime
  3. from typing import Optional
  4. import json
  5. import secrets
  6. from fastapi import APIRouter, Depends, HTTPException, Query, status
  7. from pydantic import BaseModel, Field
  8. from sqlalchemy import func, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.database import get_db
  11. from app.core.db_auth import DbUserContext, get_db_current_user
  12. from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
  13. from app.core.tenant_context import get_tenant_no
  14. from app.core.utils import generate_uuid
  15. from app.core.config import settings
  16. from app.models.pl_models import (
  17. PlCarrier,
  18. PlDriver,
  19. PlPersonnel,
  20. PlPlan,
  21. PlPlanProduct,
  22. PlPlanTransport,
  23. PlTransport,
  24. PlTransportApproval,
  25. PlTransportDbLink,
  26. PlVehicle,
  27. )
  28. from app.schemas.pl.transport import (
  29. ApprovalRecordRead,
  30. ApprovalRequest,
  31. ApprovalSubmit,
  32. TransportArrange,
  33. TransportArrangeResult,
  34. TransportCreate,
  35. TransportFeedback,
  36. TransportInventoryLinkRead,
  37. TransportIoOrderBrief,
  38. TransportSiteLinkRead,
  39. TransportListQuery,
  40. TransportListResponse,
  41. TransportRead,
  42. )
  43. from app.services.pl_dibang_bridge_service import (
  44. revoke_regeneratable_dibang_for_transport,
  45. sync_dibang_for_transport,
  46. )
  47. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  48. from app.services.pl_io_order_service import (
  49. apply_dibang_prefill_to_order,
  50. create_io_order_for_transport,
  51. )
  52. from app.services.pl_plan_service import (
  53. is_ton_product_unit,
  54. validate_hazard_cert_ready_for_transport,
  55. )
  56. from app.services.pl_transport_resource_snapshot_service import (
  57. apply_resource_snapshot_json,
  58. build_transport_resource_snapshot,
  59. )
  60. from app.services.pl_carrier_service import assert_carrier_eligible_for_transport
  61. router = APIRouter(prefix="/order", tags=["运输计划-运输管理"])
  62. def _generate_transport_no(db: Session) -> str:
  63. """生成运输编号 SXYS######"""
  64. now = date.today()
  65. prefix = f"SXYS{now.strftime('%Y%m')}"
  66. last = (
  67. db.query(PlTransport)
  68. .filter(
  69. PlTransport.transport_no.like(f"{prefix}%"),
  70. PlTransport.del_flag == 0,
  71. )
  72. .order_by(PlTransport.transport_no.desc())
  73. .first()
  74. )
  75. if last:
  76. last_seq = int(last.transport_no[-6:])
  77. seq = last_seq + 1
  78. else:
  79. seq = 1
  80. return f"{prefix}{seq:06d}"
  81. def _resolve_driver_info(db: Session, tenant_no: str, driver_id: str) -> Optional[dict]:
  82. """解析司机信息:优先 pl_personnel,兼容旧 pl_driver。"""
  83. personnel = (
  84. db.query(PlPersonnel)
  85. .filter(
  86. PlPersonnel.id == driver_id,
  87. PlPersonnel.tenant_no == tenant_no,
  88. PlPersonnel.del_flag == 0,
  89. )
  90. .first()
  91. )
  92. if personnel:
  93. return {
  94. "id": personnel.id,
  95. "name": personnel.personnel_name,
  96. "phone": personnel.phone_no,
  97. }
  98. legacy = (
  99. db.query(PlDriver)
  100. .filter(
  101. PlDriver.id == driver_id,
  102. PlDriver.tenant_no == tenant_no,
  103. PlDriver.del_flag == 0,
  104. )
  105. .first()
  106. )
  107. if legacy:
  108. return {
  109. "id": legacy.id,
  110. "name": legacy.driver_name,
  111. "phone": legacy.phone_no,
  112. }
  113. return None
  114. def _get_vehicle_info(db: Session, vehicle_id: str, tenant_no: Optional[str] = None) -> dict:
  115. vehicle = db.query(PlVehicle).filter(PlVehicle.id == vehicle_id).first()
  116. if not vehicle:
  117. return {}
  118. info = {
  119. "license_plate": vehicle.license_plate,
  120. "carrier_id": vehicle.carrier_id,
  121. "carrier_name": vehicle.carrier_name,
  122. }
  123. if vehicle.default_driver_id:
  124. driver_info = (
  125. _resolve_driver_info(db, tenant_no, vehicle.default_driver_id)
  126. if tenant_no
  127. else None
  128. )
  129. if not driver_info:
  130. personnel = db.query(PlPersonnel).filter(
  131. PlPersonnel.id == vehicle.default_driver_id,
  132. PlPersonnel.del_flag == 0,
  133. ).first()
  134. if personnel:
  135. driver_info = {
  136. "id": personnel.id,
  137. "name": personnel.personnel_name,
  138. "phone": personnel.phone_no,
  139. }
  140. else:
  141. legacy = db.query(PlDriver).filter(
  142. PlDriver.id == vehicle.default_driver_id,
  143. PlDriver.del_flag == 0,
  144. ).first()
  145. if legacy:
  146. driver_info = {
  147. "id": legacy.id,
  148. "name": legacy.driver_name,
  149. "phone": legacy.phone_no,
  150. }
  151. if driver_info:
  152. info["driver_id"] = driver_info["id"]
  153. info["driver_name"] = driver_info["name"]
  154. info["driver_phone"] = driver_info["phone"]
  155. elif vehicle.default_driver_name:
  156. info["driver_id"] = vehicle.default_driver_id
  157. info["driver_name"] = vehicle.default_driver_name
  158. return info
  159. def _assert_vehicle_carrier_eligible(
  160. db: Session,
  161. tenant_no: str,
  162. vehicle_id: str,
  163. *,
  164. rearrange: bool = False,
  165. ) -> None:
  166. vehicle = (
  167. db.query(PlVehicle)
  168. .filter(
  169. PlVehicle.id == vehicle_id,
  170. PlVehicle.tenant_no == tenant_no,
  171. PlVehicle.del_flag == 0,
  172. )
  173. .first()
  174. )
  175. if not vehicle:
  176. raise HTTPException(status_code=400, detail="车辆不存在")
  177. if not vehicle.carrier_id:
  178. return
  179. carrier = (
  180. db.query(PlCarrier)
  181. .filter(
  182. PlCarrier.id == vehicle.carrier_id,
  183. PlCarrier.tenant_no == tenant_no,
  184. PlCarrier.del_flag == 0,
  185. )
  186. .first()
  187. )
  188. if not carrier:
  189. raise HTTPException(status_code=400, detail="承运商不存在")
  190. assert_carrier_eligible_for_transport(carrier, rearrange=rearrange)
  191. def _ensure_driver_access_token(transport: PlTransport) -> str:
  192. token = (transport.driver_access_token or "").strip()
  193. if not token:
  194. token = secrets.token_urlsafe(32)[:64]
  195. transport.driver_access_token = token
  196. return token
  197. def _rotate_driver_access_token(transport: PlTransport) -> str:
  198. token = secrets.token_urlsafe(32)[:64]
  199. transport.driver_access_token = token
  200. return token
  201. def _is_rearrange(plan: PlPlan, existing_transport: Optional[PlTransport]) -> bool:
  202. """仅计划已正式安排才算「调整运输」。
  203. 创建/编辑表单预选车辆会留下 draft 运输单,计划仍为 pending;
  204. 若把非 cancelled 运输单都当成 rearrange,入库/出库会被误拦截。
  205. existing_transport 保留入参以兼容调用方;草稿复用由后续分支处理。
  206. """
  207. _ = existing_transport
  208. return plan.transport_status == "arranged"
  209. def _transport_has_db_links(db: Session, tenant_no: str, transport_id: str) -> bool:
  210. return (
  211. db.query(PlTransportDbLink.id)
  212. .filter(
  213. PlTransportDbLink.tenant_no == tenant_no,
  214. PlTransportDbLink.transport_id == transport_id,
  215. PlTransportDbLink.del_flag == 0,
  216. )
  217. .first()
  218. is not None
  219. )
  220. def _apply_plan_logistics_from_payload(plan: PlPlan, payload: TransportArrange) -> None:
  221. if payload.pickup_address is not None:
  222. plan.pickup_address = payload.pickup_address
  223. elif payload.start_point is not None:
  224. plan.pickup_address = payload.start_point
  225. if "pickup_lng" in payload.model_fields_set or "pickup_lat" in payload.model_fields_set:
  226. plan.pickup_lng = payload.pickup_lng
  227. plan.pickup_lat = payload.pickup_lat
  228. if payload.pickup_time is not None:
  229. plan.pickup_time = payload.pickup_time
  230. if payload.pickup_notes is not None:
  231. plan.pickup_notes = payload.pickup_notes
  232. if payload.destination_address is not None:
  233. plan.destination_address = payload.destination_address
  234. elif payload.end_point is not None:
  235. plan.destination_address = payload.end_point
  236. if "destination_lng" in payload.model_fields_set or "destination_lat" in payload.model_fields_set:
  237. plan.destination_lng = payload.destination_lng
  238. plan.destination_lat = payload.destination_lat
  239. if payload.delivery_time is not None:
  240. plan.delivery_time = payload.delivery_time
  241. if payload.destination_notes is not None:
  242. plan.destination_notes = payload.destination_notes
  243. def _resolve_transport_endpoints(plan: PlPlan, payload: TransportArrange) -> tuple[Optional[str], Optional[str]]:
  244. start_point = (
  245. payload.pickup_address
  246. or payload.start_point
  247. or plan.pickup_address
  248. )
  249. end_point = (
  250. payload.destination_address
  251. or payload.end_point
  252. or plan.destination_address
  253. or plan.destination_name
  254. )
  255. return start_point, end_point
  256. def _apply_transport_arrange_fields(
  257. transport: PlTransport,
  258. *,
  259. payload: TransportArrange,
  260. vehicle_info: dict,
  261. driver_info: dict,
  262. carrier_name: Optional[str],
  263. start_point: Optional[str],
  264. end_point: Optional[str],
  265. freight_amount: Optional[float],
  266. user_id: str,
  267. ) -> None:
  268. transport.vehicle_id = payload.vehicle_id
  269. transport.license_plate = vehicle_info.get("license_plate")
  270. transport.driver_id = driver_info["id"]
  271. transport.driver_name = driver_info["name"]
  272. transport.driver_phone = driver_info["phone"]
  273. transport.carrier_id = payload.carrier_id or vehicle_info.get("carrier_id")
  274. transport.carrier_name = carrier_name
  275. transport.transport_medium = payload.transport_medium
  276. transport.start_point = start_point
  277. transport.end_point = end_point
  278. transport.distance_km = payload.distance_km
  279. transport.unit = payload.unit or ("吨" if payload.freight_type == "by_weight" else "趟")
  280. transport.freight_price = payload.freight_price
  281. transport.freight_amount = freight_amount or 0
  282. transport.freight_type = payload.freight_type
  283. if payload.has_invoice is not None:
  284. transport.has_invoice = payload.has_invoice
  285. if payload.remark is not None:
  286. transport.remark = payload.remark
  287. transport.update_by = user_id
  288. def _build_driver_url(token: str) -> str:
  289. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  290. return f"{origin}/#/public/pl/transport/{token}"
  291. def _build_arrange_result(
  292. transport: PlTransport,
  293. site_records: list[TransportSiteLinkRead],
  294. inventory_records: list[TransportInventoryLinkRead],
  295. io_order: Optional[TransportIoOrderBrief] = None,
  296. ) -> TransportArrangeResult:
  297. token = transport.driver_access_token or ""
  298. result = TransportArrangeResult.model_validate(transport)
  299. result.driver_access_token = token
  300. result.driver_url = _build_driver_url(token) if token else None
  301. result.site_records = site_records
  302. result.inventory_records = inventory_records
  303. result.io_order = io_order
  304. return result
  305. # =============================================================================
  306. # 运输列表
  307. # =============================================================================
  308. @router.get("/", response_model=TransportListResponse)
  309. def list_transports(
  310. q: Optional[str] = Query(default=None, description="模糊搜索"),
  311. transport_no: Optional[str] = Query(default=None, alias="transportNo", description="运输编号"),
  312. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  313. approval_status: Optional[str] = Query(default=None, alias="approvalStatus", description="审批状态"),
  314. vehicle_id: Optional[str] = Query(default=None, alias="vehicleId", description="车辆ID"),
  315. driver_id: Optional[str] = Query(default=None, alias="driverId", description="司机ID"),
  316. page: int = Query(default=1, ge=1),
  317. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  318. db: Session = Depends(get_db),
  319. user: DbUserContext = Depends(get_db_current_user),
  320. ) -> TransportListResponse:
  321. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  322. query = db.query(PlTransport).filter(
  323. PlTransport.tenant_no == tenant_no,
  324. PlTransport.del_flag == 0,
  325. )
  326. if q:
  327. query = query.filter(
  328. or_(
  329. PlTransport.transport_no.contains(q),
  330. PlTransport.license_plate.contains(q),
  331. PlTransport.driver_name.contains(q),
  332. )
  333. )
  334. if transport_no:
  335. query = query.filter(PlTransport.transport_no.contains(transport_no))
  336. if transport_status:
  337. query = query.filter(PlTransport.transport_status == transport_status)
  338. if approval_status:
  339. query = query.filter(PlTransport.approval_status == approval_status)
  340. if vehicle_id:
  341. query = query.filter(PlTransport.vehicle_id == vehicle_id)
  342. if driver_id:
  343. query = query.filter(PlTransport.driver_id == driver_id)
  344. query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
  345. total = query.count()
  346. offset = (page - 1) * page_size
  347. transports = query.order_by(PlTransport.create_time.desc()).offset(offset).limit(page_size).all()
  348. return TransportListResponse(total=total, list=transports)
  349. # =============================================================================
  350. # 创建运输
  351. # =============================================================================
  352. @router.post("/", response_model=TransportRead, status_code=status.HTTP_201_CREATED)
  353. def create_transport(
  354. payload: TransportCreate,
  355. db: Session = Depends(get_db),
  356. user: DbUserContext = Depends(get_db_current_user),
  357. ) -> TransportRead:
  358. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  359. transport_no = _generate_transport_no(db)
  360. transport = PlTransport(
  361. id=generate_uuid(),
  362. tenant_no=tenant_no,
  363. transport_no=transport_no,
  364. vehicle_id=payload.vehicle_id,
  365. license_plate=payload.license_plate,
  366. driver_id=payload.driver_id,
  367. driver_name=payload.driver_name,
  368. driver_phone=payload.driver_phone,
  369. carrier_id=payload.carrier_id,
  370. carrier_name=payload.carrier_name,
  371. transport_medium=payload.transport_medium,
  372. start_point=payload.start_point,
  373. end_point=payload.end_point,
  374. distance_km=payload.distance_km,
  375. unit=payload.unit,
  376. freight_price=payload.freight_price,
  377. freight_amount=payload.freight_amount,
  378. freight_type=payload.freight_type,
  379. has_invoice=payload.has_invoice,
  380. qr_expire_date=payload.qr_expire_date,
  381. transport_status=payload.transport_status or "draft",
  382. approval_status=payload.approval_status or "none",
  383. remark=payload.remark,
  384. create_by=user.id,
  385. create_dept_id=user.dept_id,
  386. )
  387. db.add(transport)
  388. # 关联计划
  389. if payload.plan_ids:
  390. for pid in payload.plan_ids:
  391. pt = PlPlanTransport(
  392. id=generate_uuid(),
  393. tenant_no=tenant_no,
  394. plan_id=pid,
  395. transport_id=transport.id,
  396. create_by=user.id,
  397. create_dept_id=user.dept_id,
  398. )
  399. db.add(pt)
  400. # 更新计划关联的运输信息
  401. plan = db.query(PlPlan).filter(PlPlan.id == pid, PlPlan.del_flag == 0).first()
  402. if plan:
  403. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  404. plan.transport_id = transport.id
  405. plan.transport_no = transport_no
  406. plan.vehicle_id = transport.vehicle_id
  407. plan.license_plate = transport.license_plate
  408. plan.driver_id = transport.driver_id
  409. plan.driver_name = transport.driver_name
  410. if transport.vehicle_id:
  411. snapshot = build_transport_resource_snapshot(
  412. db,
  413. tenant_no,
  414. transport.vehicle_id,
  415. selected_driver_id=transport.driver_id,
  416. )
  417. apply_resource_snapshot_json(
  418. plan=plan, transport=transport, snapshot=snapshot
  419. )
  420. db.commit()
  421. db.refresh(transport)
  422. return transport
  423. # =============================================================================
  424. # 运输安排
  425. # =============================================================================
  426. @router.post("/arrange/{plan_id}", response_model=TransportArrangeResult, status_code=status.HTTP_201_CREATED)
  427. def arrange_transport(
  428. plan_id: str,
  429. payload: TransportArrange,
  430. db: Session = Depends(get_db),
  431. user: DbUserContext = Depends(get_db_current_user),
  432. ) -> TransportArrangeResult:
  433. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  434. # 获取计划
  435. plan = db.query(PlPlan).filter(
  436. PlPlan.id == plan_id,
  437. PlPlan.tenant_no == tenant_no,
  438. PlPlan.del_flag == 0,
  439. ).first()
  440. if not plan:
  441. raise HTTPException(status_code=404, detail="计划不存在")
  442. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  443. existing_transport = None
  444. if plan.transport_id:
  445. existing_transport = db.query(PlTransport).filter(
  446. PlTransport.id == plan.transport_id,
  447. PlTransport.tenant_no == tenant_no,
  448. PlTransport.del_flag == 0,
  449. ).first()
  450. is_rearrange = _is_rearrange(plan, existing_transport)
  451. # 入库/出库:已安排后完全冻结,仅允许作废后新建计划再安排
  452. if is_rearrange and plan.plan_type in ("inbound", "outbound"):
  453. raise HTTPException(
  454. status_code=400,
  455. detail="已安排的入库/出库计划不可调整运输,请作废后新建计划再安排",
  456. )
  457. if is_rearrange and existing_transport and existing_transport.vehicle_id:
  458. _assert_vehicle_carrier_eligible(
  459. db,
  460. tenant_no,
  461. existing_transport.vehicle_id,
  462. rearrange=True,
  463. )
  464. _assert_vehicle_carrier_eligible(db, tenant_no, payload.vehicle_id, rearrange=False)
  465. # 获取车辆信息
  466. vehicle_info = _get_vehicle_info(db, payload.vehicle_id, tenant_no)
  467. # 获取司机信息(driverId 为人员 pl_personnel.id)
  468. driver_info = _resolve_driver_info(db, tenant_no, payload.driver_id)
  469. if not driver_info:
  470. raise HTTPException(status_code=400, detail="司机不存在")
  471. # 检查产品属性(易制毒/易制爆需要证件,以 CRM 产品主数据为准)
  472. products = db.query(PlPlanProduct).filter(
  473. PlPlanProduct.plan_id == plan_id,
  474. PlPlanProduct.del_flag == 0,
  475. ).all()
  476. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  477. # 计算运费
  478. freight_amount = payload.freight_amount
  479. if payload.freight_type == "by_weight" and payload.freight_price:
  480. total_qty = sum(
  481. float(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0)
  482. for p in products
  483. )
  484. freight_amount = total_qty * float(payload.freight_price)
  485. carrier_name = vehicle_info.get("carrier_name")
  486. if payload.carrier_id:
  487. carrier = db.query(PlCarrier).filter(PlCarrier.id == payload.carrier_id).first()
  488. if carrier:
  489. carrier_name = carrier.carrier_name
  490. _apply_plan_logistics_from_payload(plan, payload)
  491. start_point, end_point = _resolve_transport_endpoints(plan, payload)
  492. if (
  493. plan.plan_type in ("inbound", "outbound")
  494. and is_rearrange
  495. and existing_transport
  496. and _transport_has_db_links(db, tenant_no, existing_transport.id)
  497. ):
  498. try:
  499. revoke_regeneratable_dibang_for_transport(
  500. db,
  501. tenant_no=tenant_no,
  502. transport_id=existing_transport.id,
  503. user_id=user.id,
  504. )
  505. except ValueError as exc:
  506. raise HTTPException(status_code=400, detail=str(exc)) from exc
  507. if existing_transport and existing_transport.transport_status != "cancelled":
  508. transport = existing_transport
  509. _apply_transport_arrange_fields(
  510. transport,
  511. payload=payload,
  512. vehicle_info=vehicle_info,
  513. driver_info=driver_info,
  514. carrier_name=carrier_name,
  515. start_point=start_point,
  516. end_point=end_point,
  517. freight_amount=freight_amount,
  518. user_id=user.id,
  519. )
  520. transport_no = transport.transport_no
  521. else:
  522. transport_no = _generate_transport_no(db)
  523. transport = PlTransport(
  524. id=generate_uuid(),
  525. tenant_no=tenant_no,
  526. transport_no=transport_no,
  527. transport_status="draft",
  528. approval_status="none",
  529. create_by=user.id,
  530. create_dept_id=user.dept_id,
  531. )
  532. db.add(transport)
  533. _apply_transport_arrange_fields(
  534. transport,
  535. payload=payload,
  536. vehicle_info=vehicle_info,
  537. driver_info=driver_info,
  538. carrier_name=carrier_name,
  539. start_point=start_point,
  540. end_point=end_point,
  541. freight_amount=freight_amount,
  542. user_id=user.id,
  543. )
  544. pt = PlPlanTransport(
  545. id=generate_uuid(),
  546. tenant_no=tenant_no,
  547. plan_id=plan_id,
  548. transport_id=transport.id,
  549. create_by=user.id,
  550. create_dept_id=user.dept_id,
  551. )
  552. db.add(pt)
  553. plan.transport_id = transport.id
  554. plan.transport_no = transport_no
  555. plan.vehicle_id = transport.vehicle_id
  556. plan.license_plate = transport.license_plate
  557. plan.driver_id = transport.driver_id
  558. plan.driver_name = transport.driver_name
  559. if transport.vehicle_id:
  560. snapshot = build_transport_resource_snapshot(
  561. db,
  562. tenant_no,
  563. transport.vehicle_id,
  564. selected_driver_id=transport.driver_id,
  565. )
  566. apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
  567. plan.transport_status = "arranged"
  568. plan.freight_amount = freight_amount or 0
  569. plan.update_by = user.id
  570. if is_rearrange:
  571. _rotate_driver_access_token(transport)
  572. else:
  573. _ensure_driver_access_token(transport)
  574. site_records: list[TransportSiteLinkRead] = []
  575. inventory_records: list[TransportInventoryLinkRead] = []
  576. io_order_brief: Optional[TransportIoOrderBrief] = None
  577. if plan.plan_type in ("inbound", "outbound"):
  578. try:
  579. io_result = create_io_order_for_transport(
  580. db,
  581. tenant_no=tenant_no,
  582. plan=plan,
  583. products=products,
  584. transport=transport,
  585. user_id=user.id,
  586. dept_id=user.dept_id or "",
  587. after_dibang=False,
  588. )
  589. if io_result:
  590. io_order_brief = TransportIoOrderBrief(
  591. kind=io_result.kind,
  592. order_id=io_result.order_id,
  593. order_no=io_result.order_no,
  594. site_access_token=io_result.site_access_token,
  595. site_url=io_result.site_url,
  596. status="in_progress",
  597. )
  598. # 兼容旧前端:合成单条 siteRecords 供复制现场链接
  599. site_records = [
  600. TransportSiteLinkRead(
  601. plan_product_id="",
  602. product_name=io_result.order_no,
  603. site_link_id=io_result.order_id,
  604. plan_no=plan.plan_no or "",
  605. business_type="入库" if io_result.kind == "inbound" else "出库",
  606. site_access_token=io_result.site_access_token,
  607. site_url=io_result.site_url,
  608. )
  609. ]
  610. except ValueError as exc:
  611. db.rollback()
  612. raise HTTPException(status_code=400, detail=str(exc)) from exc
  613. if plan.plan_type in ("inbound", "outbound") and payload.generate_dibang_plan:
  614. non_ton_products = [
  615. prod.product_name or prod.product_no or "未知产品"
  616. for prod in products
  617. if not is_ton_product_unit(prod.unit)
  618. ]
  619. if non_ton_products:
  620. raise HTTPException(
  621. status_code=400,
  622. detail=f"产品【{non_ton_products[0]}】等单位非吨,不支持生成地磅单",
  623. )
  624. try:
  625. link_results = sync_dibang_for_transport(
  626. db,
  627. tenant_no=tenant_no,
  628. plan=plan,
  629. products=products,
  630. transport=transport,
  631. user_id=user.id,
  632. dept_id=user.dept_id,
  633. )
  634. inventory_records = [
  635. build_transport_inventory_link_read(
  636. db,
  637. plan_product_id=item.plan_product_id,
  638. product_name=item.product_name,
  639. db_purchase_plan_id=item.db_purchase_plan_id,
  640. db_inventory_id=item.db_inventory_id,
  641. batch_no=item.batch_no,
  642. plan_no=item.plan_no,
  643. )
  644. for item in link_results
  645. ]
  646. except ValueError as exc:
  647. db.rollback()
  648. raise HTTPException(status_code=400, detail=str(exc)) from exc
  649. apply_dibang_prefill_to_order(
  650. db, tenant_no=tenant_no, plan=plan, transport=transport
  651. )
  652. db.commit()
  653. db.refresh(transport)
  654. return _build_arrange_result(transport, site_records, inventory_records, io_order_brief)
  655. # =============================================================================
  656. # 获取运输详情
  657. # =============================================================================
  658. @router.get("/{id}", response_model=TransportRead)
  659. def get_transport(
  660. id: str,
  661. db: Session = Depends(get_db),
  662. user: DbUserContext = Depends(get_db_current_user),
  663. ) -> TransportRead:
  664. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  665. transport = _get_transport(db, tenant_no, id, user)
  666. return transport
  667. # =============================================================================
  668. # 生成二维码
  669. # =============================================================================
  670. class QrCodeResponse(BaseModel):
  671. transport_id: str = Field(alias="transportId")
  672. transport_no: str = Field(alias="transportNo")
  673. qr_code: str = Field(alias="qrCode")
  674. expire_date: Optional[date] = Field(default=None, alias="expireDate")
  675. model_config = {"from_attributes": True, "populate_by_name": True}
  676. @router.get("/{id}/qrcode", response_model=QrCodeResponse)
  677. def generate_qrcode(
  678. id: str,
  679. expire_days: int = Query(default=30, ge=1, le=365, description="有效期天数"),
  680. db: Session = Depends(get_db),
  681. user: DbUserContext = Depends(get_db_current_user),
  682. ) -> QrCodeResponse:
  683. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  684. transport = _get_transport(db, tenant_no, id, user)
  685. from datetime import timedelta
  686. expire_date = date.today() + timedelta(days=expire_days)
  687. # 生成二维码内容(URL scheme)
  688. qr_content = f"/transport/scan/{transport.id}?code={transport.transport_no}"
  689. transport.qr_code = qr_content
  690. transport.qr_expire_date = expire_date
  691. db.commit()
  692. return QrCodeResponse(
  693. transport_id=transport.id,
  694. transport_no=transport.transport_no,
  695. qr_code=qr_content,
  696. expire_date=expire_date,
  697. )
  698. # =============================================================================
  699. # 司机扫码回传(无需登录)
  700. # =============================================================================
  701. @router.post("/scan/feedback")
  702. def transport_scan_feedback(
  703. payload: TransportFeedback,
  704. db: Session = Depends(get_db),
  705. ) -> dict:
  706. """司机扫码回传,无需登录"""
  707. transport = (
  708. db.query(PlTransport)
  709. .filter(
  710. PlTransport.transport_no == payload.transport_no,
  711. PlTransport.del_flag == 0,
  712. )
  713. .first()
  714. )
  715. if not transport:
  716. raise HTTPException(status_code=404, detail="运输单不存在")
  717. if payload.verify_code and payload.verify_code != transport.transport_no:
  718. raise HTTPException(status_code=400, detail="验证码错误")
  719. # 更新回传信息
  720. if payload.feedback_qty:
  721. transport.feedback_qty = payload.feedback_qty
  722. if payload.feedback_images:
  723. transport.feedback_images = json.dumps(payload.feedback_images)
  724. if payload.feedback_remark:
  725. transport.feedback_remark = payload.feedback_remark
  726. transport.feedback_time = datetime.now()
  727. db.commit()
  728. return {"success": True, "message": "回传成功"}
  729. # =============================================================================
  730. # 提交审批
  731. # =============================================================================
  732. @router.post("/{id}/submit-approval", response_model=TransportRead)
  733. def submit_approval(
  734. id: str,
  735. payload: ApprovalSubmit,
  736. db: Session = Depends(get_db),
  737. user: DbUserContext = Depends(get_db_current_user),
  738. ) -> TransportRead:
  739. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  740. transport = _get_transport(db, tenant_no, id, user)
  741. if transport.approval_status not in ("none", "rejected"):
  742. raise HTTPException(status_code=400, detail="当前状态不允许提交审批")
  743. # 创建审批记录
  744. for idx, approver_id in enumerate(payload.approver_ids):
  745. from app.models.models import SysUser
  746. approver = db.query(SysUser).filter(SysUser.id == approver_id).first()
  747. if not approver:
  748. raise HTTPException(status_code=400, detail=f"审批人不存在: {approver_id}")
  749. approval = PlTransportApproval(
  750. id=generate_uuid(),
  751. tenant_no=tenant_no,
  752. transport_id=id,
  753. approver_id=approver_id,
  754. approver_name=approver.nick_name,
  755. approval_order=idx + 1,
  756. status="pending",
  757. create_by=user.id,
  758. create_dept_id=user.dept_id,
  759. )
  760. db.add(approval)
  761. transport.approval_status = "pending"
  762. db.commit()
  763. db.refresh(transport)
  764. return transport
  765. # =============================================================================
  766. # 审批
  767. # =============================================================================
  768. @router.post("/approval/{id}", response_model=ApprovalRecordRead)
  769. def process_approval(
  770. id: str,
  771. payload: ApprovalRequest,
  772. db: Session = Depends(get_db),
  773. user: DbUserContext = Depends(get_db_current_user),
  774. ) -> ApprovalRecordRead:
  775. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  776. approval = (
  777. db.query(PlTransportApproval)
  778. .filter(
  779. PlTransportApproval.id == id,
  780. PlTransportApproval.tenant_no == tenant_no,
  781. PlTransportApproval.del_flag == 0,
  782. )
  783. .first()
  784. )
  785. if not approval:
  786. raise HTTPException(status_code=404, detail="审批记录不存在")
  787. if approval.status != "pending":
  788. raise HTTPException(status_code=400, detail="该审批已处理")
  789. # 更新审批状态
  790. from datetime import datetime
  791. approval.status = "approved" if payload.action == "approve" else "rejected"
  792. approval.comment = payload.comment
  793. approval.approval_time = datetime.now()
  794. # 更新运输审批状态
  795. transport = db.query(PlTransport).filter(PlTransport.id == approval.transport_id).first()
  796. if transport:
  797. if payload.action == "approve":
  798. # 检查是否所有审批人都通过了
  799. pending = (
  800. db.query(PlTransportApproval)
  801. .filter(
  802. PlTransportApproval.transport_id == approval.transport_id,
  803. PlTransportApproval.status == "pending",
  804. PlTransportApproval.del_flag == 0,
  805. )
  806. .count()
  807. )
  808. if pending == 0:
  809. transport.approval_status = "approved"
  810. transport.transport_status = "pending"
  811. else:
  812. transport.approval_status = "rejected"
  813. transport.transport_status = "draft"
  814. db.commit()
  815. db.refresh(approval)
  816. return approval
  817. # =============================================================================
  818. # 获取运输的审批记录
  819. # =============================================================================
  820. @router.get("/{id}/approvals", response_model=list[ApprovalRecordRead])
  821. def get_transport_approvals(
  822. id: str,
  823. db: Session = Depends(get_db),
  824. user: DbUserContext = Depends(get_db_current_user),
  825. ) -> list[ApprovalRecordRead]:
  826. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  827. approvals = (
  828. db.query(PlTransportApproval)
  829. .filter(
  830. PlTransportApproval.transport_id == id,
  831. PlTransportApproval.tenant_no == tenant_no,
  832. PlTransportApproval.del_flag == 0,
  833. )
  834. .order_by(PlTransportApproval.approval_order)
  835. .all()
  836. )
  837. return approvals
  838. # =============================================================================
  839. # 辅助函数
  840. # =============================================================================
  841. def _get_transport(db: Session, tenant_no: str, transport_id: str, user: DbUserContext) -> PlTransport:
  842. transport = (
  843. db.query(PlTransport)
  844. .filter(
  845. PlTransport.id == transport_id,
  846. PlTransport.tenant_no == tenant_no,
  847. PlTransport.del_flag == 0,
  848. )
  849. .first()
  850. )
  851. if not transport:
  852. raise HTTPException(status_code=404, detail="运输不存在")
  853. assert_pl_row_in_scope(db, transport, PlTransport, user, tenant_no)
  854. return transport