pl_plan_stop_service.py 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097
  1. """运输计划 · 装货/送货段读取与列表摘要。"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from datetime import datetime
  5. from decimal import Decimal
  6. from typing import Optional
  7. from fastapi import HTTPException
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import generate_uuid
  10. from app.models.crm_models import CrmContractProduct
  11. from app.models.pl_models import (
  12. PlPlan,
  13. PlPlanContract,
  14. PlPlanDelivery,
  15. PlPlanDeliveryProduct,
  16. PlPlanLoad,
  17. PlPlanLoadProduct,
  18. PlTransportAddress,
  19. )
  20. from app.schemas.pl.plan import (
  21. PlanArchiveBrandQtyItem,
  22. PlanContractLink,
  23. PlanDeliveryBody,
  24. PlanDeliveryRead,
  25. PlanLoadBody,
  26. PlanLoadRead,
  27. PlanStopProductBody,
  28. PlanStopProductRead,
  29. )
  30. from app.services.pl_brand_archive import load_brand_qtys_by_stop_product_ids
  31. from app.services.pl_internal_customer import is_internal_customer_name
  32. from app.services.pl_plan_stop_rules import normalize_stop_dibang_fields, warn_qty_imbalance
  33. from app.services.pl_plan_service import (
  34. PLAN_CONTRACT_SIDE_DEMANDER,
  35. PLAN_CONTRACT_SIDE_SUPPLIER,
  36. assert_contract_selectable_for_plan,
  37. )
  38. from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names
  39. PLAN_CONTRACT_SIDE_LOAD = "load"
  40. PLAN_CONTRACT_SIDE_DELIVERY = "delivery"
  41. def _contract_link(row: PlPlanContract) -> PlanContractLink:
  42. return PlanContractLink(
  43. contractId=row.contract_id,
  44. contractNo=row.contract_no,
  45. stopId=row.stop_id,
  46. )
  47. def _attach_stop_brand_qtys(
  48. product: PlanStopProductRead,
  49. brand_qtys_by_stop: dict[str, list[dict]],
  50. ) -> PlanStopProductRead:
  51. raw = brand_qtys_by_stop.get(product.id) or None
  52. if raw:
  53. product.brand_qtys = [PlanArchiveBrandQtyItem.model_validate(x) for x in raw]
  54. else:
  55. product.brand_qtys = None
  56. return product
  57. def _resolve_stop_list_product_name(
  58. *,
  59. saved_product_name: Optional[str],
  60. product_id: Optional[str],
  61. standard_by_id: dict[str, str],
  62. alias_options_by_id: dict[str, list[str]],
  63. ) -> str:
  64. """列表展示用:优先 CRM 标准名,不展示别名。"""
  65. display_name, _alias = resolve_product_display_names(
  66. saved_product_name=saved_product_name,
  67. product_id=product_id,
  68. standard_by_id=standard_by_id,
  69. alias_options_by_id=alias_options_by_id,
  70. )
  71. return display_name or (saved_product_name or "")
  72. def _apply_stop_list_product_name(
  73. product: PlanStopProductRead,
  74. *,
  75. standard_by_id: dict[str, str],
  76. alias_options_by_id: dict[str, list[str]],
  77. ) -> PlanStopProductRead:
  78. product.product_name = _resolve_stop_list_product_name(
  79. saved_product_name=product.product_name,
  80. product_id=product.product_id,
  81. standard_by_id=standard_by_id,
  82. alias_options_by_id=alias_options_by_id,
  83. )
  84. return product
  85. def _load_stop_product(
  86. row: PlPlanLoadProduct,
  87. brand_qtys_by_stop: dict[str, list[dict]] | None = None,
  88. *,
  89. standard_by_id: dict[str, str] | None = None,
  90. alias_options_by_id: dict[str, list[str]] | None = None,
  91. ) -> PlanStopProductRead:
  92. product = PlanStopProductRead.model_validate(row)
  93. if standard_by_id is not None and alias_options_by_id is not None:
  94. product = _apply_stop_list_product_name(
  95. product,
  96. standard_by_id=standard_by_id,
  97. alias_options_by_id=alias_options_by_id,
  98. )
  99. if brand_qtys_by_stop is not None:
  100. return _attach_stop_brand_qtys(product, brand_qtys_by_stop)
  101. return product
  102. def _delivery_stop_product(
  103. row: PlPlanDeliveryProduct,
  104. brand_qtys_by_stop: dict[str, list[dict]] | None = None,
  105. *,
  106. standard_by_id: dict[str, str] | None = None,
  107. alias_options_by_id: dict[str, list[str]] | None = None,
  108. ) -> PlanStopProductRead:
  109. product = PlanStopProductRead.model_validate(row)
  110. if standard_by_id is not None and alias_options_by_id is not None:
  111. product = _apply_stop_list_product_name(
  112. product,
  113. standard_by_id=standard_by_id,
  114. alias_options_by_id=alias_options_by_id,
  115. )
  116. if brand_qtys_by_stop is not None:
  117. return _attach_stop_brand_qtys(product, brand_qtys_by_stop)
  118. return product
  119. def load_stop_contract_rows_grouped(
  120. db: Session,
  121. tenant_no: str,
  122. plan_ids: list[str],
  123. ) -> dict[str, list[PlPlanContract]]:
  124. if not plan_ids:
  125. return {}
  126. rows = (
  127. db.query(PlPlanContract)
  128. .filter(
  129. PlPlanContract.tenant_no == tenant_no,
  130. PlPlanContract.plan_id.in_(plan_ids),
  131. PlPlanContract.del_flag == 0,
  132. )
  133. .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc())
  134. .all()
  135. )
  136. grouped: dict[str, list[PlPlanContract]] = defaultdict(list)
  137. for row in rows:
  138. grouped[row.plan_id].append(row)
  139. return grouped
  140. def _contracts_for_stop(
  141. contract_rows: list[PlPlanContract],
  142. stop_id: str,
  143. legacy_sides: tuple[str, ...],
  144. ) -> list[PlanContractLink]:
  145. by_stop = [_contract_link(r) for r in contract_rows if (r.stop_id or "") == stop_id]
  146. if by_stop:
  147. return by_stop
  148. return [
  149. _contract_link(r)
  150. for r in contract_rows
  151. if not (r.stop_id or "").strip() and r.side in legacy_sides
  152. ]
  153. def _to_decimal(value) -> Optional[Decimal]:
  154. if value is None or value == "":
  155. return None
  156. try:
  157. return Decimal(str(value))
  158. except Exception:
  159. return None
  160. def resolve_stop_address_from_library(
  161. db: Session,
  162. tenant_no: str,
  163. address: Optional[str],
  164. lng,
  165. lat,
  166. ) -> Optional[str]:
  167. """段 address 为空时,按经纬度从运输地址库补全 full_address。"""
  168. text = (address or "").strip()
  169. if text:
  170. return text
  171. lat_d = _to_decimal(lat)
  172. lng_d = _to_decimal(lng)
  173. if lat_d is None and lng_d is None:
  174. return text or None
  175. query = db.query(PlTransportAddress).filter(
  176. PlTransportAddress.tenant_no == tenant_no,
  177. PlTransportAddress.del_flag == 0,
  178. PlTransportAddress.status == "1",
  179. )
  180. if lat_d is not None:
  181. query = query.filter(PlTransportAddress.lat == lat_d)
  182. if lng_d is not None:
  183. query = query.filter(PlTransportAddress.lng == lng_d)
  184. rows = query.order_by(PlTransportAddress.create_time.desc()).all()
  185. if not rows:
  186. return text or None
  187. if len(rows) == 1:
  188. full = (rows[0].full_address or "").strip()
  189. return full or None
  190. if lng_d is not None:
  191. for row in rows:
  192. if row.lng == lng_d:
  193. full = (row.full_address or "").strip()
  194. if full:
  195. return full
  196. full = (rows[0].full_address or "").strip()
  197. return full or None
  198. def enrich_stop_addresses_from_library(
  199. db: Session,
  200. tenant_no: str,
  201. load: PlanLoadBody,
  202. deliveries: list[PlanDeliveryBody],
  203. ) -> None:
  204. resolved_load = resolve_stop_address_from_library(
  205. db, tenant_no, load.address, load.lng, load.lat
  206. )
  207. if resolved_load:
  208. load.address = resolved_load
  209. for delivery in deliveries:
  210. resolved = resolve_stop_address_from_library(
  211. db, tenant_no, delivery.address, delivery.lng, delivery.lat
  212. )
  213. if resolved:
  214. delivery.address = resolved
  215. def load_plan_stops_grouped(
  216. db: Session,
  217. tenant_no: str,
  218. plan_ids: list[str],
  219. ) -> dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]]:
  220. """批量读取装货段与送货段(列表/详情共用)。"""
  221. if not plan_ids:
  222. return {}
  223. loads = (
  224. db.query(PlPlanLoad)
  225. .filter(
  226. PlPlanLoad.tenant_no == tenant_no,
  227. PlPlanLoad.plan_id.in_(plan_ids),
  228. PlPlanLoad.del_flag == 0,
  229. )
  230. .all()
  231. )
  232. load_by_plan = {row.plan_id: row for row in loads}
  233. load_ids = [row.id for row in loads]
  234. load_products: dict[str, list[PlPlanLoadProduct]] = defaultdict(list)
  235. if load_ids:
  236. for row in (
  237. db.query(PlPlanLoadProduct)
  238. .filter(
  239. PlPlanLoadProduct.tenant_no == tenant_no,
  240. PlPlanLoadProduct.load_id.in_(load_ids),
  241. PlPlanLoadProduct.del_flag == 0,
  242. )
  243. .order_by(PlPlanLoadProduct.order_num.asc(), PlPlanLoadProduct.create_time.asc())
  244. .all()
  245. ):
  246. load_products[row.load_id].append(row)
  247. deliveries = (
  248. db.query(PlPlanDelivery)
  249. .filter(
  250. PlPlanDelivery.tenant_no == tenant_no,
  251. PlPlanDelivery.plan_id.in_(plan_ids),
  252. PlPlanDelivery.del_flag == 0,
  253. )
  254. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  255. .all()
  256. )
  257. deliveries_by_plan: dict[str, list[PlPlanDelivery]] = defaultdict(list)
  258. delivery_ids: list[str] = []
  259. for row in deliveries:
  260. deliveries_by_plan[row.plan_id].append(row)
  261. delivery_ids.append(row.id)
  262. delivery_products: dict[str, list[PlPlanDeliveryProduct]] = defaultdict(list)
  263. if delivery_ids:
  264. for row in (
  265. db.query(PlPlanDeliveryProduct)
  266. .filter(
  267. PlPlanDeliveryProduct.tenant_no == tenant_no,
  268. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  269. PlPlanDeliveryProduct.del_flag == 0,
  270. )
  271. .order_by(PlPlanDeliveryProduct.order_num.asc(), PlPlanDeliveryProduct.create_time.asc())
  272. .all()
  273. ):
  274. delivery_products[row.delivery_id].append(row)
  275. stop_product_ids: list[str] = []
  276. crm_product_ids: set[str] = set()
  277. for rows in load_products.values():
  278. for p in rows:
  279. if p.id:
  280. stop_product_ids.append(p.id)
  281. if (p.product_id or "").strip():
  282. crm_product_ids.add(p.product_id.strip())
  283. for rows in delivery_products.values():
  284. for p in rows:
  285. if p.id:
  286. stop_product_ids.append(p.id)
  287. if (p.product_id or "").strip():
  288. crm_product_ids.add(p.product_id.strip())
  289. brand_qtys_by_stop = load_brand_qtys_by_stop_product_ids(
  290. db, tenant_no=tenant_no, stop_product_ids=stop_product_ids
  291. )
  292. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  293. db, tenant_no=tenant_no, product_ids=crm_product_ids
  294. )
  295. contracts_by_plan = load_stop_contract_rows_grouped(db, tenant_no, plan_ids)
  296. result: dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]] = {}
  297. for plan_id in plan_ids:
  298. contract_rows = contracts_by_plan.get(plan_id, [])
  299. load_row = load_by_plan.get(plan_id)
  300. load_read: Optional[PlanLoadRead] = None
  301. if load_row:
  302. load_read = PlanLoadRead.model_validate(load_row)
  303. resolved_load_address = resolve_stop_address_from_library(
  304. db,
  305. tenant_no,
  306. load_read.address,
  307. load_read.lng,
  308. load_read.lat,
  309. )
  310. if resolved_load_address:
  311. load_read.address = resolved_load_address
  312. load_read.products = [
  313. _load_stop_product(
  314. p,
  315. brand_qtys_by_stop,
  316. standard_by_id=standard_by_id,
  317. alias_options_by_id=alias_options_by_id,
  318. )
  319. for p in load_products.get(load_row.id, [])
  320. ]
  321. load_read.contracts = _contracts_for_stop(
  322. contract_rows,
  323. load_row.id,
  324. (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER),
  325. )
  326. delivery_reads: list[PlanDeliveryRead] = []
  327. plan_deliveries = deliveries_by_plan.get(plan_id, [])
  328. for idx, delivery_row in enumerate(plan_deliveries):
  329. delivery_read = PlanDeliveryRead.model_validate(delivery_row)
  330. resolved_delivery_address = resolve_stop_address_from_library(
  331. db,
  332. tenant_no,
  333. delivery_read.address,
  334. delivery_read.lng,
  335. delivery_read.lat,
  336. )
  337. if resolved_delivery_address:
  338. delivery_read.address = resolved_delivery_address
  339. delivery_read.products = [
  340. _delivery_stop_product(
  341. p,
  342. brand_qtys_by_stop,
  343. standard_by_id=standard_by_id,
  344. alias_options_by_id=alias_options_by_id,
  345. )
  346. for p in delivery_products.get(delivery_row.id, [])
  347. ]
  348. legacy_sides = (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER)
  349. # 未迁移合同仅挂到第一个送货段
  350. if idx == 0:
  351. delivery_read.contracts = _contracts_for_stop(
  352. contract_rows,
  353. delivery_row.id,
  354. legacy_sides,
  355. )
  356. else:
  357. delivery_read.contracts = _contracts_for_stop(
  358. contract_rows,
  359. delivery_row.id,
  360. (PLAN_CONTRACT_SIDE_DELIVERY,),
  361. )
  362. delivery_reads.append(delivery_read)
  363. result[plan_id] = (load_read, delivery_reads)
  364. return result
  365. def resolve_load_customer_name(
  366. plan: PlPlan,
  367. load: Optional[PlanLoadRead],
  368. ) -> Optional[str]:
  369. if load and (load.customer_name or "").strip():
  370. return load.customer_name.strip()
  371. legacy = (plan.supplier_name or "").strip()
  372. return legacy or None
  373. def resolve_delivery_customer_summary(
  374. plan: PlPlan,
  375. deliveries: list[PlanDeliveryRead],
  376. ) -> Optional[str]:
  377. names: list[str] = []
  378. seen: set[str] = set()
  379. for delivery in deliveries:
  380. name = (delivery.customer_name or "").strip()
  381. if name and name not in seen:
  382. seen.add(name)
  383. names.append(name)
  384. if names:
  385. if len(names) <= 2:
  386. return "、".join(names)
  387. return f"{names[0]}、{names[1]}等{len(names)}家"
  388. legacy = (plan.customer_name or plan.destination_name or "").strip()
  389. return legacy or None
  390. def attach_plan_stops_to_item(
  391. plan_item,
  392. plan: PlPlan,
  393. stops: tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]],
  394. ) -> None:
  395. load_read, delivery_reads = stops
  396. plan_item.load = load_read
  397. plan_item.deliveries = delivery_reads
  398. plan_item.load_customer_name = resolve_load_customer_name(plan, load_read)
  399. plan_item.delivery_customer_summary = resolve_delivery_customer_summary(plan, delivery_reads)
  400. if load_read and (load_read.address or "").strip():
  401. plan_item.pickup_address = load_read.address
  402. if delivery_reads and (delivery_reads[0].address or "").strip():
  403. plan_item.destination_address = delivery_reads[0].address
  404. def uses_multi_stop_payload(load: Optional[PlanLoadBody], deliveries: Optional[list[PlanDeliveryBody]]) -> bool:
  405. return load is not None or deliveries is not None
  406. def _normalize_contract_links(links: Optional[list[PlanContractLink]]) -> list[PlanContractLink]:
  407. if not links:
  408. return []
  409. seen: set[str] = set()
  410. out: list[PlanContractLink] = []
  411. for link in links:
  412. cid = (link.contract_id or "").strip()
  413. if not cid or cid in seen:
  414. continue
  415. seen.add(cid)
  416. out.append(link)
  417. return out
  418. def assert_at_most_one_contract(
  419. links: list[PlanContractLink],
  420. side_label: str,
  421. ) -> list[PlanContractLink]:
  422. if len(links) > 1:
  423. raise HTTPException(
  424. status_code=422,
  425. detail=f"{side_label}只能选择 1 份",
  426. )
  427. return links
  428. def _assert_product_rows_in_contract_allowed(
  429. products: Optional[list],
  430. allowed: set[str],
  431. *,
  432. row_label: str = "装货产品",
  433. ) -> None:
  434. for prod in products or []:
  435. pid = str(getattr(prod, "product_id", None) or "").strip()
  436. name = (getattr(prod, "product_name", None) or pid or "未命名").strip()
  437. if not pid or pid not in allowed:
  438. raise HTTPException(
  439. status_code=422,
  440. detail=f"{row_label}「{name}」不在所选合同明细中",
  441. )
  442. def _contract_product_rows(db: Session, tenant_no: str, contract_id: str) -> list:
  443. return (
  444. db.query(CrmContractProduct)
  445. .filter(
  446. CrmContractProduct.contract_id == contract_id,
  447. CrmContractProduct.tenant_no == tenant_no,
  448. CrmContractProduct.del_flag == 0,
  449. )
  450. .all()
  451. )
  452. def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) -> set[str]:
  453. rows = _contract_product_rows(db, tenant_no, contract_id)
  454. return {
  455. str(r.product_id).strip()
  456. for r in rows
  457. if getattr(r, "product_id", None) and str(r.product_id).strip()
  458. }
  459. def _assert_stop_products_package_for_contract(
  460. db: Session,
  461. tenant_no: str,
  462. contract_id: str,
  463. stop_products: Optional[list],
  464. ) -> None:
  465. from app.services.pl_contract_plan_qty import (
  466. validate_stop_products_package_against_contract,
  467. )
  468. rows = _contract_product_rows(db, tenant_no, contract_id)
  469. ok, err = validate_stop_products_package_against_contract(
  470. contract_products=rows,
  471. stop_products=stop_products or [],
  472. )
  473. if not ok:
  474. raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细")
  475. def assert_load_products_in_selected_contract(
  476. db: Session,
  477. tenant_no: str,
  478. load: PlanLoadBody,
  479. plan_products: Optional[list] = None,
  480. ) -> None:
  481. if is_internal_customer_name(load.customer_name):
  482. return
  483. links = _normalize_contract_links(load.contracts)
  484. if not links:
  485. return
  486. contract_id = (links[0].contract_id or "").strip()
  487. if not contract_id:
  488. return
  489. allowed = _contract_allowed_product_ids(db, tenant_no, contract_id)
  490. _assert_product_rows_in_contract_allowed(load.products, allowed, row_label="装货产品")
  491. _assert_product_rows_in_contract_allowed(plan_products, allowed, row_label="装货产品")
  492. merged = list(load.products or []) + list(plan_products or [])
  493. _assert_stop_products_package_for_contract(db, tenant_no, contract_id, merged)
  494. def assert_delivery_products_in_selected_contract(
  495. db: Session,
  496. tenant_no: str,
  497. delivery: PlanDeliveryBody,
  498. seq: int,
  499. ) -> None:
  500. if is_internal_customer_name(delivery.customer_name):
  501. return
  502. links = _normalize_contract_links(delivery.contracts)
  503. if not links:
  504. return
  505. contract_id = (links[0].contract_id or "").strip()
  506. if not contract_id:
  507. return
  508. allowed = _contract_allowed_product_ids(db, tenant_no, contract_id)
  509. _assert_product_rows_in_contract_allowed(
  510. delivery.products,
  511. allowed,
  512. row_label=f"送货段#{seq}产品",
  513. )
  514. _assert_stop_products_package_for_contract(
  515. db, tenant_no, contract_id, delivery.products
  516. )
  517. def assert_stop_contract_required(
  518. customer_name: Optional[str],
  519. contracts: Optional[list[PlanContractLink]],
  520. side_label: str,
  521. ) -> None:
  522. """非内部客户装/送货段保存时必须至少选一份合同。"""
  523. if is_internal_customer_name(customer_name):
  524. return
  525. if not _normalize_contract_links(contracts):
  526. raise HTTPException(status_code=422, detail=f"请为{side_label}选择合同")
  527. def _resolve_stop_contracts(
  528. db: Session,
  529. tenant_no: str,
  530. customer_id: Optional[str],
  531. customer_name: Optional[str],
  532. contracts: Optional[list[PlanContractLink]],
  533. side_label: str,
  534. ) -> list[PlanContractLink]:
  535. if is_internal_customer_name(customer_name):
  536. return []
  537. links = _normalize_contract_links(contracts)
  538. links = assert_at_most_one_contract(links, side_label)
  539. if not links:
  540. return []
  541. resolved: list[PlanContractLink] = []
  542. for link in links:
  543. contract = assert_contract_selectable_for_plan(
  544. db,
  545. link.contract_id,
  546. tenant_no,
  547. expected_customer_id=customer_id,
  548. side_label=side_label,
  549. )
  550. resolved.append(
  551. PlanContractLink(
  552. contractId=contract.id,
  553. contractNo=contract.contract_no or link.contract_no,
  554. )
  555. )
  556. return resolved
  557. def validate_plan_stops_write(
  558. db: Session,
  559. tenant_no: str,
  560. load: Optional[PlanLoadBody],
  561. deliveries: Optional[list[PlanDeliveryBody]],
  562. products: Optional[list],
  563. ) -> tuple[PlanLoadBody, list[PlanDeliveryBody]]:
  564. if load is None:
  565. raise HTTPException(status_code=422, detail="请填写装货信息")
  566. if not (load.customer_id or "").strip() or not (load.customer_name or "").strip():
  567. raise HTTPException(status_code=422, detail="请选择装货客户")
  568. if not deliveries:
  569. raise HTTPException(status_code=422, detail="请至少添加一段送货信息")
  570. if products is not None and not products:
  571. raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
  572. assert_stop_contract_required(load.customer_name, load.contracts, "装货客户")
  573. load.contracts = _resolve_stop_contracts(
  574. db,
  575. tenant_no,
  576. load.customer_id,
  577. load.customer_name,
  578. load.contracts,
  579. "装货合同",
  580. )
  581. resolved_deliveries: list[PlanDeliveryBody] = []
  582. for idx, delivery in enumerate(deliveries, start=1):
  583. if not (delivery.customer_id or "").strip() or not (delivery.customer_name or "").strip():
  584. raise HTTPException(status_code=422, detail=f"送货段#{idx}请选择客户")
  585. assert_stop_contract_required(delivery.customer_name, delivery.contracts, f"送货段#{idx}")
  586. delivery.contracts = _resolve_stop_contracts(
  587. db,
  588. tenant_no,
  589. delivery.customer_id,
  590. delivery.customer_name,
  591. delivery.contracts,
  592. f"送货段#{idx}合同",
  593. )
  594. resolved_deliveries.append(delivery)
  595. enrich_stop_addresses_from_library(db, tenant_no, load, resolved_deliveries)
  596. assert_load_products_in_selected_contract(db, tenant_no, load, plan_products=products)
  597. for idx, delivery in enumerate(resolved_deliveries, start=1):
  598. assert_delivery_products_in_selected_contract(db, tenant_no, delivery, idx)
  599. return load, resolved_deliveries
  600. def collect_stop_qty_warnings(
  601. products: Optional[list],
  602. load: PlanLoadBody,
  603. deliveries: list[PlanDeliveryBody],
  604. ) -> list[str]:
  605. plan_rows = products or []
  606. load_rows = load.products or []
  607. delivery_rows: list[PlanStopProductBody] = []
  608. for delivery in deliveries:
  609. delivery_rows.extend(delivery.products or [])
  610. return warn_qty_imbalance(plan_rows, load_rows, delivery_rows)
  611. def sync_plan_legacy_from_stops(
  612. plan: PlPlan,
  613. load: PlanLoadBody,
  614. deliveries: list[PlanDeliveryBody],
  615. ) -> None:
  616. plan.supplier_id = load.customer_id
  617. plan.supplier_name = load.customer_name
  618. plan.pickup_address = load.address
  619. plan.pickup_lng = load.lng
  620. plan.pickup_lat = load.lat
  621. plan.pickup_time = load.load_time
  622. plan.pickup_contact = load.contact
  623. plan.pickup_notes = load.notes
  624. first = deliveries[0]
  625. plan.customer_id = first.customer_id
  626. plan.customer_name = first.customer_name
  627. plan.destination_id = first.customer_id
  628. plan.destination_name = first.customer_name
  629. plan.destination_address = first.address
  630. plan.destination_lng = first.lng
  631. plan.destination_lat = first.lat
  632. plan.delivery_time = first.delivery_time
  633. plan.delivery_contact = first.contact
  634. plan.destination_notes = first.notes
  635. def _soft_delete_load_products_and_stop_contracts(
  636. db: Session,
  637. tenant_no: str,
  638. plan_id: str,
  639. user_id: Optional[str],
  640. ) -> None:
  641. """软删装货产品与装/送货段合同;送货段本体由 upsert 保留主键,不在此全量软删。"""
  642. now = datetime.now()
  643. load_ids = [
  644. row.id
  645. for row in db.query(PlPlanLoad)
  646. .filter(
  647. PlPlanLoad.plan_id == plan_id,
  648. PlPlanLoad.tenant_no == tenant_no,
  649. PlPlanLoad.del_flag == 0,
  650. )
  651. .all()
  652. ]
  653. if load_ids:
  654. db.query(PlPlanLoadProduct).filter(
  655. PlPlanLoadProduct.load_id.in_(load_ids),
  656. PlPlanLoadProduct.del_flag == 0,
  657. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  658. db.query(PlPlanContract).filter(
  659. PlPlanContract.plan_id == plan_id,
  660. PlPlanContract.tenant_no == tenant_no,
  661. PlPlanContract.del_flag == 0,
  662. PlPlanContract.side.in_(
  663. [PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_DELIVERY]
  664. ),
  665. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  666. def _soft_delete_delivery_products(
  667. db: Session,
  668. delivery_ids: list[str],
  669. user_id: Optional[str],
  670. now: datetime,
  671. ) -> None:
  672. if not delivery_ids:
  673. return
  674. db.query(PlPlanDeliveryProduct).filter(
  675. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  676. PlPlanDeliveryProduct.del_flag == 0,
  677. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  678. def resolve_delivery_id_for_upsert(
  679. *,
  680. plan_id: str,
  681. payload_id: Optional[str],
  682. existing_by_id: dict,
  683. ) -> tuple[str, Optional[object]]:
  684. pid = (payload_id or "").strip()
  685. if not pid:
  686. return generate_uuid(), None
  687. row = existing_by_id.get(pid)
  688. if row is None or getattr(row, "plan_id", None) != plan_id:
  689. raise HTTPException(status_code=422, detail=f"送货段 id 无效或不属于本计划:{pid}")
  690. return pid, row
  691. def _write_stop_contracts(
  692. db: Session,
  693. tenant_no: str,
  694. plan_id: str,
  695. stop_id: str,
  696. side: str,
  697. contracts: list[PlanContractLink],
  698. user_id: Optional[str],
  699. dept_id: Optional[str],
  700. order_num_start: int,
  701. ) -> int:
  702. now = datetime.now()
  703. order_num = order_num_start
  704. for link in contracts:
  705. order_num += 1
  706. db.add(
  707. PlPlanContract(
  708. id=generate_uuid(),
  709. plan_id=plan_id,
  710. side=side,
  711. stop_id=stop_id,
  712. contract_id=link.contract_id,
  713. contract_no=link.contract_no,
  714. tenant_no=tenant_no,
  715. order_num=order_num,
  716. create_by=user_id,
  717. create_dept_id=dept_id or "",
  718. create_time=now,
  719. update_by=user_id,
  720. update_time=now,
  721. del_flag=0,
  722. )
  723. )
  724. return order_num
  725. def _resolve_plan_product_id(
  726. prod: PlanStopProductBody,
  727. plan_products: list,
  728. ) -> str:
  729. explicit = (prod.plan_product_id or "").strip()
  730. if explicit:
  731. for row in plan_products:
  732. if row.id == explicit:
  733. return explicit
  734. product_id = (prod.product_id or "").strip()
  735. if product_id:
  736. for row in plan_products:
  737. if (row.product_id or "").strip() == product_id:
  738. return row.id
  739. product_no = (prod.product_no or "").strip()
  740. if product_no:
  741. for row in plan_products:
  742. if (row.product_no or "").strip() == product_no:
  743. return row.id
  744. raise HTTPException(
  745. status_code=422,
  746. detail=f"无法匹配计划产品行:{prod.product_name or explicit or product_id}",
  747. )
  748. def _resolve_or_create_plan_load(
  749. db: Session,
  750. tenant_no: str,
  751. plan_id: str,
  752. load: PlanLoadBody,
  753. user_id: Optional[str],
  754. dept_id: Optional[str],
  755. now: datetime,
  756. ) -> str:
  757. """装货段与 plan 1:1;uk_pl_plan_load_plan 仅按 plan_id 唯一,软删后不可再 INSERT,须原地更新。"""
  758. generate_dibang, dibang_direction = normalize_stop_dibang_fields(
  759. load.customer_name,
  760. "load",
  761. load.generate_dibang,
  762. load.dibang_direction,
  763. )
  764. existing = (
  765. db.query(PlPlanLoad)
  766. .filter(
  767. PlPlanLoad.plan_id == plan_id,
  768. PlPlanLoad.tenant_no == tenant_no,
  769. )
  770. .order_by(PlPlanLoad.del_flag.asc(), PlPlanLoad.update_time.desc())
  771. .first()
  772. )
  773. if existing:
  774. existing.customer_id = load.customer_id
  775. existing.customer_name = load.customer_name
  776. existing.address = load.address
  777. existing.lng = load.lng
  778. existing.lat = load.lat
  779. existing.load_time = load.load_time
  780. existing.contact = load.contact
  781. existing.notes = load.notes
  782. existing.generate_dibang = generate_dibang
  783. existing.dibang_direction = dibang_direction
  784. existing.remark = load.remark
  785. existing.del_flag = 0
  786. existing.update_by = user_id
  787. existing.update_time = now
  788. return existing.id
  789. load_id = generate_uuid()
  790. db.add(
  791. PlPlanLoad(
  792. id=load_id,
  793. plan_id=plan_id,
  794. customer_id=load.customer_id,
  795. customer_name=load.customer_name,
  796. address=load.address,
  797. lng=load.lng,
  798. lat=load.lat,
  799. load_time=load.load_time,
  800. contact=load.contact,
  801. notes=load.notes,
  802. generate_dibang=generate_dibang,
  803. dibang_direction=dibang_direction,
  804. tenant_no=tenant_no,
  805. remark=load.remark,
  806. create_by=user_id,
  807. create_dept_id=dept_id or "",
  808. create_time=now,
  809. update_by=user_id,
  810. update_time=now,
  811. del_flag=0,
  812. )
  813. )
  814. return load_id
  815. def replace_plan_stops(
  816. db: Session,
  817. tenant_no: str,
  818. plan_id: str,
  819. load: PlanLoadBody,
  820. deliveries: list[PlanDeliveryBody],
  821. user_id: Optional[str],
  822. dept_id: Optional[str],
  823. plan_products: Optional[list] = None,
  824. ) -> None:
  825. _soft_delete_load_products_and_stop_contracts(db, tenant_no, plan_id, user_id)
  826. now = datetime.now()
  827. load_id = _resolve_or_create_plan_load(
  828. db,
  829. tenant_no,
  830. plan_id,
  831. load,
  832. user_id,
  833. dept_id,
  834. now,
  835. )
  836. plan_product_rows = plan_products or []
  837. for idx, prod in enumerate(load.products or []):
  838. plan_product_id = _resolve_plan_product_id(prod, plan_product_rows)
  839. db.add(
  840. PlPlanLoadProduct(
  841. id=generate_uuid(),
  842. load_id=load_id,
  843. plan_id=plan_id,
  844. plan_product_id=plan_product_id,
  845. product_id=prod.product_id,
  846. product_no=prod.product_no,
  847. product_name=prod.product_name,
  848. model=prod.model,
  849. unit=prod.unit or "吨",
  850. package_spec=prod.package_spec,
  851. delivery_method=prod.delivery_method,
  852. plan_qty=prod.plan_qty,
  853. tenant_no=tenant_no,
  854. order_num=idx,
  855. remark=prod.remark,
  856. create_by=user_id,
  857. create_dept_id=dept_id or "",
  858. create_time=now,
  859. update_by=user_id,
  860. update_time=now,
  861. del_flag=0,
  862. )
  863. )
  864. contract_order = 0
  865. contract_order = _write_stop_contracts(
  866. db,
  867. tenant_no,
  868. plan_id,
  869. load_id,
  870. PLAN_CONTRACT_SIDE_LOAD,
  871. load.contracts or [],
  872. user_id,
  873. dept_id,
  874. contract_order,
  875. )
  876. existing_deliveries = (
  877. db.query(PlPlanDelivery)
  878. .filter(
  879. PlPlanDelivery.plan_id == plan_id,
  880. PlPlanDelivery.tenant_no == tenant_no,
  881. )
  882. .all()
  883. )
  884. existing_by_id = {row.id: row for row in existing_deliveries}
  885. kept_ids: set[str] = set()
  886. for delivery in deliveries:
  887. generate_dibang, dibang_direction = normalize_stop_dibang_fields(
  888. delivery.customer_name,
  889. "delivery",
  890. delivery.generate_dibang,
  891. delivery.dibang_direction,
  892. )
  893. delivery_id, existing = resolve_delivery_id_for_upsert(
  894. plan_id=plan_id,
  895. payload_id=delivery.id,
  896. existing_by_id=existing_by_id,
  897. )
  898. kept_ids.add(delivery_id)
  899. if existing is not None:
  900. existing.seq = delivery.seq or 1
  901. existing.customer_id = delivery.customer_id
  902. existing.customer_name = delivery.customer_name
  903. existing.address = delivery.address
  904. existing.lng = delivery.lng
  905. existing.lat = delivery.lat
  906. existing.delivery_time = delivery.delivery_time
  907. existing.contact = delivery.contact
  908. existing.notes = delivery.notes
  909. existing.generate_dibang = generate_dibang
  910. existing.dibang_direction = dibang_direction
  911. existing.remark = delivery.remark
  912. existing.del_flag = 0
  913. existing.update_by = user_id
  914. existing.update_time = now
  915. else:
  916. db.add(
  917. PlPlanDelivery(
  918. id=delivery_id,
  919. plan_id=plan_id,
  920. seq=delivery.seq or 1,
  921. customer_id=delivery.customer_id,
  922. customer_name=delivery.customer_name,
  923. address=delivery.address,
  924. lng=delivery.lng,
  925. lat=delivery.lat,
  926. delivery_time=delivery.delivery_time,
  927. contact=delivery.contact,
  928. notes=delivery.notes,
  929. generate_dibang=generate_dibang,
  930. dibang_direction=dibang_direction,
  931. tenant_no=tenant_no,
  932. remark=delivery.remark,
  933. create_by=user_id,
  934. create_dept_id=dept_id or "",
  935. create_time=now,
  936. update_by=user_id,
  937. update_time=now,
  938. del_flag=0,
  939. )
  940. )
  941. _soft_delete_delivery_products(db, [delivery_id], user_id, now)
  942. for idx, prod in enumerate(delivery.products or []):
  943. plan_product_id = _resolve_plan_product_id(prod, plan_product_rows)
  944. db.add(
  945. PlPlanDeliveryProduct(
  946. id=generate_uuid(),
  947. delivery_id=delivery_id,
  948. plan_id=plan_id,
  949. plan_product_id=plan_product_id,
  950. product_id=prod.product_id,
  951. product_no=prod.product_no,
  952. product_name=prod.product_name,
  953. model=prod.model,
  954. unit=prod.unit or "吨",
  955. package_spec=prod.package_spec,
  956. delivery_method=prod.delivery_method,
  957. plan_qty=prod.plan_qty,
  958. tenant_no=tenant_no,
  959. order_num=idx,
  960. remark=prod.remark,
  961. create_by=user_id,
  962. create_dept_id=dept_id or "",
  963. create_time=now,
  964. update_by=user_id,
  965. update_time=now,
  966. del_flag=0,
  967. )
  968. )
  969. contract_order = _write_stop_contracts(
  970. db,
  971. tenant_no,
  972. plan_id,
  973. delivery_id,
  974. PLAN_CONTRACT_SIDE_DELIVERY,
  975. delivery.contracts or [],
  976. user_id,
  977. dept_id,
  978. contract_order,
  979. )
  980. removed_ids = [row.id for row in existing_deliveries if row.id not in kept_ids and row.del_flag == 0]
  981. if removed_ids:
  982. _soft_delete_delivery_products(db, removed_ids, user_id, now)
  983. db.query(PlPlanDelivery).filter(
  984. PlPlanDelivery.id.in_(removed_ids),
  985. PlPlanDelivery.tenant_no == tenant_no,
  986. PlPlanDelivery.del_flag == 0,
  987. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)