pl_plan_service.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386
  1. """运输计划业务逻辑"""
  2. import json
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from typing import Optional, Union
  6. from fastapi import HTTPException
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.models.pl_models import (
  10. PlCarrier,
  11. PlFreightSettlement,
  12. PlFreightSettlementTransport,
  13. PlPersonnel,
  14. PlPlan,
  15. PlPlanChange,
  16. PlPlanProduct,
  17. PlPlanTransport,
  18. PlTransport,
  19. PlTransportSiteLink,
  20. PlVehicle,
  21. PlVehiclePersonnel,
  22. )
  23. from app.schemas.pl.plan import (
  24. PlanArchiveFreightBlock,
  25. PlanArchiveProductItem,
  26. PlanCreateFull,
  27. PlanDriverFeedbackUpdateBody,
  28. PlanProductCreate,
  29. PlanSiteFeedbackUpdateBody,
  30. PlanTransportCreateBlock,
  31. )
  32. from app.services.pl_brand_archive import persist_plan_product_brand_qtys, validate_brand_qty_rows
  33. from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
  34. from app.services.pl_freight_settlement_service import sync_draft_settlement_freight_for_transport
  35. from app.services.pl_transport_resource_snapshot_service import (
  36. apply_resource_snapshot_json,
  37. build_transport_resource_snapshot,
  38. clear_resource_snapshot,
  39. )
  40. def determine_plan_type(supplier_name: Optional[str], destination_name: Optional[str]) -> str:
  41. if destination_name and ("安庆山峡" in destination_name or "安徽云栋" in destination_name):
  42. return "inbound"
  43. if supplier_name and ("安庆山峡" in supplier_name or "安徽云栋" in supplier_name):
  44. return "outbound"
  45. return "direct"
  46. def is_ton_product_unit(unit: Optional[str]) -> bool:
  47. code = (unit or "").strip()
  48. if not code:
  49. return True
  50. return code in ("dun", "吨")
  51. def _product_quantity(prod: PlanProductCreate) -> Decimal:
  52. if prod.quantity is not None:
  53. return Decimal(str(prod.quantity))
  54. if prod.inbound_estimate_qty is not None:
  55. return Decimal(str(prod.inbound_estimate_qty))
  56. if prod.outbound_estimate_qty is not None:
  57. return Decimal(str(prod.outbound_estimate_qty))
  58. return Decimal("0")
  59. def _product_create_actual_qty(prod: PlanProductCreate) -> Decimal:
  60. if prod.actual_qty is not None:
  61. return Decimal(str(prod.actual_qty))
  62. return Decimal("0")
  63. def _plan_product_actual_qty(prod: PlPlanProduct) -> Decimal:
  64. if prod.actual_qty is not None:
  65. return Decimal(str(prod.actual_qty))
  66. return Decimal("0")
  67. def sum_product_actual_quantities(products: list[PlanProductCreate]) -> Decimal:
  68. return sum(_product_create_actual_qty(p) for p in products)
  69. def sum_plan_product_actual_quantities(products: list[PlPlanProduct]) -> Decimal:
  70. return sum(_plan_product_actual_qty(p) for p in products)
  71. def _estimate_qty_fields(plan_type: str, qty: Optional[Decimal]) -> dict:
  72. if qty is None:
  73. return {}
  74. q = float(qty)
  75. if plan_type == "inbound":
  76. return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
  77. if plan_type == "outbound":
  78. return {"outbound_estimate_qty": q, "inbound_estimate_qty": q}
  79. return {"inbound_estimate_qty": q, "outbound_estimate_qty": q}
  80. def build_plan_product(
  81. *,
  82. tenant_no: str,
  83. plan_id: str,
  84. plan_type: str,
  85. prod: PlanProductCreate,
  86. order_num: int,
  87. user_id: str,
  88. dept_id: str,
  89. ) -> PlPlanProduct:
  90. qty = _product_quantity(prod)
  91. estimates = _estimate_qty_fields(plan_type, qty if qty else None)
  92. tax_price = prod.tax_price
  93. return PlPlanProduct(
  94. id=generate_uuid(),
  95. tenant_no=tenant_no,
  96. plan_id=plan_id,
  97. product_id=prod.product_id,
  98. product_no=prod.product_no,
  99. product_name=prod.product_name,
  100. brand_mode=prod.brand_mode,
  101. brand_id=prod.brand_id,
  102. brand_name=prod.brand_name,
  103. model=prod.model,
  104. unit=prod.unit or "吨",
  105. category_name=prod.category_name,
  106. package_spec=prod.package_spec,
  107. delivery_method=prod.delivery_method,
  108. quantity=qty if qty else None,
  109. actual_qty=Decimal(str(prod.actual_qty)) if prod.actual_qty is not None else None,
  110. tax_price=tax_price,
  111. tax_amount=prod.tax_amount,
  112. tax_subtotal=prod.tax_subtotal,
  113. is_explosive=prod.is_explosive,
  114. is_toxic=prod.is_toxic,
  115. purchase_cert_url=prod.purchase_cert_url,
  116. transport_cert_url=prod.transport_cert_url,
  117. legal_use_cert_url=prod.legal_use_cert_url,
  118. inbound_price=tax_price,
  119. outbound_price=tax_price,
  120. inbound_amount=prod.tax_subtotal,
  121. outbound_amount=prod.tax_subtotal,
  122. remark=prod.remark,
  123. order_num=order_num,
  124. create_by=user_id,
  125. create_dept_id=dept_id,
  126. **estimates,
  127. )
  128. def _apply_vehicle_snapshot_to_plan_transport(
  129. db: Session,
  130. *,
  131. tenant_no: str,
  132. plan: PlPlan,
  133. transport: Optional[PlTransport],
  134. vehicle: PlVehicle,
  135. driver_id: str,
  136. ) -> None:
  137. snapshot = build_transport_resource_snapshot(
  138. db,
  139. tenant_no,
  140. vehicle.id,
  141. selected_driver_id=driver_id,
  142. )
  143. apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
  144. def _resolve_driver(
  145. db: Session,
  146. tenant_no: str,
  147. vehicle_id: str,
  148. driver_id: Optional[str],
  149. ) -> tuple[str, str, Optional[str]]:
  150. if driver_id:
  151. personnel = (
  152. db.query(PlPersonnel)
  153. .filter(
  154. PlPersonnel.id == driver_id,
  155. PlPersonnel.tenant_no == tenant_no,
  156. PlPersonnel.del_flag == 0,
  157. )
  158. .first()
  159. )
  160. if personnel:
  161. return personnel.id, personnel.personnel_name, personnel.phone_no
  162. default_link = (
  163. db.query(PlVehiclePersonnel)
  164. .filter(
  165. PlVehiclePersonnel.vehicle_id == vehicle_id,
  166. PlVehiclePersonnel.tenant_no == tenant_no,
  167. PlVehiclePersonnel.del_flag == 0,
  168. PlVehiclePersonnel.role == "driver",
  169. )
  170. .order_by(PlVehiclePersonnel.is_default.desc(), PlVehiclePersonnel.order_num.asc())
  171. .first()
  172. )
  173. if default_link:
  174. personnel = (
  175. db.query(PlPersonnel)
  176. .filter(
  177. PlPersonnel.id == default_link.personnel_id,
  178. PlPersonnel.del_flag == 0,
  179. )
  180. .first()
  181. )
  182. if personnel:
  183. return personnel.id, personnel.personnel_name, personnel.phone_no
  184. raise HTTPException(status_code=400, detail="请选择司机或确保车辆已关联默认司机")
  185. def _calc_freight_amount(
  186. freight_type: str,
  187. products: list[PlPlanProduct],
  188. transport_qty: Optional[Decimal],
  189. freight_price: Optional[Decimal],
  190. freight_amount: Optional[Decimal],
  191. ) -> Decimal:
  192. if freight_type == "by_trip":
  193. return Decimal(str(freight_amount or 0))
  194. qty = transport_qty
  195. if qty is None:
  196. qty = sum_plan_product_actual_quantities(products)
  197. if freight_price is not None:
  198. computed = qty * Decimal(str(freight_price))
  199. if freight_amount is not None:
  200. return Decimal(str(freight_amount))
  201. return computed
  202. return Decimal(str(freight_amount or 0))
  203. def fetch_hazard_flags_map(
  204. db: Session,
  205. tenant_no: str,
  206. product_ids: Optional[list[str]] = None,
  207. ) -> dict[str, tuple[int, int]]:
  208. """批量从 CRM 产品主数据读取易制毒/易制爆标识。"""
  209. from app.models.crm_models import CrmProduct
  210. ids = [str(pid) for pid in (product_ids or []) if pid]
  211. if not ids:
  212. return {}
  213. rows = (
  214. db.query(CrmProduct.id, CrmProduct.is_toxic, CrmProduct.is_explosive)
  215. .filter(
  216. CrmProduct.id.in_(ids),
  217. CrmProduct.tenant_no == tenant_no,
  218. CrmProduct.del_flag == 0,
  219. )
  220. .all()
  221. )
  222. return {
  223. str(row.id): (int(row.is_toxic or 0), int(row.is_explosive or 0))
  224. for row in rows
  225. }
  226. def resolve_hazard_flags_from_crm(
  227. db: Session,
  228. tenant_no: str,
  229. product_id: Optional[str],
  230. *,
  231. hazard_map: Optional[dict[str, tuple[int, int]]] = None,
  232. ) -> tuple[int, int]:
  233. """从 CRM 产品主数据读取易制毒/易制爆标识(不读计划产品快照)。"""
  234. if not product_id:
  235. return 0, 0
  236. if hazard_map is not None:
  237. return hazard_map.get(str(product_id), (0, 0))
  238. from app.models.crm_models import CrmProduct
  239. row = (
  240. db.query(CrmProduct)
  241. .filter(
  242. CrmProduct.id == product_id,
  243. CrmProduct.tenant_no == tenant_no,
  244. CrmProduct.del_flag == 0,
  245. )
  246. .first()
  247. )
  248. if not row:
  249. return 0, 0
  250. return int(row.is_toxic or 0), int(row.is_explosive or 0)
  251. def plan_has_hazard_products(
  252. db: Session,
  253. tenant_no: str,
  254. products: list[PlPlanProduct],
  255. ) -> bool:
  256. return bool(collect_hazard_product_ids(db, tenant_no, products))
  257. def collect_hazard_product_ids(
  258. db: Session,
  259. tenant_no: str,
  260. products: list[Union[PlPlanProduct, PlanProductCreate]],
  261. ) -> frozenset[str]:
  262. product_ids = [str(p.product_id) for p in products if getattr(p, "product_id", None)]
  263. if not product_ids:
  264. return frozenset()
  265. hazard_map = fetch_hazard_flags_map(db, tenant_no, product_ids)
  266. hazard_ids: set[str] = set()
  267. for prod in products:
  268. product_id = getattr(prod, "product_id", None)
  269. if not product_id:
  270. continue
  271. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  272. db,
  273. tenant_no,
  274. product_id,
  275. hazard_map=hazard_map,
  276. )
  277. if is_toxic or is_explosive:
  278. hazard_ids.add(str(product_id))
  279. return frozenset(hazard_ids)
  280. def clear_hazard_cert_ready(plan: PlPlan) -> None:
  281. plan.hazard_cert_ready = 0
  282. plan.hazard_cert_ready_by = None
  283. plan.hazard_cert_ready_time = None
  284. def _required_hazard_cert_fields(is_toxic: int, is_explosive: int) -> list[str]:
  285. fields: list[str] = []
  286. if is_toxic:
  287. fields.extend(["purchase_cert_url", "transport_cert_url"])
  288. if is_explosive:
  289. fields.append("legal_use_cert_url")
  290. return fields
  291. def _is_hazard_product_cert_complete(
  292. product: PlPlanProduct,
  293. *,
  294. is_toxic: int,
  295. is_explosive: int,
  296. ) -> bool:
  297. for field in _required_hazard_cert_fields(is_toxic, is_explosive):
  298. if not str(getattr(product, field, "") or "").strip():
  299. return False
  300. return True
  301. def all_hazard_products_cert_complete(
  302. db: Session,
  303. tenant_no: str,
  304. products: list[PlPlanProduct],
  305. ) -> bool:
  306. """计划内所有危化品产品的所需证书槽位均已上传。"""
  307. if not products:
  308. return True
  309. hazard_map = fetch_hazard_flags_map(
  310. db,
  311. tenant_no,
  312. [product.product_id for product in products if product.product_id],
  313. )
  314. has_hazard = False
  315. for product in products:
  316. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  317. db,
  318. tenant_no,
  319. product.product_id,
  320. hazard_map=hazard_map,
  321. )
  322. if not (is_toxic or is_explosive):
  323. continue
  324. has_hazard = True
  325. if not _is_hazard_product_cert_complete(
  326. product,
  327. is_toxic=is_toxic,
  328. is_explosive=is_explosive,
  329. ):
  330. return False
  331. return has_hazard
  332. def maybe_auto_set_hazard_cert_ready(
  333. db: Session,
  334. *,
  335. tenant_no: str,
  336. plan: PlPlan,
  337. products: list[PlPlanProduct],
  338. user_id: str,
  339. ) -> None:
  340. """全部危化品证书已上传时,自动标记证书齐备(与人工确认等效)。"""
  341. if int(plan.hazard_cert_ready or 0):
  342. return
  343. if not all_hazard_products_cert_complete(db, tenant_no, products):
  344. return
  345. plan.hazard_cert_ready = 1
  346. plan.hazard_cert_ready_by = user_id
  347. plan.hazard_cert_ready_time = datetime.now()
  348. def validate_hazard_cert_ready_for_transport(
  349. db: Session,
  350. tenant_no: str,
  351. plan: PlPlan,
  352. products: list[PlPlanProduct],
  353. ) -> None:
  354. if not plan_has_hazard_products(db, tenant_no, products):
  355. return
  356. if int(plan.hazard_cert_ready or 0):
  357. return
  358. if all_hazard_products_cert_complete(db, tenant_no, products):
  359. return
  360. raise HTTPException(
  361. status_code=400,
  362. detail="该计划危化品证书尚未确认齐备,请至危化品证上传页操作",
  363. )
  364. def set_hazard_cert_ready(
  365. db: Session,
  366. *,
  367. tenant_no: str,
  368. plan_id: str,
  369. ready: bool,
  370. user_id: str,
  371. ) -> PlPlan:
  372. plan = (
  373. db.query(PlPlan)
  374. .filter(
  375. PlPlan.id == plan_id,
  376. PlPlan.tenant_no == tenant_no,
  377. PlPlan.del_flag == 0,
  378. )
  379. .first()
  380. )
  381. if not plan:
  382. raise HTTPException(status_code=404, detail="计划不存在")
  383. if plan.archive_status == "archived":
  384. raise HTTPException(status_code=400, detail="已归档的计划不能修改证书齐备状态")
  385. if plan.follow_status == "cancelled":
  386. raise HTTPException(status_code=400, detail="已作废的计划不能修改证书齐备状态")
  387. products = (
  388. db.query(PlPlanProduct)
  389. .filter(
  390. PlPlanProduct.plan_id == plan_id,
  391. PlPlanProduct.tenant_no == tenant_no,
  392. PlPlanProduct.del_flag == 0,
  393. )
  394. .all()
  395. )
  396. has_hazard = plan_has_hazard_products(db, tenant_no, products)
  397. if ready:
  398. if not has_hazard:
  399. raise HTTPException(status_code=400, detail="该计划不含易制毒/易制爆产品,无需确认证书齐备")
  400. plan.hazard_cert_ready = 1
  401. plan.hazard_cert_ready_by = user_id
  402. plan.hazard_cert_ready_time = datetime.now()
  403. else:
  404. clear_hazard_cert_ready(plan)
  405. plan.update_by = user_id
  406. db.commit()
  407. db.refresh(plan)
  408. return plan
  409. def update_plan_product_certs(
  410. db: Session,
  411. *,
  412. tenant_no: str,
  413. plan_id: str,
  414. items: list,
  415. user_id: str,
  416. ) -> list[PlPlanProduct]:
  417. plan = (
  418. db.query(PlPlan)
  419. .filter(
  420. PlPlan.id == plan_id,
  421. PlPlan.tenant_no == tenant_no,
  422. PlPlan.del_flag == 0,
  423. )
  424. .first()
  425. )
  426. if not plan:
  427. raise HTTPException(status_code=404, detail="计划不存在")
  428. if plan.archive_status == "archived":
  429. raise HTTPException(status_code=400, detail="已归档的计划不能修改")
  430. existing_rows = (
  431. db.query(PlPlanProduct)
  432. .filter(
  433. PlPlanProduct.plan_id == plan_id,
  434. PlPlanProduct.tenant_no == tenant_no,
  435. PlPlanProduct.del_flag == 0,
  436. )
  437. .all()
  438. )
  439. hazard_map = fetch_hazard_flags_map(
  440. db,
  441. tenant_no,
  442. [row.product_id for row in existing_rows if row.product_id],
  443. )
  444. updated: list[PlPlanProduct] = []
  445. for item in items:
  446. row = (
  447. db.query(PlPlanProduct)
  448. .filter(
  449. PlPlanProduct.id == item.id,
  450. PlPlanProduct.plan_id == plan_id,
  451. PlPlanProduct.tenant_no == tenant_no,
  452. PlPlanProduct.del_flag == 0,
  453. )
  454. .first()
  455. )
  456. if not row:
  457. raise HTTPException(status_code=404, detail=f"计划产品不存在: {item.id}")
  458. is_toxic, is_explosive = resolve_hazard_flags_from_crm(
  459. db, tenant_no, row.product_id, hazard_map=hazard_map
  460. )
  461. if not (is_toxic or is_explosive):
  462. raise HTTPException(status_code=400, detail=f"产品【{row.product_name}】无需上传危化品证书")
  463. row.purchase_cert_url = (item.purchase_cert_url or "").strip() or None
  464. row.transport_cert_url = (item.transport_cert_url or "").strip() or None
  465. row.legal_use_cert_url = (item.legal_use_cert_url or "").strip() or None
  466. row.update_by = user_id
  467. updated.append(row)
  468. maybe_auto_set_hazard_cert_ready(
  469. db,
  470. tenant_no=tenant_no,
  471. plan=plan,
  472. products=existing_rows,
  473. user_id=user_id,
  474. )
  475. plan.update_by = user_id
  476. db.commit()
  477. for row in updated:
  478. db.refresh(row)
  479. return updated
  480. def _generate_transport_no(db: Session) -> str:
  481. now = date.today()
  482. prefix = f"SXYS{now.strftime('%Y%m')}"
  483. last = (
  484. db.query(PlTransport)
  485. .filter(PlTransport.transport_no.like(f"{prefix}%"))
  486. .order_by(PlTransport.transport_no.desc())
  487. .first()
  488. )
  489. seq = 1
  490. if last and last.transport_no:
  491. try:
  492. seq = int(last.transport_no[-6:]) + 1
  493. except ValueError:
  494. seq = 1
  495. return f"{prefix}{seq:06d}"
  496. def _resolve_transport_context(
  497. db: Session,
  498. *,
  499. tenant_no: str,
  500. plan: PlPlan,
  501. transport_payload: PlanTransportCreateBlock,
  502. products: list[PlPlanProduct],
  503. ) -> tuple[PlVehicle, str, str, Optional[str], Optional[str], Optional[str], Decimal, Optional[Decimal]]:
  504. vehicle = (
  505. db.query(PlVehicle)
  506. .filter(
  507. PlVehicle.id == transport_payload.vehicle_id,
  508. PlVehicle.tenant_no == tenant_no,
  509. PlVehicle.del_flag == 0,
  510. )
  511. .first()
  512. )
  513. if not vehicle:
  514. raise HTTPException(status_code=400, detail="车辆不存在")
  515. driver_id, driver_name, driver_phone = _resolve_driver(
  516. db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
  517. )
  518. carrier_id = transport_payload.carrier_id or vehicle.carrier_id
  519. carrier_name = vehicle.carrier_name
  520. if carrier_id:
  521. carrier = (
  522. db.query(PlCarrier)
  523. .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
  524. .first()
  525. )
  526. if carrier:
  527. carrier_name = carrier.carrier_name
  528. freight_amount = _calc_freight_amount(
  529. transport_payload.freight_type,
  530. products,
  531. transport_payload.transport_qty,
  532. transport_payload.freight_price,
  533. transport_payload.freight_amount,
  534. )
  535. transport_qty = transport_payload.transport_qty
  536. if transport_qty is None and transport_payload.freight_type == "by_weight":
  537. transport_qty = sum_plan_product_actual_quantities(products)
  538. return (
  539. vehicle,
  540. driver_id,
  541. driver_name,
  542. driver_phone,
  543. carrier_id,
  544. carrier_name,
  545. freight_amount,
  546. transport_qty,
  547. )
  548. def _apply_payload_to_transport_row(
  549. transport: PlTransport,
  550. *,
  551. vehicle: PlVehicle,
  552. driver_id: str,
  553. driver_name: str,
  554. driver_phone: Optional[str],
  555. carrier_id: Optional[str],
  556. carrier_name: Optional[str],
  557. plan: PlPlan,
  558. transport_payload: PlanTransportCreateBlock,
  559. freight_amount: Decimal,
  560. transport_qty: Optional[Decimal],
  561. user_id: str,
  562. ) -> None:
  563. transport.vehicle_id = vehicle.id
  564. transport.license_plate = vehicle.license_plate
  565. transport.driver_id = driver_id
  566. transport.driver_name = driver_name
  567. transport.driver_phone = driver_phone
  568. transport.carrier_id = carrier_id
  569. transport.carrier_name = carrier_name
  570. transport.start_point = plan.pickup_address
  571. transport.end_point = plan.destination_address or plan.destination_name
  572. transport.distance_km = transport_payload.distance_km
  573. transport.transport_qty = transport_qty
  574. transport.freight_price = transport_payload.freight_price
  575. transport.freight_amount = freight_amount or 0
  576. transport.freight_type = transport_payload.freight_type
  577. if transport_payload.has_invoice is not None:
  578. transport.has_invoice = transport_payload.has_invoice
  579. transport.unit = "吨" if transport_payload.freight_type == "by_weight" else "趟"
  580. if transport_payload.remark is not None:
  581. transport.remark = transport_payload.remark
  582. transport.update_by = user_id
  583. def upsert_draft_transport_for_plan(
  584. db: Session,
  585. *,
  586. tenant_no: str,
  587. plan: PlPlan,
  588. transport_payload: PlanTransportCreateBlock,
  589. products: list[PlPlanProduct],
  590. user_id: str,
  591. dept_id: str,
  592. ) -> None:
  593. """写入/更新草稿运输单(pl_transport + pl_plan_transport),计划保持待安排。"""
  594. (
  595. vehicle,
  596. driver_id,
  597. driver_name,
  598. driver_phone,
  599. carrier_id,
  600. carrier_name,
  601. freight_amount,
  602. transport_qty,
  603. ) = _resolve_transport_context(
  604. db,
  605. tenant_no=tenant_no,
  606. plan=plan,
  607. transport_payload=transport_payload,
  608. products=products,
  609. )
  610. transport: Optional[PlTransport] = None
  611. if plan.transport_id:
  612. transport = (
  613. db.query(PlTransport)
  614. .filter(
  615. PlTransport.id == plan.transport_id,
  616. PlTransport.tenant_no == tenant_no,
  617. PlTransport.del_flag == 0,
  618. )
  619. .first()
  620. )
  621. if transport:
  622. _apply_payload_to_transport_row(
  623. transport,
  624. vehicle=vehicle,
  625. driver_id=driver_id,
  626. driver_name=driver_name,
  627. driver_phone=driver_phone,
  628. carrier_id=carrier_id,
  629. carrier_name=carrier_name,
  630. plan=plan,
  631. transport_payload=transport_payload,
  632. freight_amount=freight_amount,
  633. transport_qty=transport_qty,
  634. user_id=user_id,
  635. )
  636. if transport.transport_status == "draft":
  637. pass
  638. else:
  639. transport_no = _generate_transport_no(db)
  640. transport = PlTransport(
  641. id=generate_uuid(),
  642. tenant_no=tenant_no,
  643. transport_no=transport_no,
  644. transport_status="draft",
  645. approval_status="none",
  646. remark=transport_payload.remark,
  647. create_by=user_id,
  648. create_dept_id=dept_id,
  649. )
  650. db.add(transport)
  651. db.flush()
  652. db.add(
  653. PlPlanTransport(
  654. id=generate_uuid(),
  655. tenant_no=tenant_no,
  656. plan_id=plan.id,
  657. transport_id=transport.id,
  658. create_by=user_id,
  659. create_dept_id=dept_id,
  660. )
  661. )
  662. plan.transport_id = transport.id
  663. plan.transport_no = transport_no
  664. _apply_payload_to_transport_row(
  665. transport,
  666. vehicle=vehicle,
  667. driver_id=driver_id,
  668. driver_name=driver_name,
  669. driver_phone=driver_phone,
  670. carrier_id=carrier_id,
  671. carrier_name=carrier_name,
  672. plan=plan,
  673. transport_payload=transport_payload,
  674. freight_amount=freight_amount,
  675. transport_qty=transport_qty,
  676. user_id=user_id,
  677. )
  678. plan.vehicle_id = vehicle.id
  679. plan.license_plate = vehicle.license_plate
  680. plan.driver_id = driver_id
  681. plan.driver_name = driver_name
  682. _apply_vehicle_snapshot_to_plan_transport(
  683. db,
  684. tenant_no=tenant_no,
  685. plan=plan,
  686. transport=transport,
  687. vehicle=vehicle,
  688. driver_id=driver_id,
  689. )
  690. plan.freight_amount = freight_amount or 0
  691. if plan.transport_id:
  692. sync_draft_settlement_freight_for_transport(
  693. db,
  694. tenant_no=tenant_no,
  695. transport_id=plan.transport_id,
  696. freight_amount=freight_amount or Decimal("0"),
  697. user_id=user_id,
  698. )
  699. if plan.transport_status != "arranged":
  700. plan.transport_status = "pending"
  701. def clear_draft_transport_for_plan(
  702. db: Session,
  703. *,
  704. tenant_no: str,
  705. plan: PlPlan,
  706. user_id: str,
  707. ) -> None:
  708. """清除计划上的草稿运输单及车辆预选。"""
  709. transport: Optional[PlTransport] = None
  710. if plan.transport_id:
  711. transport = (
  712. db.query(PlTransport)
  713. .filter(
  714. PlTransport.id == plan.transport_id,
  715. PlTransport.tenant_no == tenant_no,
  716. PlTransport.del_flag == 0,
  717. )
  718. .first()
  719. )
  720. if transport and transport.transport_status == "draft":
  721. transport.del_flag = 1
  722. transport.update_by = user_id
  723. db.query(PlPlanTransport).filter(
  724. PlPlanTransport.plan_id == plan.id,
  725. PlPlanTransport.transport_id == transport.id,
  726. PlPlanTransport.tenant_no == tenant_no,
  727. PlPlanTransport.del_flag == 0,
  728. ).update({"del_flag": 1, "update_by": user_id})
  729. plan.transport_id = None
  730. plan.transport_no = None
  731. plan.vehicle_id = None
  732. plan.license_plate = None
  733. plan.driver_id = None
  734. plan.driver_name = None
  735. clear_resource_snapshot(plan=plan, transport=transport)
  736. plan.freight_amount = 0
  737. if plan.transport_status != "arranged":
  738. plan.transport_status = "pending"
  739. def sync_transport_prefill_for_plan(
  740. db: Session,
  741. *,
  742. tenant_no: str,
  743. plan: PlPlan,
  744. transport_payload: PlanTransportCreateBlock,
  745. products: list[PlPlanProduct],
  746. user_id: str,
  747. dept_id: str = "",
  748. ) -> None:
  749. """同步运输预选到草稿运输子表(pl_transport)。"""
  750. upsert_draft_transport_for_plan(
  751. db,
  752. tenant_no=tenant_no,
  753. plan=plan,
  754. transport_payload=transport_payload,
  755. products=products,
  756. user_id=user_id,
  757. dept_id=dept_id or user_id,
  758. )
  759. def apply_vehicle_prefill_to_plan(
  760. db: Session,
  761. *,
  762. tenant_no: str,
  763. plan: PlPlan,
  764. transport_payload: PlanTransportCreateBlock,
  765. products: list[PlPlanProduct],
  766. user_id: str = "",
  767. dept_id: str = "",
  768. ) -> None:
  769. """兼容旧调用:写入草稿运输子表。"""
  770. upsert_draft_transport_for_plan(
  771. db,
  772. tenant_no=tenant_no,
  773. plan=plan,
  774. transport_payload=transport_payload,
  775. products=products,
  776. user_id=user_id,
  777. dept_id=dept_id or user_id,
  778. )
  779. def create_transport_for_plan(
  780. db: Session,
  781. *,
  782. tenant_no: str,
  783. plan: PlPlan,
  784. products: list[PlPlanProduct],
  785. transport_payload: PlanTransportCreateBlock,
  786. user_id: str,
  787. dept_id: str,
  788. generate_transport_no,
  789. ) -> PlTransport:
  790. vehicle = (
  791. db.query(PlVehicle)
  792. .filter(
  793. PlVehicle.id == transport_payload.vehicle_id,
  794. PlVehicle.tenant_no == tenant_no,
  795. PlVehicle.del_flag == 0,
  796. )
  797. .first()
  798. )
  799. if not vehicle:
  800. raise HTTPException(status_code=400, detail="车辆不存在")
  801. validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products)
  802. driver_id, driver_name, driver_phone = _resolve_driver(
  803. db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
  804. )
  805. carrier_name = vehicle.carrier_name
  806. carrier_id = transport_payload.carrier_id or vehicle.carrier_id
  807. if carrier_id:
  808. carrier = (
  809. db.query(PlCarrier)
  810. .filter(PlCarrier.id == carrier_id, PlCarrier.del_flag == 0)
  811. .first()
  812. )
  813. if carrier:
  814. carrier_name = carrier.carrier_name
  815. freight_amount = _calc_freight_amount(
  816. transport_payload.freight_type,
  817. products,
  818. transport_payload.transport_qty,
  819. transport_payload.freight_price,
  820. transport_payload.freight_amount,
  821. )
  822. transport_qty = transport_payload.transport_qty
  823. if transport_qty is None and transport_payload.freight_type == "by_weight":
  824. transport_qty = sum_plan_product_actual_quantities(products)
  825. transport_no = generate_transport_no(db)
  826. transport = PlTransport(
  827. id=generate_uuid(),
  828. tenant_no=tenant_no,
  829. transport_no=transport_no,
  830. vehicle_id=vehicle.id,
  831. license_plate=vehicle.license_plate,
  832. driver_id=driver_id,
  833. driver_name=driver_name,
  834. driver_phone=driver_phone,
  835. carrier_id=carrier_id,
  836. carrier_name=carrier_name,
  837. start_point=plan.pickup_address,
  838. end_point=plan.destination_address or plan.destination_name,
  839. distance_km=transport_payload.distance_km,
  840. unit="吨" if transport_payload.freight_type == "by_weight" else "趟",
  841. transport_qty=transport_qty,
  842. freight_price=transport_payload.freight_price,
  843. freight_amount=freight_amount or 0,
  844. freight_type=transport_payload.freight_type,
  845. has_invoice=transport_payload.has_invoice if transport_payload.has_invoice is not None else 0,
  846. transport_status="draft",
  847. approval_status="none",
  848. remark=transport_payload.remark,
  849. create_by=user_id,
  850. create_dept_id=dept_id,
  851. )
  852. db.add(transport)
  853. db.add(
  854. PlPlanTransport(
  855. id=generate_uuid(),
  856. tenant_no=tenant_no,
  857. plan_id=plan.id,
  858. transport_id=transport.id,
  859. create_by=user_id,
  860. create_dept_id=dept_id,
  861. )
  862. )
  863. plan.transport_id = transport.id
  864. plan.transport_no = transport_no
  865. plan.vehicle_id = vehicle.id
  866. plan.license_plate = vehicle.license_plate
  867. plan.driver_id = driver_id
  868. plan.driver_name = driver_name
  869. _apply_vehicle_snapshot_to_plan_transport(
  870. db,
  871. tenant_no=tenant_no,
  872. plan=plan,
  873. transport=transport,
  874. vehicle=vehicle,
  875. driver_id=driver_id,
  876. )
  877. plan.transport_status = "arranged"
  878. plan.follow_status = "arranged"
  879. plan.freight_amount = freight_amount or 0
  880. return transport
  881. PLAN_SELECTABLE_CONTRACT_STATUSES = ("approved", "executing")
  882. def assert_contract_selectable_for_plan(
  883. db: Session,
  884. contract_id: str,
  885. tenant_no: str,
  886. ) -> None:
  887. from app.models.crm_models import CrmContract
  888. contract = (
  889. db.query(CrmContract)
  890. .filter(
  891. CrmContract.id == contract_id,
  892. CrmContract.tenant_no == tenant_no,
  893. CrmContract.del_flag == 0,
  894. )
  895. .first()
  896. )
  897. if not contract:
  898. raise HTTPException(status_code=404, detail="合同不存在")
  899. if contract.status not in PLAN_SELECTABLE_CONTRACT_STATUSES:
  900. raise HTTPException(status_code=422, detail="仅已审批或执行中的合同可关联运输计划")
  901. def validate_create_full(payload: PlanCreateFull) -> None:
  902. if not payload.contract_id:
  903. raise HTTPException(status_code=422, detail="请选择合同")
  904. if not payload.products:
  905. raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
  906. transport = payload.transport
  907. if not transport or not transport.vehicle_id:
  908. return
  909. if payload.has_freight:
  910. if transport.freight_type == "by_weight":
  911. if not transport.freight_price or Decimal(str(transport.freight_price)) <= 0:
  912. raise HTTPException(status_code=422, detail="按重量计费时请填写运输单价")
  913. qty = transport.transport_qty or sum_product_actual_quantities(payload.products)
  914. if qty <= 0:
  915. raise HTTPException(status_code=422, detail="运输量必须大于0,请填写产品实际数量或运输量")
  916. elif transport.freight_type == "by_trip":
  917. if not transport.freight_amount or Decimal(str(transport.freight_amount)) <= 0:
  918. raise HTTPException(status_code=422, detail="按趟计费时请填写运费金额")
  919. def calc_plan_total_amount(products: list[PlanProductCreate]) -> Decimal:
  920. total = Decimal("0")
  921. for prod in products:
  922. if prod.tax_subtotal is not None:
  923. total += Decimal(str(prod.tax_subtotal))
  924. return total
  925. _LOCKED_SETTLEMENT_STATUSES = ("approving", "approved", "confirmed")
  926. def transport_has_locked_freight_settlement(
  927. db: Session, tenant_no: str, transport_id: Optional[str]
  928. ) -> bool:
  929. if not transport_id:
  930. return False
  931. return (
  932. db.query(PlFreightSettlementTransport.id)
  933. .join(
  934. PlFreightSettlement,
  935. PlFreightSettlement.id == PlFreightSettlementTransport.settlement_id,
  936. )
  937. .filter(
  938. PlFreightSettlementTransport.transport_id == transport_id,
  939. PlFreightSettlementTransport.tenant_no == tenant_no,
  940. PlFreightSettlementTransport.del_flag == 0,
  941. PlFreightSettlement.tenant_no == tenant_no,
  942. PlFreightSettlement.del_flag == 0,
  943. PlFreightSettlement.settlement_status.in_(_LOCKED_SETTLEMENT_STATUSES),
  944. )
  945. .first()
  946. is not None
  947. )
  948. def sync_archive_qty_from_io_order(
  949. db: Session,
  950. *,
  951. tenant_no: str,
  952. plan: PlPlan,
  953. items: list[PlanArchiveProductItem],
  954. ) -> list[PlanArchiveProductItem]:
  955. """若存在入库/出库单,用明细 actual_qty 覆盖归档提交中的实际数量(权威来源)。"""
  956. from app.models.pl_models import PlInboundOrder, PlInboundOrderItem, PlOutboundOrder, PlOutboundOrderItem
  957. if plan.plan_type not in ("inbound", "outbound"):
  958. return items
  959. header_m = PlInboundOrder if plan.plan_type == "inbound" else PlOutboundOrder
  960. item_m = PlInboundOrderItem if plan.plan_type == "inbound" else PlOutboundOrderItem
  961. header = (
  962. db.query(header_m)
  963. .filter(
  964. header_m.tenant_no == tenant_no,
  965. header_m.plan_id == plan.id,
  966. header_m.status != "voided",
  967. header_m.del_flag == 0,
  968. )
  969. .order_by(header_m.create_time.desc())
  970. .first()
  971. )
  972. if not header:
  973. return items
  974. io_items = (
  975. db.query(item_m)
  976. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  977. .all()
  978. )
  979. qty_by_plan_product = {
  980. row.plan_product_id: Decimal(str(row.actual_qty))
  981. for row in io_items
  982. if row.actual_qty is not None
  983. }
  984. if not qty_by_plan_product:
  985. return items
  986. out: list[PlanArchiveProductItem] = []
  987. for item in items:
  988. qty = qty_by_plan_product.get(item.id)
  989. if qty is not None:
  990. out.append(PlanArchiveProductItem(id=item.id, actual_qty=qty))
  991. else:
  992. out.append(item)
  993. return out
  994. def apply_archive_product_quantities(
  995. db: Session,
  996. *,
  997. tenant_no: str,
  998. plan: PlPlan,
  999. items: list[PlanArchiveProductItem],
  1000. user_id: str,
  1001. sync_from_io_order: bool = True,
  1002. ) -> None:
  1003. if sync_from_io_order:
  1004. items = sync_archive_qty_from_io_order(db, tenant_no=tenant_no, plan=plan, items=items)
  1005. product_map = {
  1006. row.id: row
  1007. for row in db.query(PlPlanProduct).filter(
  1008. PlPlanProduct.plan_id == plan.id,
  1009. PlPlanProduct.tenant_no == tenant_no,
  1010. PlPlanProduct.del_flag == 0,
  1011. ).all()
  1012. }
  1013. for item in items:
  1014. prod = product_map.get(item.id)
  1015. if not prod:
  1016. raise HTTPException(status_code=400, detail=f"产品明细不存在: {item.id}")
  1017. actual = Decimal(str(item.actual_qty))
  1018. prod.actual_qty = actual
  1019. price_in = prod.inbound_price or prod.tax_price
  1020. price_out = prod.outbound_price or prod.tax_price
  1021. if plan.plan_type == "inbound":
  1022. prod.inbound_qty = actual
  1023. if price_in is not None:
  1024. prod.inbound_amount = actual * Decimal(str(price_in))
  1025. elif plan.plan_type == "outbound":
  1026. prod.outbound_qty = actual
  1027. if price_out is not None:
  1028. prod.outbound_amount = actual * Decimal(str(price_out))
  1029. prod.update_by = user_id
  1030. brand_rows = []
  1031. if item.brand_qtys:
  1032. brand_rows = [
  1033. {
  1034. "brandId": bq.brand_id or "",
  1035. "brandName": bq.brand_name,
  1036. "qty": bq.qty,
  1037. }
  1038. for bq in item.brand_qtys
  1039. ]
  1040. try:
  1041. validate_brand_qty_rows(
  1042. brand_mode=prod.brand_mode,
  1043. required_brand_id=prod.brand_id,
  1044. actual_qty=actual,
  1045. rows=brand_rows,
  1046. )
  1047. except ValueError as exc:
  1048. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1049. persist_plan_product_brand_qtys(
  1050. db,
  1051. tenant_no=tenant_no,
  1052. plan_id=plan.id,
  1053. plan_product_id=prod.id,
  1054. brand_mode=prod.brand_mode,
  1055. rows=brand_rows,
  1056. user_id=user_id,
  1057. dept_id=getattr(prod, "create_dept_id", "") or "",
  1058. )
  1059. def apply_archive_freight(
  1060. db: Session,
  1061. *,
  1062. tenant_no: str,
  1063. plan: PlPlan,
  1064. freight: PlanArchiveFreightBlock,
  1065. user_id: str,
  1066. ) -> None:
  1067. has_freight = int(freight.has_freight if freight.has_freight is not None else plan.has_freight or 0)
  1068. plan.has_freight = has_freight
  1069. plan.update_by = user_id
  1070. if has_freight != 1:
  1071. plan.freight_amount = Decimal("0")
  1072. if not plan.transport_id:
  1073. return
  1074. if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
  1075. raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
  1076. transport = (
  1077. db.query(PlTransport)
  1078. .filter(
  1079. PlTransport.id == plan.transport_id,
  1080. PlTransport.tenant_no == tenant_no,
  1081. PlTransport.del_flag == 0,
  1082. )
  1083. .first()
  1084. )
  1085. if not transport:
  1086. raise HTTPException(status_code=400, detail="关联运输单不存在")
  1087. transport.freight_amount = Decimal("0")
  1088. transport.freight_price = None
  1089. transport.transport_qty = None
  1090. transport.update_by = user_id
  1091. sync_draft_settlement_freight_for_transport(
  1092. db,
  1093. tenant_no=tenant_no,
  1094. transport_id=plan.transport_id,
  1095. freight_amount=Decimal("0"),
  1096. user_id=user_id,
  1097. )
  1098. return
  1099. if not plan.transport_id:
  1100. raise HTTPException(status_code=400, detail="计划有运输费用但未安排运输,请先安排运输")
  1101. if transport_has_locked_freight_settlement(db, tenant_no, plan.transport_id):
  1102. raise HTTPException(status_code=400, detail="运输单已纳入运费结算,无法修改运费")
  1103. transport = (
  1104. db.query(PlTransport)
  1105. .filter(
  1106. PlTransport.id == plan.transport_id,
  1107. PlTransport.tenant_no == tenant_no,
  1108. PlTransport.del_flag == 0,
  1109. )
  1110. .first()
  1111. )
  1112. if not transport:
  1113. raise HTTPException(status_code=400, detail="关联运输单不存在")
  1114. transport.distance_km = freight.distance_km
  1115. transport.freight_type = freight.freight_type or "by_weight"
  1116. transport.freight_price = freight.freight_price
  1117. amount = freight.freight_amount or Decimal("0")
  1118. transport.freight_amount = amount
  1119. freight_type = freight.freight_type or transport.freight_type or "by_weight"
  1120. plan_products = (
  1121. db.query(PlPlanProduct)
  1122. .filter(
  1123. PlPlanProduct.plan_id == plan.id,
  1124. PlPlanProduct.tenant_no == tenant_no,
  1125. PlPlanProduct.del_flag == 0,
  1126. )
  1127. .all()
  1128. )
  1129. if freight.transport_qty is not None:
  1130. transport.transport_qty = freight.transport_qty
  1131. elif freight_type == "by_weight":
  1132. transport.transport_qty = sum_plan_product_actual_quantities(plan_products)
  1133. if freight_type == "by_weight" and (transport.transport_qty or 0) <= 0:
  1134. raise HTTPException(status_code=400, detail="运输量必须大于0,请先填写产品实际数量")
  1135. if freight.has_invoice is not None:
  1136. transport.has_invoice = freight.has_invoice
  1137. transport.update_by = user_id
  1138. plan.freight_amount = amount
  1139. sync_draft_settlement_freight_for_transport(
  1140. db,
  1141. tenant_no=tenant_no,
  1142. transport_id=plan.transport_id,
  1143. freight_amount=amount,
  1144. user_id=user_id,
  1145. )
  1146. def record_plan_change(
  1147. db: Session,
  1148. *,
  1149. tenant_no: str,
  1150. plan_id: str,
  1151. change_type: str,
  1152. change_content: str,
  1153. user_id: str,
  1154. user_name: Optional[str],
  1155. dept_id: str,
  1156. ) -> None:
  1157. db.add(
  1158. PlPlanChange(
  1159. id=generate_uuid(),
  1160. tenant_no=tenant_no,
  1161. plan_id=plan_id,
  1162. change_type=change_type,
  1163. change_content=change_content,
  1164. change_user_id=user_id,
  1165. change_user_name=user_name,
  1166. create_by=user_id,
  1167. create_dept_id=dept_id,
  1168. )
  1169. )
  1170. def apply_plan_driver_feedback(
  1171. db: Session,
  1172. *,
  1173. tenant_no: str,
  1174. plan: PlPlan,
  1175. payload: PlanDriverFeedbackUpdateBody,
  1176. user_id: str,
  1177. ) -> PlTransport:
  1178. if plan.follow_status == "cancelled":
  1179. raise HTTPException(status_code=400, detail="已作废的计划不能编辑司机回传")
  1180. if not plan.transport_id:
  1181. raise HTTPException(status_code=400, detail="计划未安排运输,无法编辑司机回传")
  1182. transport = (
  1183. db.query(PlTransport)
  1184. .filter(
  1185. PlTransport.id == plan.transport_id,
  1186. PlTransport.tenant_no == tenant_no,
  1187. PlTransport.del_flag == 0,
  1188. )
  1189. .first()
  1190. )
  1191. if not transport:
  1192. raise HTTPException(status_code=404, detail="运输单不存在")
  1193. if payload.products is not None:
  1194. products_payload = [
  1195. {
  1196. "planProductId": item.plan_product_id,
  1197. "actualQty": item.actual_qty,
  1198. "brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
  1199. }
  1200. for item in payload.products
  1201. ]
  1202. try:
  1203. persist_transport_product_feedback(
  1204. db,
  1205. tenant_no=tenant_no,
  1206. transport=transport,
  1207. plan_id=plan.id,
  1208. products=products_payload,
  1209. user_id=user_id,
  1210. enforce_brand=True,
  1211. )
  1212. except ValueError as exc:
  1213. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1214. elif payload.feedback_qty is not None:
  1215. transport.feedback_qty = payload.feedback_qty
  1216. if payload.feedback_remark is not None:
  1217. transport.feedback_remark = payload.feedback_remark.strip() if payload.feedback_remark else None
  1218. if payload.feedback_images is not None:
  1219. transport.feedback_images = json.dumps([item for item in payload.feedback_images if item])
  1220. transport.feedback_time = payload.feedback_time or datetime.now()
  1221. transport.update_by = user_id
  1222. return transport
  1223. def apply_plan_site_feedback(
  1224. db: Session,
  1225. *,
  1226. tenant_no: str,
  1227. plan: PlPlan,
  1228. link_id: str,
  1229. payload: PlanSiteFeedbackUpdateBody,
  1230. user_id: str,
  1231. ) -> PlTransportSiteLink:
  1232. if plan.follow_status == "cancelled":
  1233. raise HTTPException(status_code=400, detail="已作废的计划不能编辑现场回传")
  1234. link = (
  1235. db.query(PlTransportSiteLink)
  1236. .filter(
  1237. PlTransportSiteLink.id == link_id,
  1238. PlTransportSiteLink.plan_id == plan.id,
  1239. PlTransportSiteLink.tenant_no == tenant_no,
  1240. PlTransportSiteLink.del_flag == 0,
  1241. )
  1242. .first()
  1243. )
  1244. if not link:
  1245. raise HTTPException(status_code=404, detail="现场链接不存在")
  1246. if payload.site_feedback_remark is not None:
  1247. link.site_feedback_remark = (
  1248. payload.site_feedback_remark.strip() if payload.site_feedback_remark else None
  1249. )
  1250. if payload.site_feedback_images is not None:
  1251. link.site_feedback_images = json.dumps([item for item in payload.site_feedback_images if item])
  1252. link.site_feedback_time = payload.site_feedback_time or datetime.now()
  1253. link.update_by = user_id
  1254. return link