transport.py 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363
  1. """运输计划管理 - 运输 API"""
  2. from datetime import date, datetime
  3. from typing import List, 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.utils.excel_export import (
  15. EXPORT_MAX_ROWS,
  16. ExportColumn,
  17. ExportValidationError,
  18. export_rows_to_xlsx_response,
  19. filter_export_columns,
  20. )
  21. from app.core.utils import generate_uuid
  22. from app.core.config import settings
  23. from app.models.pl_models import (
  24. PlCarrier,
  25. PlDriver,
  26. PlPersonnel,
  27. PlPlan,
  28. PlPlanProduct,
  29. PlPlanTransport,
  30. PlTransport,
  31. PlTransportApproval,
  32. PlTransportDbLink,
  33. PlVehicle,
  34. )
  35. from app.schemas.pl.io_order import BrandQtyItem
  36. from app.schemas.pl.transport import (
  37. ApprovalRecordRead,
  38. ApprovalRequest,
  39. ApprovalSubmit,
  40. TransportArrange,
  41. TransportArrangeResult,
  42. TransportCreate,
  43. TransportFeedback,
  44. TransportInventoryLinkRead,
  45. TransportIoOrderBrief,
  46. TransportSiteLinkRead,
  47. TransportListQuery,
  48. TransportListResponse,
  49. TransportDriverProductFeedbackRead,
  50. TransportDriverStopProductFeedbackRead,
  51. TransportRead,
  52. TransportStopFeedbackRead,
  53. )
  54. from app.services.pl_dibang_bridge_service import (
  55. StopDibangOption,
  56. plan_has_load_stop,
  57. sync_dibang_for_plan_stops,
  58. sync_dibang_for_transport,
  59. )
  60. from app.services.pl_transport_stop_feedback_service import (
  61. build_transport_site_url,
  62. ensure_transport_site_token,
  63. load_driver_stop_segments,
  64. load_site_stop_segments,
  65. )
  66. from app.services.pl_inventory_link_read import build_transport_inventory_link_read
  67. from app.services.pl_io_order_service import (
  68. apply_dibang_prefill_to_order,
  69. create_io_order_for_transport,
  70. void_io_orders_for_plan,
  71. )
  72. from app.services.pl_internal_customer import plan_requires_site_link
  73. from app.services.pl_plan_service import (
  74. clear_hazard_cert_ready,
  75. plan_has_toxic_products,
  76. )
  77. from app.services.pl_transport_effect_service import revoke_transport_effective_effects
  78. from app.services.pl_transport_resource_snapshot_service import (
  79. apply_resource_snapshot_json,
  80. build_transport_resource_snapshot,
  81. )
  82. from app.services.pl_carrier_service import assert_carrier_eligible_for_transport
  83. from app.services.pl_feedback_brand_qty import load_transport_driver_product_feedbacks
  84. router = APIRouter(prefix="/order", tags=["运输计划-运输管理"])
  85. def _transport_to_read(db: Session, transport: PlTransport) -> TransportRead:
  86. base = TransportRead.model_validate(transport)
  87. feedbacks = load_transport_driver_product_feedbacks(
  88. db,
  89. tenant_no=transport.tenant_no,
  90. transport_id=transport.id,
  91. )
  92. updates: dict = {}
  93. if feedbacks:
  94. updates["driver_product_feedbacks"] = [
  95. TransportDriverProductFeedbackRead.model_validate(item)
  96. for item in feedbacks
  97. ]
  98. link = (
  99. db.query(PlPlanTransport)
  100. .filter(
  101. PlPlanTransport.transport_id == transport.id,
  102. PlPlanTransport.del_flag == 0,
  103. )
  104. .order_by(PlPlanTransport.create_time.desc())
  105. .first()
  106. )
  107. if link and link.plan_id and plan_has_load_stop(db, transport.tenant_no, link.plan_id):
  108. updates["driver_stop_feedbacks"] = [
  109. TransportStopFeedbackRead(
  110. stop_type=segment.stop_type,
  111. stop_id=segment.stop_id,
  112. label=segment.label,
  113. customer_name=segment.customer_name,
  114. attachment_urls=segment.attachment_urls,
  115. products=[
  116. TransportDriverStopProductFeedbackRead(
  117. stop_product_id=product.stop_product_id,
  118. plan_product_id=product.plan_product_id,
  119. product_name=product.product_name,
  120. model=product.model,
  121. unit=product.unit,
  122. package_spec=product.package_spec,
  123. quantity=product.plan_qty,
  124. actual_qty=product.actual_qty,
  125. brand_qtys=[
  126. BrandQtyItem(
  127. brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
  128. brand_name=item.get("brandName") or item.get("brand_name"),
  129. qty=item.get("qty") or 0,
  130. )
  131. for item in (product.brand_qtys or [])
  132. if isinstance(item, dict)
  133. ]
  134. or None,
  135. )
  136. for product in segment.products
  137. if product.actual_qty is not None
  138. or (product.brand_qtys and len(product.brand_qtys) > 0)
  139. ],
  140. remark=segment.remark,
  141. feedback_time=segment.feedback_time,
  142. )
  143. for segment in load_driver_stop_segments(
  144. db,
  145. tenant_no=transport.tenant_no,
  146. plan_id=link.plan_id,
  147. transport_id=transport.id,
  148. )
  149. if segment.attachment_urls
  150. or segment.remark
  151. or segment.feedback_time
  152. or any(
  153. product.actual_qty is not None
  154. or (product.brand_qtys and len(product.brand_qtys) > 0)
  155. for product in segment.products
  156. )
  157. ]
  158. updates["site_stop_feedbacks"] = [
  159. TransportStopFeedbackRead(
  160. stop_type=segment.stop_type,
  161. stop_id=segment.stop_id,
  162. label=segment.label,
  163. customer_name=segment.customer_name,
  164. business_type=segment.business_type,
  165. quantity=segment.quantity,
  166. attachment_urls=segment.attachment_urls,
  167. products=[
  168. TransportDriverStopProductFeedbackRead(
  169. stop_product_id=product.stop_product_id,
  170. plan_product_id=product.plan_product_id,
  171. product_name=product.product_name,
  172. model=product.model,
  173. unit=product.unit,
  174. package_spec=product.package_spec,
  175. quantity=product.plan_qty,
  176. actual_qty=product.actual_qty,
  177. brand_qtys=[
  178. BrandQtyItem(
  179. brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
  180. brand_name=item.get("brandName") or item.get("brand_name"),
  181. qty=item.get("qty") or 0,
  182. )
  183. for item in (product.brand_qtys or [])
  184. if isinstance(item, dict)
  185. ]
  186. or None,
  187. )
  188. for product in segment.products
  189. if product.actual_qty is not None
  190. or (product.brand_qtys and len(product.brand_qtys) > 0)
  191. ],
  192. remark=segment.remark,
  193. feedback_time=segment.feedback_time,
  194. )
  195. for segment in load_site_stop_segments(
  196. db,
  197. tenant_no=transport.tenant_no,
  198. plan_id=link.plan_id,
  199. transport_id=transport.id,
  200. )
  201. if segment.quantity is not None
  202. or segment.attachment_urls
  203. or segment.remark
  204. or any(
  205. product.actual_qty is not None
  206. or (product.brand_qtys and len(product.brand_qtys) > 0)
  207. for product in segment.products
  208. )
  209. ]
  210. if not updates:
  211. return base
  212. return base.model_copy(update=updates)
  213. def _generate_transport_no(db: Session) -> str:
  214. """生成运输编号 SXYS######"""
  215. now = date.today()
  216. prefix = f"SXYS{now.strftime('%Y%m')}"
  217. last = (
  218. db.query(PlTransport)
  219. .filter(
  220. PlTransport.transport_no.like(f"{prefix}%"),
  221. PlTransport.del_flag == 0,
  222. )
  223. .order_by(PlTransport.transport_no.desc())
  224. .first()
  225. )
  226. if last:
  227. last_seq = int(last.transport_no[-6:])
  228. seq = last_seq + 1
  229. else:
  230. seq = 1
  231. return f"{prefix}{seq:06d}"
  232. def _resolve_driver_info(db: Session, tenant_no: str, driver_id: Optional[str]) -> Optional[dict]:
  233. """解析司机信息:优先 pl_personnel,兼容旧 pl_driver。"""
  234. if not driver_id:
  235. return None
  236. personnel = (
  237. db.query(PlPersonnel)
  238. .filter(
  239. PlPersonnel.id == driver_id,
  240. PlPersonnel.tenant_no == tenant_no,
  241. PlPersonnel.del_flag == 0,
  242. )
  243. .first()
  244. )
  245. if personnel:
  246. return {
  247. "id": personnel.id,
  248. "name": personnel.personnel_name,
  249. "phone": personnel.phone_no,
  250. }
  251. legacy = (
  252. db.query(PlDriver)
  253. .filter(
  254. PlDriver.id == driver_id,
  255. PlDriver.tenant_no == tenant_no,
  256. PlDriver.del_flag == 0,
  257. )
  258. .first()
  259. )
  260. if legacy:
  261. return {
  262. "id": legacy.id,
  263. "name": legacy.driver_name,
  264. "phone": legacy.phone_no,
  265. }
  266. return None
  267. def _get_vehicle_info(db: Session, vehicle_id: str, tenant_no: Optional[str] = None) -> dict:
  268. vehicle = db.query(PlVehicle).filter(PlVehicle.id == vehicle_id).first()
  269. if not vehicle:
  270. return {}
  271. info = {
  272. "license_plate": vehicle.license_plate,
  273. "carrier_id": vehicle.carrier_id,
  274. "carrier_name": vehicle.carrier_name,
  275. }
  276. if vehicle.default_driver_id:
  277. driver_info = (
  278. _resolve_driver_info(db, tenant_no, vehicle.default_driver_id)
  279. if tenant_no
  280. else None
  281. )
  282. if not driver_info:
  283. personnel = db.query(PlPersonnel).filter(
  284. PlPersonnel.id == vehicle.default_driver_id,
  285. PlPersonnel.del_flag == 0,
  286. ).first()
  287. if personnel:
  288. driver_info = {
  289. "id": personnel.id,
  290. "name": personnel.personnel_name,
  291. "phone": personnel.phone_no,
  292. }
  293. else:
  294. legacy = db.query(PlDriver).filter(
  295. PlDriver.id == vehicle.default_driver_id,
  296. PlDriver.del_flag == 0,
  297. ).first()
  298. if legacy:
  299. driver_info = {
  300. "id": legacy.id,
  301. "name": legacy.driver_name,
  302. "phone": legacy.phone_no,
  303. }
  304. if driver_info:
  305. info["driver_id"] = driver_info["id"]
  306. info["driver_name"] = driver_info["name"]
  307. info["driver_phone"] = driver_info["phone"]
  308. elif vehicle.default_driver_name:
  309. info["driver_id"] = vehicle.default_driver_id
  310. info["driver_name"] = vehicle.default_driver_name
  311. return info
  312. def _assert_vehicle_carrier_eligible(
  313. db: Session,
  314. tenant_no: str,
  315. vehicle_id: str,
  316. *,
  317. rearrange: bool = False,
  318. ) -> None:
  319. vehicle = (
  320. db.query(PlVehicle)
  321. .filter(
  322. PlVehicle.id == vehicle_id,
  323. PlVehicle.tenant_no == tenant_no,
  324. PlVehicle.del_flag == 0,
  325. )
  326. .first()
  327. )
  328. if not vehicle:
  329. raise HTTPException(status_code=400, detail="车辆不存在")
  330. if not vehicle.carrier_id:
  331. return
  332. carrier = (
  333. db.query(PlCarrier)
  334. .filter(
  335. PlCarrier.id == vehicle.carrier_id,
  336. PlCarrier.tenant_no == tenant_no,
  337. PlCarrier.del_flag == 0,
  338. )
  339. .first()
  340. )
  341. if not carrier:
  342. raise HTTPException(status_code=400, detail="承运商不存在")
  343. assert_carrier_eligible_for_transport(carrier, rearrange=rearrange)
  344. def _ensure_driver_access_token(transport: PlTransport) -> str:
  345. token = (transport.driver_access_token or "").strip()
  346. if not token:
  347. token = secrets.token_urlsafe(32)[:64]
  348. transport.driver_access_token = token
  349. return token
  350. def _rotate_driver_access_token(transport: PlTransport) -> str:
  351. token = secrets.token_urlsafe(32)[:64]
  352. transport.driver_access_token = token
  353. return token
  354. def _is_rearrange(plan: PlPlan, existing_transport: Optional[PlTransport]) -> bool:
  355. """仅计划已正式安排才算「调整运输」。
  356. 创建/编辑表单预选车辆会留下 draft 运输单,计划仍为 pending;
  357. 若把非 cancelled 运输单都当成 rearrange,会把首次正式安排误判为调整。
  358. existing_transport 保留入参以兼容调用方;草稿复用由后续分支处理。
  359. """
  360. _ = existing_transport
  361. return plan.transport_status == "arranged"
  362. def _apply_plan_logistics_from_payload(plan: PlPlan, payload: TransportArrange) -> None:
  363. if payload.pickup_address is not None:
  364. plan.pickup_address = payload.pickup_address
  365. elif payload.start_point is not None:
  366. plan.pickup_address = payload.start_point
  367. if "pickup_lng" in payload.model_fields_set or "pickup_lat" in payload.model_fields_set:
  368. plan.pickup_lng = payload.pickup_lng
  369. plan.pickup_lat = payload.pickup_lat
  370. if payload.pickup_time is not None:
  371. plan.pickup_time = payload.pickup_time
  372. if payload.pickup_contact is not None:
  373. plan.pickup_contact = payload.pickup_contact
  374. if payload.pickup_notes is not None:
  375. plan.pickup_notes = payload.pickup_notes
  376. if payload.destination_address is not None:
  377. plan.destination_address = payload.destination_address
  378. elif payload.end_point is not None:
  379. plan.destination_address = payload.end_point
  380. if "destination_lng" in payload.model_fields_set or "destination_lat" in payload.model_fields_set:
  381. plan.destination_lng = payload.destination_lng
  382. plan.destination_lat = payload.destination_lat
  383. if payload.delivery_time is not None:
  384. plan.delivery_time = payload.delivery_time
  385. if payload.delivery_contact is not None:
  386. plan.delivery_contact = payload.delivery_contact
  387. if payload.destination_notes is not None:
  388. plan.destination_notes = payload.destination_notes
  389. def _resolve_transport_endpoints(plan: PlPlan, payload: TransportArrange) -> tuple[Optional[str], Optional[str]]:
  390. start_point = (
  391. payload.pickup_address
  392. or payload.start_point
  393. or plan.pickup_address
  394. )
  395. end_point = (
  396. payload.destination_address
  397. or payload.end_point
  398. or plan.destination_address
  399. or plan.destination_name
  400. )
  401. return start_point, end_point
  402. def _apply_transport_arrange_fields(
  403. transport: PlTransport,
  404. *,
  405. payload: TransportArrange,
  406. vehicle_info: dict,
  407. driver_info: Optional[dict],
  408. escort_info: Optional[dict],
  409. carrier_name: Optional[str],
  410. start_point: Optional[str],
  411. end_point: Optional[str],
  412. freight_amount: Optional[float],
  413. user_id: str,
  414. ) -> None:
  415. transport.vehicle_id = payload.vehicle_id
  416. transport.license_plate = vehicle_info.get("license_plate")
  417. transport.driver_id = driver_info["id"] if driver_info else None
  418. transport.driver_name = driver_info["name"] if driver_info else None
  419. transport.driver_phone = driver_info["phone"] if driver_info else None
  420. transport.escort_id = escort_info["id"] if escort_info else None
  421. transport.escort_name = escort_info["name"] if escort_info else None
  422. transport.escort_phone = escort_info["phone"] if escort_info else None
  423. transport.carrier_id = payload.carrier_id or vehicle_info.get("carrier_id")
  424. transport.carrier_name = carrier_name
  425. transport.transport_medium = payload.transport_medium
  426. transport.start_point = start_point
  427. transport.end_point = end_point
  428. transport.distance_km = payload.distance_km
  429. transport.unit = payload.unit or ("吨" if payload.freight_type == "by_weight" else "趟")
  430. transport.freight_price = payload.freight_price
  431. transport.freight_amount = freight_amount or 0
  432. transport.freight_type = payload.freight_type
  433. if payload.has_invoice is not None:
  434. transport.has_invoice = payload.has_invoice
  435. if payload.remark is not None:
  436. transport.remark = payload.remark
  437. transport.update_by = user_id
  438. def _build_driver_url(token: str) -> str:
  439. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  440. return f"{origin}/#/public/pl/transport/{token}"
  441. def _build_arrange_result(
  442. transport: PlTransport,
  443. site_records: list[TransportSiteLinkRead],
  444. inventory_records: list[TransportInventoryLinkRead],
  445. io_order: Optional[TransportIoOrderBrief] = None,
  446. contract_qty_reminder: Optional[dict] = None,
  447. ) -> TransportArrangeResult:
  448. token = transport.driver_access_token or ""
  449. result = TransportArrangeResult.model_validate(transport)
  450. result.driver_access_token = token
  451. result.driver_url = _build_driver_url(token) if token else None
  452. result.site_records = site_records
  453. result.inventory_records = inventory_records
  454. result.io_order = io_order
  455. if contract_qty_reminder:
  456. from app.schemas.pl.transport import ContractQtyReminder
  457. result.contract_qty_reminder = ContractQtyReminder.model_validate(contract_qty_reminder)
  458. return result
  459. # =============================================================================
  460. # 运输列表
  461. # =============================================================================
  462. @router.get("/", response_model=TransportListResponse)
  463. def list_transports(
  464. q: Optional[str] = Query(default=None, description="模糊搜索"),
  465. transport_no: Optional[str] = Query(default=None, alias="transportNo", description="运输编号"),
  466. transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"),
  467. approval_status: Optional[str] = Query(default=None, alias="approvalStatus", description="审批状态"),
  468. vehicle_id: Optional[str] = Query(default=None, alias="vehicleId", description="车辆ID"),
  469. driver_id: Optional[str] = Query(default=None, alias="driverId", description="司机ID"),
  470. page: int = Query(default=1, ge=1),
  471. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  472. db: Session = Depends(get_db),
  473. user: DbUserContext = Depends(get_db_current_user),
  474. ) -> TransportListResponse:
  475. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  476. query = db.query(PlTransport).filter(
  477. PlTransport.tenant_no == tenant_no,
  478. PlTransport.del_flag == 0,
  479. )
  480. if q:
  481. query = query.filter(
  482. or_(
  483. PlTransport.transport_no.contains(q),
  484. PlTransport.license_plate.contains(q),
  485. PlTransport.driver_name.contains(q),
  486. )
  487. )
  488. if transport_no:
  489. query = query.filter(PlTransport.transport_no.contains(transport_no))
  490. if transport_status:
  491. query = query.filter(PlTransport.transport_status == transport_status)
  492. if approval_status:
  493. query = query.filter(PlTransport.approval_status == approval_status)
  494. if vehicle_id:
  495. query = query.filter(PlTransport.vehicle_id == vehicle_id)
  496. if driver_id:
  497. query = query.filter(PlTransport.driver_id == driver_id)
  498. query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
  499. total = query.count()
  500. offset = (page - 1) * page_size
  501. transports = query.order_by(PlTransport.create_time.desc()).offset(offset).limit(page_size).all()
  502. return TransportListResponse(total=total, list=transports)
  503. # =============================================================================
  504. # 创建运输
  505. # =============================================================================
  506. TRANSPORT_EXPORT_COLUMNS = [
  507. ExportColumn(title="运输编号", field="transportNo"),
  508. ExportColumn(title="车牌号", field="licensePlate"),
  509. ExportColumn(title="司机", field="driverName"),
  510. ExportColumn(title="承运商", field="carrierName"),
  511. ExportColumn(title="运输介质", field="transportMedium"),
  512. ExportColumn(title="起点", field="startPoint"),
  513. ExportColumn(title="终点", field="endPoint"),
  514. ExportColumn(title="运输状态", field="transportStatus"),
  515. ExportColumn(title="审批状态", field="approvalStatus"),
  516. ExportColumn(title="备注", field="remark"),
  517. ]
  518. class TransportExportBody(BaseModel):
  519. q: Optional[str] = None
  520. transport_no: Optional[str] = Field(default=None, alias="transportNo")
  521. transport_status: Optional[str] = Field(default=None, alias="transportStatus")
  522. approval_status: Optional[str] = Field(default=None, alias="approvalStatus")
  523. vehicle_id: Optional[str] = Field(default=None, alias="vehicleId")
  524. driver_id: Optional[str] = Field(default=None, alias="driverId")
  525. ids: Optional[List[str]] = None
  526. columns: Optional[List[str]] = None
  527. model_config = {"populate_by_name": True}
  528. @router.post("/export")
  529. def export_transports(
  530. body: TransportExportBody,
  531. db: Session = Depends(get_db),
  532. user: DbUserContext = Depends(get_db_current_user),
  533. ):
  534. """导出运输列表(当前筛选全量,或 ids 已选)。"""
  535. try:
  536. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  537. query = db.query(PlTransport).filter(
  538. PlTransport.tenant_no == tenant_no,
  539. PlTransport.del_flag == 0,
  540. )
  541. if body.q:
  542. query = query.filter(
  543. or_(
  544. PlTransport.transport_no.contains(body.q),
  545. PlTransport.license_plate.contains(body.q),
  546. PlTransport.driver_name.contains(body.q),
  547. )
  548. )
  549. if body.transport_no:
  550. query = query.filter(PlTransport.transport_no.contains(body.transport_no))
  551. if body.transport_status:
  552. query = query.filter(PlTransport.transport_status == body.transport_status)
  553. if body.approval_status:
  554. query = query.filter(PlTransport.approval_status == body.approval_status)
  555. if body.vehicle_id:
  556. query = query.filter(PlTransport.vehicle_id == body.vehicle_id)
  557. if body.driver_id:
  558. query = query.filter(PlTransport.driver_id == body.driver_id)
  559. if body.ids:
  560. query = query.filter(PlTransport.id.in_(body.ids))
  561. query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
  562. rows = query.order_by(PlTransport.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all()
  563. items = [TransportRead.model_validate(r).model_dump(by_alias=True) for r in rows]
  564. cols = filter_export_columns(TRANSPORT_EXPORT_COLUMNS, body.columns)
  565. date_str = datetime.now().strftime("%Y%m%d")
  566. return export_rows_to_xlsx_response(
  567. db,
  568. tenant_no=tenant_no,
  569. sheet_title="运输列表",
  570. columns=cols,
  571. rows=items,
  572. filename_stem=f"运输列表_{date_str}",
  573. )
  574. except ExportValidationError as e:
  575. raise HTTPException(status_code=400, detail=str(e)) from e
  576. @router.post("/", response_model=TransportRead, status_code=status.HTTP_201_CREATED)
  577. def create_transport(
  578. payload: TransportCreate,
  579. db: Session = Depends(get_db),
  580. user: DbUserContext = Depends(get_db_current_user),
  581. ) -> TransportRead:
  582. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  583. transport_no = _generate_transport_no(db)
  584. transport = PlTransport(
  585. id=generate_uuid(),
  586. tenant_no=tenant_no,
  587. transport_no=transport_no,
  588. vehicle_id=payload.vehicle_id,
  589. license_plate=payload.license_plate,
  590. driver_id=payload.driver_id,
  591. driver_name=payload.driver_name,
  592. driver_phone=payload.driver_phone,
  593. escort_id=payload.escort_id,
  594. escort_name=payload.escort_name,
  595. escort_phone=payload.escort_phone,
  596. carrier_id=payload.carrier_id,
  597. carrier_name=payload.carrier_name,
  598. transport_medium=payload.transport_medium,
  599. start_point=payload.start_point,
  600. end_point=payload.end_point,
  601. distance_km=payload.distance_km,
  602. unit=payload.unit,
  603. freight_price=payload.freight_price,
  604. freight_amount=payload.freight_amount,
  605. freight_type=payload.freight_type,
  606. has_invoice=payload.has_invoice,
  607. qr_expire_date=payload.qr_expire_date,
  608. transport_status=payload.transport_status or "draft",
  609. approval_status=payload.approval_status or "none",
  610. remark=payload.remark,
  611. create_by=user.id,
  612. create_dept_id=user.dept_id,
  613. )
  614. db.add(transport)
  615. # 关联计划
  616. if payload.plan_ids:
  617. for pid in payload.plan_ids:
  618. pt = PlPlanTransport(
  619. id=generate_uuid(),
  620. tenant_no=tenant_no,
  621. plan_id=pid,
  622. transport_id=transport.id,
  623. create_by=user.id,
  624. create_dept_id=user.dept_id,
  625. )
  626. db.add(pt)
  627. # 更新计划关联的运输信息
  628. plan = db.query(PlPlan).filter(PlPlan.id == pid, PlPlan.del_flag == 0).first()
  629. if plan:
  630. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  631. plan.transport_id = transport.id
  632. plan.transport_no = transport_no
  633. plan.vehicle_id = transport.vehicle_id
  634. plan.license_plate = transport.license_plate
  635. plan.driver_id = transport.driver_id
  636. plan.driver_name = transport.driver_name
  637. plan.escort_id = transport.escort_id
  638. plan.escort_name = transport.escort_name
  639. plan.escort_phone = transport.escort_phone
  640. if transport.vehicle_id:
  641. snapshot = build_transport_resource_snapshot(
  642. db,
  643. tenant_no,
  644. transport.vehicle_id,
  645. selected_driver_id=transport.driver_id,
  646. selected_escort_id=transport.escort_id,
  647. )
  648. apply_resource_snapshot_json(
  649. plan=plan, transport=transport, snapshot=snapshot
  650. )
  651. db.commit()
  652. db.refresh(transport)
  653. return transport
  654. # =============================================================================
  655. # 运输安排
  656. # =============================================================================
  657. @router.post("/arrange/{plan_id}", response_model=TransportArrangeResult, status_code=status.HTTP_201_CREATED)
  658. def arrange_transport(
  659. plan_id: str,
  660. payload: TransportArrange,
  661. db: Session = Depends(get_db),
  662. user: DbUserContext = Depends(get_db_current_user),
  663. ) -> TransportArrangeResult:
  664. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  665. # 获取计划
  666. plan = db.query(PlPlan).filter(
  667. PlPlan.id == plan_id,
  668. PlPlan.tenant_no == tenant_no,
  669. PlPlan.del_flag == 0,
  670. ).first()
  671. if not plan:
  672. raise HTTPException(status_code=404, detail="计划不存在")
  673. assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
  674. existing_transport = None
  675. if plan.transport_id:
  676. existing_transport = db.query(PlTransport).filter(
  677. PlTransport.id == plan.transport_id,
  678. PlTransport.tenant_no == tenant_no,
  679. PlTransport.del_flag == 0,
  680. ).first()
  681. is_rearrange = _is_rearrange(plan, existing_transport)
  682. if is_rearrange and existing_transport and existing_transport.vehicle_id:
  683. _assert_vehicle_carrier_eligible(
  684. db,
  685. tenant_no,
  686. existing_transport.vehicle_id,
  687. rearrange=True,
  688. )
  689. _assert_vehicle_carrier_eligible(db, tenant_no, payload.vehicle_id, rearrange=False)
  690. # 获取车辆信息
  691. vehicle_info = _get_vehicle_info(db, payload.vehicle_id, tenant_no)
  692. # 获取司机信息(可选;driverId 为人员 pl_personnel.id)
  693. driver_info = None
  694. if payload.driver_id:
  695. driver_info = _resolve_driver_info(db, tenant_no, payload.driver_id)
  696. if not driver_info:
  697. raise HTTPException(status_code=400, detail="司机不存在")
  698. # 获取押运员信息(可选)
  699. escort_info = None
  700. if payload.escort_id:
  701. escort_info = _resolve_driver_info(db, tenant_no, payload.escort_id)
  702. if not escort_info:
  703. raise HTTPException(status_code=400, detail="押运员不存在")
  704. # 产品行(易制毒安排后进待确认,确认前不生成链接/地磅)
  705. products = db.query(PlPlanProduct).filter(
  706. PlPlanProduct.plan_id == plan_id,
  707. PlPlanProduct.del_flag == 0,
  708. ).all()
  709. needs_toxic_confirm = plan_has_toxic_products(db, tenant_no, products)
  710. was_effective = (plan.follow_status or "").strip() == "arranged"
  711. from app.services.pl_contract_plan_qty import (
  712. assert_contract_qty_for_arrange,
  713. build_contract_qty_reminder_payload,
  714. )
  715. contract_qty_reminders = assert_contract_qty_for_arrange(
  716. db, tenant_no=tenant_no, plan_id=plan_id
  717. )
  718. contract_qty_reminder_payload = build_contract_qty_reminder_payload(contract_qty_reminders)
  719. # 计算运费
  720. freight_amount = payload.freight_amount
  721. if payload.freight_type == "by_weight" and payload.freight_price:
  722. total_qty = sum(
  723. float(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0)
  724. for p in products
  725. )
  726. freight_amount = total_qty * float(payload.freight_price)
  727. carrier_name = vehicle_info.get("carrier_name")
  728. if payload.carrier_id:
  729. carrier = db.query(PlCarrier).filter(PlCarrier.id == payload.carrier_id).first()
  730. if carrier:
  731. carrier_name = carrier.carrier_name
  732. _apply_plan_logistics_from_payload(plan, payload)
  733. start_point, end_point = _resolve_transport_endpoints(plan, payload)
  734. # 地磅关联改为按段 upsert,不再在调整前整批 revoke 再建
  735. if existing_transport and existing_transport.transport_status != "cancelled":
  736. transport = existing_transport
  737. _apply_transport_arrange_fields(
  738. transport,
  739. payload=payload,
  740. vehicle_info=vehicle_info,
  741. driver_info=driver_info,
  742. escort_info=escort_info,
  743. carrier_name=carrier_name,
  744. start_point=start_point,
  745. end_point=end_point,
  746. freight_amount=freight_amount,
  747. user_id=user.id,
  748. )
  749. transport_no = transport.transport_no
  750. else:
  751. transport_no = _generate_transport_no(db)
  752. transport = PlTransport(
  753. id=generate_uuid(),
  754. tenant_no=tenant_no,
  755. transport_no=transport_no,
  756. transport_status="draft",
  757. approval_status="none",
  758. create_by=user.id,
  759. create_dept_id=user.dept_id,
  760. )
  761. db.add(transport)
  762. _apply_transport_arrange_fields(
  763. transport,
  764. payload=payload,
  765. vehicle_info=vehicle_info,
  766. driver_info=driver_info,
  767. escort_info=escort_info,
  768. carrier_name=carrier_name,
  769. start_point=start_point,
  770. end_point=end_point,
  771. freight_amount=freight_amount,
  772. user_id=user.id,
  773. )
  774. pt = PlPlanTransport(
  775. id=generate_uuid(),
  776. tenant_no=tenant_no,
  777. plan_id=plan_id,
  778. transport_id=transport.id,
  779. create_by=user.id,
  780. create_dept_id=user.dept_id,
  781. )
  782. db.add(pt)
  783. plan.transport_id = transport.id
  784. plan.transport_no = transport_no
  785. plan.vehicle_id = transport.vehicle_id
  786. plan.license_plate = transport.license_plate
  787. plan.driver_id = transport.driver_id
  788. plan.driver_name = transport.driver_name
  789. plan.escort_id = transport.escort_id
  790. plan.escort_name = transport.escort_name
  791. plan.escort_phone = transport.escort_phone
  792. if transport.vehicle_id:
  793. snapshot = build_transport_resource_snapshot(
  794. db,
  795. tenant_no,
  796. transport.vehicle_id,
  797. selected_driver_id=transport.driver_id,
  798. selected_escort_id=transport.escort_id,
  799. )
  800. apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
  801. plan.transport_status = "arranged"
  802. plan.freight_amount = freight_amount or 0
  803. plan.update_by = user.id
  804. site_records: list[TransportSiteLinkRead] = []
  805. inventory_records: list[TransportInventoryLinkRead] = []
  806. io_order_brief: Optional[TransportIoOrderBrief] = None
  807. if needs_toxic_confirm:
  808. # 已生效后再调整运输:撤销链接/地磅后回到待确认
  809. if was_effective:
  810. revoke_transport_effective_effects(
  811. db,
  812. tenant_no=tenant_no,
  813. plan=plan,
  814. transport=transport,
  815. user_id=user.id,
  816. )
  817. clear_hazard_cert_ready(plan)
  818. plan.follow_status = "arranged_pending_confirm"
  819. db.commit()
  820. db.refresh(transport)
  821. return _build_arrange_result(
  822. transport,
  823. site_records,
  824. inventory_records,
  825. io_order_brief,
  826. contract_qty_reminder=contract_qty_reminder_payload,
  827. )
  828. plan.follow_status = "arranged"
  829. # 安排运输时:关联且仍为「已审批」的合同自动开始执行
  830. from app.services.pl_contract_plan_qty import auto_start_execute_approved_contracts
  831. auto_start_execute_approved_contracts(
  832. db, tenant_no=tenant_no, plan=plan, user_id=user.id
  833. )
  834. needs_site_link = plan_requires_site_link(db, tenant_no=tenant_no, plan=plan)
  835. if is_rearrange:
  836. _ensure_driver_access_token(transport)
  837. if needs_site_link and plan_has_load_stop(db, tenant_no, plan.id):
  838. ensure_transport_site_token(transport)
  839. elif not needs_site_link:
  840. transport.site_access_token = None
  841. if plan.plan_type in ("inbound", "outbound"):
  842. void_io_orders_for_plan(
  843. db,
  844. tenant_no=tenant_no,
  845. plan_id=plan.id,
  846. user_id=user.id,
  847. )
  848. else:
  849. _ensure_driver_access_token(transport)
  850. if needs_site_link:
  851. use_multi_stop_site = plan_has_load_stop(db, tenant_no, plan.id)
  852. if use_multi_stop_site:
  853. site_token = ensure_transport_site_token(transport)
  854. site_url = build_transport_site_url(site_token)
  855. site_records = [
  856. TransportSiteLinkRead(
  857. plan_product_id="",
  858. product_name=plan.plan_no or transport.transport_no,
  859. site_link_id=transport.id,
  860. plan_no=plan.plan_no or "",
  861. business_type="按段现场",
  862. site_access_token=site_token,
  863. site_url=site_url,
  864. )
  865. ]
  866. elif plan.plan_type in ("inbound", "outbound"):
  867. try:
  868. if is_rearrange:
  869. # 调整运输:先作废旧出入库单(现场链接随之失效),再重建
  870. void_io_orders_for_plan(
  871. db,
  872. tenant_no=tenant_no,
  873. plan_id=plan.id,
  874. user_id=user.id,
  875. )
  876. io_result = create_io_order_for_transport(
  877. db,
  878. tenant_no=tenant_no,
  879. plan=plan,
  880. products=products,
  881. transport=transport,
  882. user_id=user.id,
  883. dept_id=user.dept_id or "",
  884. after_dibang=False,
  885. )
  886. if io_result:
  887. io_order_brief = TransportIoOrderBrief(
  888. kind=io_result.kind,
  889. order_id=io_result.order_id,
  890. order_no=io_result.order_no,
  891. site_access_token=io_result.site_access_token,
  892. site_url=io_result.site_url,
  893. status="in_progress",
  894. )
  895. # 兼容旧前端:合成单条 siteRecords 供复制现场链接
  896. site_records = [
  897. TransportSiteLinkRead(
  898. plan_product_id="",
  899. product_name=io_result.order_no,
  900. site_link_id=io_result.order_id,
  901. plan_no=plan.plan_no or "",
  902. business_type="入库" if io_result.kind == "inbound" else "出库",
  903. site_access_token=io_result.site_access_token,
  904. site_url=io_result.site_url,
  905. )
  906. ]
  907. except ValueError as exc:
  908. db.rollback()
  909. raise HTTPException(status_code=400, detail=str(exc)) from exc
  910. use_multi_stop_dibang = plan_has_load_stop(db, tenant_no, plan.id)
  911. stop_dibang_options: list[StopDibangOption] = []
  912. if use_multi_stop_dibang and payload.stop_dibang_options:
  913. stop_dibang_options = [
  914. StopDibangOption(
  915. stop_type=item.stop_type, # type: ignore[arg-type]
  916. stop_id=item.stop_id,
  917. generate_dibang=item.generate_dibang,
  918. )
  919. for item in payload.stop_dibang_options
  920. ]
  921. should_sync_legacy_dibang = (
  922. not use_multi_stop_dibang
  923. and plan.plan_type in ("inbound", "outbound")
  924. and payload.generate_dibang_plan
  925. )
  926. # 有 stop 选项即同步:全 false 也要进 upsert,才能取消勾选 / 已过磅阻断
  927. should_sync_stop_dibang = use_multi_stop_dibang and bool(stop_dibang_options)
  928. if should_sync_stop_dibang or should_sync_legacy_dibang:
  929. try:
  930. if should_sync_stop_dibang:
  931. link_results = sync_dibang_for_plan_stops(
  932. db,
  933. tenant_no=tenant_no,
  934. plan=plan,
  935. transport=transport,
  936. stop_options=stop_dibang_options,
  937. user_id=user.id,
  938. dept_id=user.dept_id or "",
  939. )
  940. else:
  941. link_results = sync_dibang_for_transport(
  942. db,
  943. tenant_no=tenant_no,
  944. plan=plan,
  945. products=products,
  946. transport=transport,
  947. user_id=user.id,
  948. dept_id=user.dept_id or "",
  949. )
  950. inv_ids = [
  951. (item.db_inventory_id or "").strip()
  952. for item in link_results
  953. if (item.db_inventory_id or "").strip()
  954. ]
  955. inv_by_id: dict = {}
  956. if inv_ids:
  957. from app.models.db_models import DbInboundOutbound
  958. inv_rows = (
  959. db.query(DbInboundOutbound)
  960. .filter(
  961. DbInboundOutbound.id.in_(inv_ids),
  962. DbInboundOutbound.tenant_no == tenant_no,
  963. DbInboundOutbound.del_flag == 0,
  964. )
  965. .all()
  966. )
  967. inv_by_id = {row.id: row for row in inv_rows}
  968. inventory_records = [
  969. build_transport_inventory_link_read(
  970. db,
  971. plan_product_id=item.plan_product_id,
  972. product_name=item.product_name,
  973. db_purchase_plan_id=item.db_purchase_plan_id,
  974. db_inventory_id=item.db_inventory_id,
  975. batch_no=item.batch_no,
  976. plan_no=item.plan_no,
  977. business_type=(
  978. inv_by_id[item.db_inventory_id].business_type
  979. if item.db_inventory_id in inv_by_id
  980. else None
  981. ),
  982. partner_company=(
  983. inv_by_id[item.db_inventory_id].partner_company
  984. if item.db_inventory_id in inv_by_id
  985. else None
  986. ),
  987. )
  988. for item in link_results
  989. ]
  990. except ValueError as exc:
  991. db.rollback()
  992. raise HTTPException(status_code=400, detail=str(exc)) from exc
  993. apply_dibang_prefill_to_order(
  994. db, tenant_no=tenant_no, plan=plan, transport=transport
  995. )
  996. db.commit()
  997. db.refresh(transport)
  998. return _build_arrange_result(
  999. transport,
  1000. site_records,
  1001. inventory_records,
  1002. io_order_brief,
  1003. contract_qty_reminder=contract_qty_reminder_payload,
  1004. )
  1005. # =============================================================================
  1006. # 获取运输详情
  1007. # =============================================================================
  1008. @router.get("/{id}", response_model=TransportRead)
  1009. def get_transport(
  1010. id: str,
  1011. db: Session = Depends(get_db),
  1012. user: DbUserContext = Depends(get_db_current_user),
  1013. ) -> TransportRead:
  1014. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1015. transport = _get_transport(db, tenant_no, id, user)
  1016. return _transport_to_read(db, transport)
  1017. # =============================================================================
  1018. # 生成二维码
  1019. # =============================================================================
  1020. class QrCodeResponse(BaseModel):
  1021. transport_id: str = Field(alias="transportId")
  1022. transport_no: str = Field(alias="transportNo")
  1023. qr_code: str = Field(alias="qrCode")
  1024. expire_date: Optional[date] = Field(default=None, alias="expireDate")
  1025. model_config = {"from_attributes": True, "populate_by_name": True}
  1026. @router.get("/{id}/qrcode", response_model=QrCodeResponse)
  1027. def generate_qrcode(
  1028. id: str,
  1029. expire_days: int = Query(default=30, ge=1, le=365, description="有效期天数"),
  1030. db: Session = Depends(get_db),
  1031. user: DbUserContext = Depends(get_db_current_user),
  1032. ) -> QrCodeResponse:
  1033. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1034. transport = _get_transport(db, tenant_no, id, user)
  1035. from datetime import timedelta
  1036. expire_date = date.today() + timedelta(days=expire_days)
  1037. # 生成二维码内容(URL scheme)
  1038. qr_content = f"/transport/scan/{transport.id}?code={transport.transport_no}"
  1039. transport.qr_code = qr_content
  1040. transport.qr_expire_date = expire_date
  1041. db.commit()
  1042. return QrCodeResponse(
  1043. transport_id=transport.id,
  1044. transport_no=transport.transport_no,
  1045. qr_code=qr_content,
  1046. expire_date=expire_date,
  1047. )
  1048. # =============================================================================
  1049. # 司机扫码回传(无需登录)
  1050. # =============================================================================
  1051. @router.post("/scan/feedback")
  1052. def transport_scan_feedback(
  1053. payload: TransportFeedback,
  1054. db: Session = Depends(get_db),
  1055. ) -> dict:
  1056. """司机扫码回传,无需登录"""
  1057. transport = (
  1058. db.query(PlTransport)
  1059. .filter(
  1060. PlTransport.transport_no == payload.transport_no,
  1061. PlTransport.del_flag == 0,
  1062. )
  1063. .first()
  1064. )
  1065. if not transport:
  1066. raise HTTPException(status_code=404, detail="运输单不存在")
  1067. if payload.verify_code and payload.verify_code != transport.transport_no:
  1068. raise HTTPException(status_code=400, detail="验证码错误")
  1069. # 更新回传信息
  1070. if payload.feedback_qty:
  1071. transport.feedback_qty = payload.feedback_qty
  1072. if payload.feedback_images:
  1073. transport.feedback_images = json.dumps(payload.feedback_images)
  1074. if payload.feedback_remark:
  1075. transport.feedback_remark = payload.feedback_remark
  1076. transport.feedback_time = datetime.now()
  1077. db.commit()
  1078. return {"success": True, "message": "回传成功"}
  1079. # =============================================================================
  1080. # 提交审批
  1081. # =============================================================================
  1082. @router.post("/{id}/submit-approval", response_model=TransportRead)
  1083. def submit_approval(
  1084. id: str,
  1085. payload: ApprovalSubmit,
  1086. db: Session = Depends(get_db),
  1087. user: DbUserContext = Depends(get_db_current_user),
  1088. ) -> TransportRead:
  1089. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1090. transport = _get_transport(db, tenant_no, id, user)
  1091. if transport.approval_status not in ("none", "rejected"):
  1092. raise HTTPException(status_code=400, detail="当前状态不允许提交审批")
  1093. # 创建审批记录
  1094. for idx, approver_id in enumerate(payload.approver_ids):
  1095. from app.models.models import SysUser
  1096. approver = db.query(SysUser).filter(SysUser.id == approver_id).first()
  1097. if not approver:
  1098. raise HTTPException(status_code=400, detail=f"审批人不存在: {approver_id}")
  1099. approval = PlTransportApproval(
  1100. id=generate_uuid(),
  1101. tenant_no=tenant_no,
  1102. transport_id=id,
  1103. approver_id=approver_id,
  1104. approver_name=approver.nick_name,
  1105. approval_order=idx + 1,
  1106. status="pending",
  1107. create_by=user.id,
  1108. create_dept_id=user.dept_id,
  1109. )
  1110. db.add(approval)
  1111. transport.approval_status = "pending"
  1112. db.commit()
  1113. db.refresh(transport)
  1114. return transport
  1115. # =============================================================================
  1116. # 审批
  1117. # =============================================================================
  1118. @router.post("/approval/{id}", response_model=ApprovalRecordRead)
  1119. def process_approval(
  1120. id: str,
  1121. payload: ApprovalRequest,
  1122. db: Session = Depends(get_db),
  1123. user: DbUserContext = Depends(get_db_current_user),
  1124. ) -> ApprovalRecordRead:
  1125. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1126. approval = (
  1127. db.query(PlTransportApproval)
  1128. .filter(
  1129. PlTransportApproval.id == id,
  1130. PlTransportApproval.tenant_no == tenant_no,
  1131. PlTransportApproval.del_flag == 0,
  1132. )
  1133. .first()
  1134. )
  1135. if not approval:
  1136. raise HTTPException(status_code=404, detail="审批记录不存在")
  1137. if approval.status != "pending":
  1138. raise HTTPException(status_code=400, detail="该审批已处理")
  1139. # 更新审批状态
  1140. from datetime import datetime
  1141. approval.status = "approved" if payload.action == "approve" else "rejected"
  1142. approval.comment = payload.comment
  1143. approval.approval_time = datetime.now()
  1144. # 更新运输审批状态
  1145. transport = db.query(PlTransport).filter(PlTransport.id == approval.transport_id).first()
  1146. if transport:
  1147. if payload.action == "approve":
  1148. # 检查是否所有审批人都通过了
  1149. pending = (
  1150. db.query(PlTransportApproval)
  1151. .filter(
  1152. PlTransportApproval.transport_id == approval.transport_id,
  1153. PlTransportApproval.status == "pending",
  1154. PlTransportApproval.del_flag == 0,
  1155. )
  1156. .count()
  1157. )
  1158. if pending == 0:
  1159. transport.approval_status = "approved"
  1160. transport.transport_status = "pending"
  1161. else:
  1162. transport.approval_status = "rejected"
  1163. transport.transport_status = "draft"
  1164. db.commit()
  1165. db.refresh(approval)
  1166. return approval
  1167. # =============================================================================
  1168. # 获取运输的审批记录
  1169. # =============================================================================
  1170. @router.get("/{id}/approvals", response_model=list[ApprovalRecordRead])
  1171. def get_transport_approvals(
  1172. id: str,
  1173. db: Session = Depends(get_db),
  1174. user: DbUserContext = Depends(get_db_current_user),
  1175. ) -> list[ApprovalRecordRead]:
  1176. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  1177. approvals = (
  1178. db.query(PlTransportApproval)
  1179. .filter(
  1180. PlTransportApproval.transport_id == id,
  1181. PlTransportApproval.tenant_no == tenant_no,
  1182. PlTransportApproval.del_flag == 0,
  1183. )
  1184. .order_by(PlTransportApproval.approval_order)
  1185. .all()
  1186. )
  1187. return approvals
  1188. # =============================================================================
  1189. # 辅助函数
  1190. # =============================================================================
  1191. def _get_transport(db: Session, tenant_no: str, transport_id: str, user: DbUserContext) -> PlTransport:
  1192. transport = (
  1193. db.query(PlTransport)
  1194. .filter(
  1195. PlTransport.id == transport_id,
  1196. PlTransport.tenant_no == tenant_no,
  1197. PlTransport.del_flag == 0,
  1198. )
  1199. .first()
  1200. )
  1201. if not transport:
  1202. raise HTTPException(status_code=404, detail="运输不存在")
  1203. assert_pl_row_in_scope(db, transport, PlTransport, user, tenant_no)
  1204. return transport