pl_plan_stop_service.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398
  1. """运输计划 · 装货/送货段读取与列表摘要。"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from datetime import date, 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.config import settings
  10. from app.services.pl_mobile_stop_meta import load_stop_meta, write_stop_meta
  11. from app.core.utils import generate_uuid
  12. from app.services.pl_mobile_product_identity import resolve_mobile_product_link, is_existing_pending_delivery
  13. from app.models.crm_models import CrmContract, CrmContractProduct
  14. from app.models.pl_models import (
  15. PlPlan,
  16. PlPlanContract,
  17. PlPlanDelivery,
  18. PlPlanDeliveryProduct,
  19. PlPlanLoad,
  20. PlPlanLoadProduct,
  21. PlTransportAddress,
  22. )
  23. from app.schemas.pl.plan import (
  24. PlanArchiveBrandQtyItem,
  25. PlanContractLink,
  26. PlanDeliveryBody,
  27. PlanDeliveryRead,
  28. PlanLoadBody,
  29. PlanLoadRead,
  30. PlanStopProductBody,
  31. PlanStopProductRead,
  32. )
  33. from app.services.pl_brand_archive import load_brand_qtys_by_stop_product_ids
  34. from app.services.pl_internal_customer import is_internal_customer_name
  35. from app.services.pl_plan_stop_rules import normalize_stop_dibang_fields, warn_qty_imbalance
  36. from app.services.pl_plan_service import (
  37. PLAN_CONTRACT_SIDE_DEMANDER,
  38. PLAN_CONTRACT_SIDE_SUPPLIER,
  39. assert_contract_selectable_for_plan,
  40. )
  41. from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names
  42. PLAN_CONTRACT_SIDE_LOAD = "load"
  43. PLAN_CONTRACT_SIDE_DELIVERY = "delivery"
  44. def _contract_link(row: PlPlanContract) -> PlanContractLink:
  45. return PlanContractLink(
  46. contractId=row.contract_id,
  47. contractNo=row.contract_no,
  48. stopId=row.stop_id,
  49. )
  50. def _attach_contract_nos_to_stop_products(
  51. db: Session,
  52. tenant_no: str,
  53. products: list[PlanStopProductRead],
  54. contracts: list[PlanContractLink],
  55. ) -> None:
  56. """段产品行展示用合同编号:优先段合同关联,其次按 contract_id 查 CRM。"""
  57. if not products:
  58. return
  59. by_id = {
  60. str(c.contract_id).strip(): (c.contract_no or "").strip()
  61. for c in contracts or []
  62. if (c.contract_id or "").strip()
  63. }
  64. missing: list[str] = []
  65. for product in products:
  66. cid = (product.contract_id or "").strip()
  67. if not cid:
  68. continue
  69. no = by_id.get(cid) or (product.contract_no or "").strip()
  70. if no:
  71. product.contract_no = no
  72. by_id.setdefault(cid, no)
  73. else:
  74. missing.append(cid)
  75. need = [cid for cid in dict.fromkeys(missing) if cid not in by_id or not by_id.get(cid)]
  76. if not need:
  77. return
  78. rows = (
  79. db.query(CrmContract.id, CrmContract.contract_no)
  80. .filter(
  81. CrmContract.tenant_no == tenant_no,
  82. CrmContract.del_flag == 0,
  83. CrmContract.id.in_(need),
  84. )
  85. .all()
  86. )
  87. for cid, cno in rows:
  88. key = str(cid or "").strip()
  89. if key and (cno or "").strip():
  90. by_id[key] = str(cno).strip()
  91. for product in products:
  92. cid = (product.contract_id or "").strip()
  93. if not cid:
  94. continue
  95. no = by_id.get(cid)
  96. if no:
  97. product.contract_no = no
  98. def _attach_stop_brand_qtys(
  99. product: PlanStopProductRead,
  100. brand_qtys_by_stop: dict[str, list[dict]],
  101. ) -> PlanStopProductRead:
  102. raw = brand_qtys_by_stop.get(product.id) or None
  103. if raw:
  104. product.brand_qtys = [PlanArchiveBrandQtyItem.model_validate(x) for x in raw]
  105. else:
  106. product.brand_qtys = None
  107. return product
  108. def _resolve_stop_list_product_name(
  109. *,
  110. saved_product_name: Optional[str],
  111. product_id: Optional[str],
  112. standard_by_id: dict[str, str],
  113. alias_options_by_id: dict[str, list[str]],
  114. ) -> str:
  115. """列表展示用:优先 CRM 标准名,不展示别名。"""
  116. display_name, _alias = resolve_product_display_names(
  117. saved_product_name=saved_product_name,
  118. product_id=product_id,
  119. standard_by_id=standard_by_id,
  120. alias_options_by_id=alias_options_by_id,
  121. )
  122. return display_name or (saved_product_name or "")
  123. def _apply_stop_list_product_name(
  124. product: PlanStopProductRead,
  125. *,
  126. standard_by_id: dict[str, str],
  127. alias_options_by_id: dict[str, list[str]],
  128. ) -> PlanStopProductRead:
  129. product.product_name = _resolve_stop_list_product_name(
  130. saved_product_name=product.product_name,
  131. product_id=product.product_id,
  132. standard_by_id=standard_by_id,
  133. alias_options_by_id=alias_options_by_id,
  134. )
  135. return product
  136. def _load_stop_product(
  137. row: PlPlanLoadProduct,
  138. brand_qtys_by_stop: dict[str, list[dict]] | None = None,
  139. *,
  140. standard_by_id: dict[str, str] | None = None,
  141. alias_options_by_id: dict[str, list[str]] | None = None,
  142. ) -> PlanStopProductRead:
  143. product = PlanStopProductRead.model_validate(row)
  144. if standard_by_id is not None and alias_options_by_id is not None:
  145. product = _apply_stop_list_product_name(
  146. product,
  147. standard_by_id=standard_by_id,
  148. alias_options_by_id=alias_options_by_id,
  149. )
  150. if brand_qtys_by_stop is not None:
  151. return _attach_stop_brand_qtys(product, brand_qtys_by_stop)
  152. return product
  153. def _delivery_stop_product(
  154. row: PlPlanDeliveryProduct,
  155. brand_qtys_by_stop: dict[str, list[dict]] | None = None,
  156. *,
  157. standard_by_id: dict[str, str] | None = None,
  158. alias_options_by_id: dict[str, list[str]] | None = None,
  159. ) -> PlanStopProductRead:
  160. product = PlanStopProductRead.model_validate(row)
  161. if standard_by_id is not None and alias_options_by_id is not None:
  162. product = _apply_stop_list_product_name(
  163. product,
  164. standard_by_id=standard_by_id,
  165. alias_options_by_id=alias_options_by_id,
  166. )
  167. if brand_qtys_by_stop is not None:
  168. return _attach_stop_brand_qtys(product, brand_qtys_by_stop)
  169. return product
  170. def load_stop_contract_rows_grouped(
  171. db: Session,
  172. tenant_no: str,
  173. plan_ids: list[str],
  174. ) -> dict[str, list[PlPlanContract]]:
  175. if not plan_ids:
  176. return {}
  177. rows = (
  178. db.query(PlPlanContract)
  179. .filter(
  180. PlPlanContract.tenant_no == tenant_no,
  181. PlPlanContract.plan_id.in_(plan_ids),
  182. PlPlanContract.del_flag == 0,
  183. )
  184. .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc())
  185. .all()
  186. )
  187. grouped: dict[str, list[PlPlanContract]] = defaultdict(list)
  188. for row in rows:
  189. grouped[row.plan_id].append(row)
  190. return grouped
  191. def _contracts_for_stop(
  192. contract_rows: list[PlPlanContract],
  193. stop_id: str,
  194. legacy_sides: tuple[str, ...],
  195. ) -> list[PlanContractLink]:
  196. by_stop = [_contract_link(r) for r in contract_rows if (r.stop_id or "") == stop_id]
  197. if by_stop:
  198. return by_stop
  199. return [
  200. _contract_link(r)
  201. for r in contract_rows
  202. if not (r.stop_id or "").strip() and r.side in legacy_sides
  203. ]
  204. def _to_decimal(value) -> Optional[Decimal]:
  205. if value is None or value == "":
  206. return None
  207. try:
  208. return Decimal(str(value))
  209. except Exception:
  210. return None
  211. def resolve_stop_address_from_library(
  212. db: Session,
  213. tenant_no: str,
  214. address: Optional[str],
  215. lng,
  216. lat,
  217. ) -> Optional[str]:
  218. """段 address 为空时,按经纬度从运输地址库补全 full_address。"""
  219. text = (address or "").strip()
  220. if text:
  221. return text
  222. lat_d = _to_decimal(lat)
  223. lng_d = _to_decimal(lng)
  224. if lat_d is None and lng_d is None:
  225. return text or None
  226. query = db.query(PlTransportAddress).filter(
  227. PlTransportAddress.tenant_no == tenant_no,
  228. PlTransportAddress.del_flag == 0,
  229. PlTransportAddress.status == "1",
  230. )
  231. if lat_d is not None:
  232. query = query.filter(PlTransportAddress.lat == lat_d)
  233. if lng_d is not None:
  234. query = query.filter(PlTransportAddress.lng == lng_d)
  235. rows = query.order_by(PlTransportAddress.create_time.desc()).all()
  236. if not rows:
  237. return text or None
  238. if len(rows) == 1:
  239. full = (rows[0].full_address or "").strip()
  240. return full or None
  241. if lng_d is not None:
  242. for row in rows:
  243. if row.lng == lng_d:
  244. full = (row.full_address or "").strip()
  245. if full:
  246. return full
  247. full = (rows[0].full_address or "").strip()
  248. return full or None
  249. def enrich_stop_addresses_from_library(
  250. db: Session,
  251. tenant_no: str,
  252. load: PlanLoadBody,
  253. deliveries: list[PlanDeliveryBody],
  254. ) -> None:
  255. resolved_load = resolve_stop_address_from_library(
  256. db, tenant_no, load.address, load.lng, load.lat
  257. )
  258. if resolved_load:
  259. load.address = resolved_load
  260. for delivery in deliveries:
  261. resolved = resolve_stop_address_from_library(
  262. db, tenant_no, delivery.address, delivery.lng, delivery.lat
  263. )
  264. if resolved:
  265. delivery.address = resolved
  266. def load_plan_stops_grouped(
  267. db: Session,
  268. tenant_no: str,
  269. plan_ids: list[str],
  270. ) -> dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]]:
  271. """批量读取装货段与送货段(列表/详情共用)。"""
  272. if not plan_ids:
  273. return {}
  274. loads = (
  275. db.query(PlPlanLoad)
  276. .filter(
  277. PlPlanLoad.tenant_no == tenant_no,
  278. PlPlanLoad.plan_id.in_(plan_ids),
  279. PlPlanLoad.del_flag == 0,
  280. )
  281. .all()
  282. )
  283. load_by_plan = {row.plan_id: row for row in loads}
  284. load_ids = [row.id for row in loads]
  285. load_products: dict[str, list[PlPlanLoadProduct]] = defaultdict(list)
  286. if load_ids:
  287. for row in (
  288. db.query(PlPlanLoadProduct)
  289. .filter(
  290. PlPlanLoadProduct.tenant_no == tenant_no,
  291. PlPlanLoadProduct.load_id.in_(load_ids),
  292. PlPlanLoadProduct.del_flag == 0,
  293. )
  294. .order_by(PlPlanLoadProduct.order_num.asc(), PlPlanLoadProduct.create_time.asc())
  295. .all()
  296. ):
  297. load_products[row.load_id].append(row)
  298. deliveries = (
  299. db.query(PlPlanDelivery)
  300. .filter(
  301. PlPlanDelivery.tenant_no == tenant_no,
  302. PlPlanDelivery.plan_id.in_(plan_ids),
  303. PlPlanDelivery.del_flag == 0,
  304. )
  305. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  306. .all()
  307. )
  308. deliveries_by_plan: dict[str, list[PlPlanDelivery]] = defaultdict(list)
  309. delivery_ids: list[str] = []
  310. for row in deliveries:
  311. deliveries_by_plan[row.plan_id].append(row)
  312. delivery_ids.append(row.id)
  313. delivery_products: dict[str, list[PlPlanDeliveryProduct]] = defaultdict(list)
  314. if delivery_ids:
  315. for row in (
  316. db.query(PlPlanDeliveryProduct)
  317. .filter(
  318. PlPlanDeliveryProduct.tenant_no == tenant_no,
  319. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  320. PlPlanDeliveryProduct.del_flag == 0,
  321. )
  322. .order_by(PlPlanDeliveryProduct.order_num.asc(), PlPlanDeliveryProduct.create_time.asc())
  323. .all()
  324. ):
  325. delivery_products[row.delivery_id].append(row)
  326. stop_product_ids: list[str] = []
  327. crm_product_ids: set[str] = set()
  328. for rows in load_products.values():
  329. for p in rows:
  330. if p.id:
  331. stop_product_ids.append(p.id)
  332. if (p.product_id or "").strip():
  333. crm_product_ids.add(p.product_id.strip())
  334. for rows in delivery_products.values():
  335. for p in rows:
  336. if p.id:
  337. stop_product_ids.append(p.id)
  338. if (p.product_id or "").strip():
  339. crm_product_ids.add(p.product_id.strip())
  340. brand_qtys_by_stop = load_brand_qtys_by_stop_product_ids(
  341. db, tenant_no=tenant_no, stop_product_ids=stop_product_ids
  342. )
  343. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  344. db, tenant_no=tenant_no, product_ids=crm_product_ids
  345. )
  346. contracts_by_plan = load_stop_contract_rows_grouped(db, tenant_no, plan_ids)
  347. result: dict[str, tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]]] = {}
  348. for plan_id in plan_ids:
  349. contract_rows = contracts_by_plan.get(plan_id, [])
  350. load_row = load_by_plan.get(plan_id)
  351. load_read: Optional[PlanLoadRead] = None
  352. if load_row:
  353. load_read = PlanLoadRead.model_validate(load_row)
  354. resolved_load_address = resolve_stop_address_from_library(
  355. db,
  356. tenant_no,
  357. load_read.address,
  358. load_read.lng,
  359. load_read.lat,
  360. )
  361. if resolved_load_address:
  362. load_read.address = resolved_load_address
  363. load_read.products = [
  364. _load_stop_product(
  365. p,
  366. brand_qtys_by_stop,
  367. standard_by_id=standard_by_id,
  368. alias_options_by_id=alias_options_by_id,
  369. )
  370. for p in load_products.get(load_row.id, [])
  371. ]
  372. load_read.contracts = _contracts_for_stop(
  373. contract_rows,
  374. load_row.id,
  375. (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER),
  376. )
  377. _attach_contract_nos_to_stop_products(
  378. db, tenant_no, load_read.products, load_read.contracts
  379. )
  380. delivery_reads: list[PlanDeliveryRead] = []
  381. plan_deliveries = deliveries_by_plan.get(plan_id, [])
  382. for idx, delivery_row in enumerate(plan_deliveries):
  383. delivery_read = PlanDeliveryRead.model_validate(delivery_row)
  384. resolved_delivery_address = resolve_stop_address_from_library(
  385. db,
  386. tenant_no,
  387. delivery_read.address,
  388. delivery_read.lng,
  389. delivery_read.lat,
  390. )
  391. if resolved_delivery_address:
  392. delivery_read.address = resolved_delivery_address
  393. delivery_read.products = [
  394. _delivery_stop_product(
  395. p,
  396. brand_qtys_by_stop,
  397. standard_by_id=standard_by_id,
  398. alias_options_by_id=alias_options_by_id,
  399. )
  400. for p in delivery_products.get(delivery_row.id, [])
  401. ]
  402. legacy_sides = (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER)
  403. # 未迁移合同仅挂到第一个送货段
  404. if idx == 0:
  405. delivery_read.contracts = _contracts_for_stop(
  406. contract_rows,
  407. delivery_row.id,
  408. legacy_sides,
  409. )
  410. else:
  411. delivery_read.contracts = _contracts_for_stop(
  412. contract_rows,
  413. delivery_row.id,
  414. (PLAN_CONTRACT_SIDE_DELIVERY,),
  415. )
  416. _attach_contract_nos_to_stop_products(
  417. db, tenant_no, delivery_read.products, delivery_read.contracts
  418. )
  419. delivery_reads.append(delivery_read)
  420. result[plan_id] = (load_read, delivery_reads)
  421. return result
  422. def resolve_load_customer_name(
  423. plan: PlPlan,
  424. load: Optional[PlanLoadRead],
  425. ) -> Optional[str]:
  426. if load and (load.customer_name or "").strip():
  427. return load.customer_name.strip()
  428. legacy = (plan.supplier_name or "").strip()
  429. return legacy or None
  430. def resolve_delivery_customer_summary(
  431. plan: PlPlan,
  432. deliveries: list[PlanDeliveryRead],
  433. ) -> Optional[str]:
  434. names: list[str] = []
  435. seen: set[str] = set()
  436. for delivery in deliveries:
  437. name = (delivery.customer_name or "").strip()
  438. if name and name not in seen:
  439. seen.add(name)
  440. names.append(name)
  441. if names:
  442. if len(names) <= 2:
  443. return "、".join(names)
  444. return f"{names[0]}、{names[1]}等{len(names)}家"
  445. legacy = (plan.customer_name or plan.destination_name or "").strip()
  446. return legacy or None
  447. def collect_unique_delivery_date_strings(deliveries) -> list[str]:
  448. """送货段 delivery_time → 去重升序 YYYY-MM-DD。"""
  449. days: set[date] = set()
  450. for row in deliveries or []:
  451. raw = getattr(row, "delivery_time", None)
  452. if raw is None or raw == "":
  453. continue
  454. if isinstance(raw, datetime):
  455. days.add(raw.date())
  456. elif isinstance(raw, date):
  457. days.add(raw)
  458. else:
  459. text = str(raw).strip()
  460. if not text:
  461. continue
  462. try:
  463. days.add(datetime.strptime(text[:10], "%Y-%m-%d").date())
  464. except ValueError:
  465. continue
  466. return [d.isoformat() for d in sorted(days)]
  467. def attach_plan_stops_to_item(
  468. plan_item,
  469. plan: PlPlan,
  470. stops: tuple[Optional[PlanLoadRead], list[PlanDeliveryRead]],
  471. ) -> None:
  472. load_read, delivery_reads = stops
  473. plan_item.load = load_read
  474. plan_item.deliveries = delivery_reads
  475. plan_item.load_customer_name = resolve_load_customer_name(plan, load_read)
  476. plan_item.delivery_customer_summary = resolve_delivery_customer_summary(plan, delivery_reads)
  477. if load_read and (load_read.address or "").strip():
  478. plan_item.pickup_address = load_read.address
  479. if delivery_reads and (delivery_reads[0].address or "").strip():
  480. plan_item.destination_address = delivery_reads[0].address
  481. plan_item.delivery_times = collect_unique_delivery_date_strings(delivery_reads)
  482. def uses_multi_stop_payload(load: Optional[PlanLoadBody], deliveries: Optional[list[PlanDeliveryBody]]) -> bool:
  483. return load is not None or deliveries is not None
  484. def _normalize_contract_links(links: Optional[list[PlanContractLink]]) -> list[PlanContractLink]:
  485. if not links:
  486. return []
  487. seen: set[str] = set()
  488. out: list[PlanContractLink] = []
  489. for link in links:
  490. cid = (link.contract_id or "").strip()
  491. if not cid or cid in seen:
  492. continue
  493. seen.add(cid)
  494. out.append(link)
  495. return out
  496. def assert_at_most_one_contract(
  497. links: list[PlanContractLink],
  498. side_label: str,
  499. ) -> list[PlanContractLink]:
  500. if len(links) > 1:
  501. raise HTTPException(
  502. status_code=422,
  503. detail=f"{side_label}只能选择 1 份",
  504. )
  505. return links
  506. def _assert_product_rows_in_contract_allowed(
  507. products: Optional[list],
  508. allowed: set[str],
  509. *,
  510. row_label: str = "装货产品",
  511. ) -> None:
  512. for prod in products or []:
  513. pid = str(getattr(prod, "product_id", None) or "").strip()
  514. name = (getattr(prod, "product_name", None) or pid or "未命名").strip()
  515. if not pid or pid not in allowed:
  516. raise HTTPException(
  517. status_code=422,
  518. detail=f"{row_label}「{name}」不在所选合同明细中",
  519. )
  520. def _contract_product_rows(db: Session, tenant_no: str, contract_id: str) -> list:
  521. return (
  522. db.query(CrmContractProduct)
  523. .filter(
  524. CrmContractProduct.contract_id == contract_id,
  525. CrmContractProduct.tenant_no == tenant_no,
  526. CrmContractProduct.del_flag == 0,
  527. )
  528. .all()
  529. )
  530. def _contract_allowed_product_ids(db: Session, tenant_no: str, contract_id: str) -> set[str]:
  531. rows = _contract_product_rows(db, tenant_no, contract_id)
  532. return {
  533. str(r.product_id).strip()
  534. for r in rows
  535. if getattr(r, "product_id", None) and str(r.product_id).strip()
  536. }
  537. def _assert_stop_products_package_for_contract(
  538. db: Session,
  539. tenant_no: str,
  540. contract_id: str,
  541. stop_products: Optional[list],
  542. ) -> None:
  543. from app.services.pl_contract_plan_qty import (
  544. validate_stop_products_package_against_contract,
  545. )
  546. rows = _contract_product_rows(db, tenant_no, contract_id)
  547. ok, err = validate_stop_products_package_against_contract(
  548. contract_products=rows,
  549. stop_products=stop_products or [],
  550. )
  551. if not ok:
  552. raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细")
  553. def _union_contract_product_rows(
  554. db: Session,
  555. tenant_no: str,
  556. links: list[PlanContractLink],
  557. ) -> list:
  558. rows: list = []
  559. seen: set[str] = set()
  560. for link in links:
  561. cid = (link.contract_id or "").strip()
  562. if not cid or cid in seen:
  563. continue
  564. seen.add(cid)
  565. rows.extend(_contract_product_rows(db, tenant_no, cid))
  566. return rows
  567. def _assert_stop_products_package_for_union(
  568. stop_products: Optional[list],
  569. contract_rows: list,
  570. ) -> None:
  571. from app.services.pl_contract_plan_qty import (
  572. validate_stop_products_package_against_contract,
  573. )
  574. ok, err = validate_stop_products_package_against_contract(
  575. contract_products=contract_rows,
  576. stop_products=stop_products or [],
  577. )
  578. if not ok:
  579. raise HTTPException(status_code=422, detail=err or "包装规格不符合合同明细")
  580. def _assert_stop_products_bound_to_lines(
  581. stop_products: Optional[list],
  582. contract_rows: list,
  583. selected_contract_ids: set[str],
  584. *,
  585. row_label: str,
  586. ) -> None:
  587. from app.services.pl_contract_plan_qty import (
  588. contract_line_package_specs,
  589. normalize_package_spec,
  590. )
  591. by_id = {
  592. str(getattr(row, "id", None) or "").strip(): row
  593. for row in contract_rows
  594. if str(getattr(row, "id", None) or "").strip()
  595. }
  596. if not by_id:
  597. return
  598. for prod in stop_products or []:
  599. cpid = str(getattr(prod, "contract_product_id", None) or "").strip()
  600. if not cpid:
  601. continue
  602. line = by_id.get(cpid)
  603. if not line:
  604. name = (getattr(prod, "product_name", None) or cpid).strip()
  605. raise HTTPException(
  606. status_code=422,
  607. detail=f"{row_label}「{name}」不在所选合同明细中",
  608. )
  609. line_cid = str(getattr(line, "contract_id", None) or "").strip()
  610. if line_cid and selected_contract_ids and line_cid not in selected_contract_ids:
  611. name = (getattr(prod, "product_name", None) or cpid).strip()
  612. raise HTTPException(
  613. status_code=422,
  614. detail=f"{row_label}「{name}」不在所选合同明细中",
  615. )
  616. line_pid = str(getattr(line, "product_id", None) or "").strip()
  617. prod_pid = str(getattr(prod, "product_id", None) or "").strip()
  618. if line_pid and prod_pid and line_pid != prod_pid:
  619. name = (getattr(prod, "product_name", None) or prod_pid).strip()
  620. raise HTTPException(
  621. status_code=422,
  622. detail=f"{row_label}「{name}」与绑定合同行产品不一致",
  623. )
  624. pkg = normalize_package_spec(getattr(prod, "package_spec", None))
  625. specs = contract_line_package_specs(line)
  626. if pkg and specs and pkg not in specs:
  627. name = (getattr(prod, "product_name", None) or prod_pid).strip()
  628. raise HTTPException(
  629. status_code=422,
  630. detail=f"{row_label}包装规格不在合同明细中:{name}({pkg})",
  631. )
  632. def assert_load_products_in_selected_contract(
  633. db: Session,
  634. tenant_no: str,
  635. load: PlanLoadBody,
  636. plan_products: Optional[list] = None,
  637. ) -> None:
  638. if is_internal_customer_name(load.customer_name):
  639. return
  640. links = _normalize_contract_links(load.contracts)
  641. if not links:
  642. return
  643. rows = _union_contract_product_rows(db, tenant_no, links)
  644. allowed = {
  645. str(r.product_id).strip()
  646. for r in rows
  647. if getattr(r, "product_id", None) and str(r.product_id).strip()
  648. }
  649. _assert_product_rows_in_contract_allowed(load.products, allowed, row_label="装货产品")
  650. _assert_product_rows_in_contract_allowed(plan_products, allowed, row_label="装货产品")
  651. merged = list(load.products or []) + list(plan_products or [])
  652. _assert_stop_products_package_for_union(merged, rows)
  653. selected_ids = {(link.contract_id or "").strip() for link in links if (link.contract_id or "").strip()}
  654. _assert_stop_products_bound_to_lines(
  655. load.products, rows, selected_ids, row_label="装货产品"
  656. )
  657. def assert_delivery_products_in_selected_contract(
  658. db: Session,
  659. tenant_no: str,
  660. delivery: PlanDeliveryBody,
  661. seq: int,
  662. ) -> None:
  663. if is_internal_customer_name(delivery.customer_name):
  664. return
  665. links = _normalize_contract_links(delivery.contracts)
  666. if not links:
  667. return
  668. rows = _union_contract_product_rows(db, tenant_no, links)
  669. allowed = {
  670. str(r.product_id).strip()
  671. for r in rows
  672. if getattr(r, "product_id", None) and str(r.product_id).strip()
  673. }
  674. _assert_product_rows_in_contract_allowed(
  675. delivery.products,
  676. allowed,
  677. row_label=f"送货段#{seq}产品",
  678. )
  679. _assert_stop_products_package_for_union(delivery.products, rows)
  680. selected_ids = {(link.contract_id or "").strip() for link in links if (link.contract_id or "").strip()}
  681. _assert_stop_products_bound_to_lines(
  682. delivery.products, rows, selected_ids, row_label=f"送货段#{seq}产品"
  683. )
  684. def assert_stop_contract_required(
  685. customer_name: Optional[str],
  686. contracts: Optional[list[PlanContractLink]],
  687. side_label: str,
  688. ) -> None:
  689. """非内部客户装/送货段保存时必须至少选一份合同。"""
  690. if is_internal_customer_name(customer_name):
  691. return
  692. if not _normalize_contract_links(contracts):
  693. raise HTTPException(status_code=422, detail=f"请为{side_label}选择合同")
  694. def _resolve_stop_contracts(
  695. db: Session,
  696. tenant_no: str,
  697. customer_id: Optional[str],
  698. customer_name: Optional[str],
  699. contracts: Optional[list[PlanContractLink]],
  700. side_label: str,
  701. ) -> list[PlanContractLink]:
  702. if is_internal_customer_name(customer_name):
  703. return []
  704. links = _normalize_contract_links(contracts)
  705. if not links:
  706. return []
  707. resolved: list[PlanContractLink] = []
  708. for link in links:
  709. contract = assert_contract_selectable_for_plan(
  710. db,
  711. link.contract_id,
  712. tenant_no,
  713. expected_customer_id=customer_id,
  714. side_label=side_label,
  715. )
  716. resolved.append(
  717. PlanContractLink(
  718. contractId=contract.id,
  719. contractNo=contract.contract_no or link.contract_no,
  720. )
  721. )
  722. return resolved
  723. def has_pending_stop_products(db: Session, tenant_no: str, plan_id: str) -> bool:
  724. # Uses only pre-existing tables, including when the mobile feature is disabled.
  725. for model in (PlPlanLoadProduct, PlPlanDeliveryProduct):
  726. if db.query(model.id).filter(model.tenant_no == tenant_no, model.plan_id == plan_id,
  727. model.del_flag == 0, model.plan_product_id == "").first():
  728. return True
  729. return False
  730. def assert_no_pending_stop_products(db: Session, tenant_no: str, plan_id: str) -> None:
  731. if has_pending_stop_products(db, tenant_no, plan_id):
  732. raise HTTPException(422, '计划存在待关联装货产品,请补齐关联并正式保存后再安排运输')
  733. def validate_mobile_stop_sources(db: Session, tenant_no: str, load: PlanLoadBody,
  734. deliveries: list[PlanDeliveryBody], products: Optional[list]) -> None:
  735. """Opt-in source validation also runs for incomplete drafts; never trusts client tenant/line ids."""
  736. from app.models.crm_models import CrmProduct
  737. if load is None:
  738. raise HTTPException(422, "请填写装货信息")
  739. product_ids = {str(p.product_id) for p in (products or []) if p.product_id}
  740. for stop in [load] + list(deliveries or []):
  741. product_ids.update(str(p.product_id) for p in stop.products or [] if p.product_id)
  742. links = _normalize_contract_links(stop.contracts)
  743. # Even internal customers cannot attach an unverified foreign contract.
  744. for link in links:
  745. assert_contract_selectable_for_plan(db, link.contract_id, tenant_no,
  746. expected_customer_id=stop.customer_id, side_label="移动段合同")
  747. contract_rows = _union_contract_product_rows(db, tenant_no, links) if links else []
  748. by_id = {str(row.id): row for row in contract_rows}
  749. selected = {str(link.contract_id) for link in links}
  750. for product in stop.products or []:
  751. cid = (product.contract_id or '').strip()
  752. cpid = (product.contract_product_id or '').strip()
  753. if not cid and not cpid:
  754. continue
  755. line = by_id.get(cpid)
  756. if not cid or not cpid or cid not in selected or line is None or str(line.contract_id) != cid:
  757. raise HTTPException(422, '移动段产品与所选合同明细关联不一致')
  758. if str(line.product_id) != str(product.product_id):
  759. raise HTTPException(422, '移动段合同行产品不一致')
  760. _assert_stop_products_bound_to_lines(stop.products, contract_rows, selected, row_label="移动段产品")
  761. if product_ids:
  762. found = db.query(CrmProduct.id).filter(CrmProduct.tenant_no == tenant_no,
  763. CrmProduct.del_flag == 0, CrmProduct.id.in_(product_ids)).all()
  764. if {str(row[0]) for row in found} != product_ids:
  765. raise HTTPException(422, '移动段产品不存在或不属于当前租户')
  766. def validate_mobile_draft_stops(db: Session, tenant_no: str, load: PlanLoadBody,
  767. deliveries: Optional[list[PlanDeliveryBody]], products: Optional[list]):
  768. rows = list(load.products or []) + [p for stop in deliveries or [] for p in stop.products or []]
  769. quantities = [getattr(p, 'plan_qty', None) for p in rows] + [getattr(p, 'quantity', None) for p in products or []]
  770. for value in quantities:
  771. if value is None:
  772. continue
  773. qty = Decimal(str(value))
  774. if not qty.is_finite() or qty < 0:
  775. raise HTTPException(422, '暂存产品数量须为非负有限数')
  776. validate_mobile_stop_sources(db, tenant_no, load, list(deliveries or []), products)
  777. return load, list(deliveries or [])
  778. def validate_plan_stops_write(
  779. db: Session,
  780. tenant_no: str,
  781. load: Optional[PlanLoadBody],
  782. deliveries: Optional[list[PlanDeliveryBody]],
  783. products: Optional[list],
  784. ) -> tuple[PlanLoadBody, list[PlanDeliveryBody]]:
  785. if load is None:
  786. raise HTTPException(status_code=422, detail="请填写装货信息")
  787. if not (load.customer_id or "").strip() or not (load.customer_name or "").strip():
  788. raise HTTPException(status_code=422, detail="请选择装货客户")
  789. if not deliveries:
  790. raise HTTPException(status_code=422, detail="请至少添加一段送货信息")
  791. if products is not None and not products:
  792. raise HTTPException(status_code=422, detail="请至少保留一条产品明细")
  793. assert_stop_contract_required(load.customer_name, load.contracts, "装货客户")
  794. load.contracts = _resolve_stop_contracts(
  795. db,
  796. tenant_no,
  797. load.customer_id,
  798. load.customer_name,
  799. load.contracts,
  800. "装货合同",
  801. )
  802. resolved_deliveries: list[PlanDeliveryBody] = []
  803. for idx, delivery in enumerate(deliveries, start=1):
  804. if not (delivery.customer_id or "").strip() or not (delivery.customer_name or "").strip():
  805. raise HTTPException(status_code=422, detail=f"送货段#{idx}请选择客户")
  806. assert_stop_contract_required(delivery.customer_name, delivery.contracts, f"送货段#{idx}")
  807. delivery.contracts = _resolve_stop_contracts(
  808. db,
  809. tenant_no,
  810. delivery.customer_id,
  811. delivery.customer_name,
  812. delivery.contracts,
  813. f"送货段#{idx}合同",
  814. )
  815. resolved_deliveries.append(delivery)
  816. enrich_stop_addresses_from_library(db, tenant_no, load, resolved_deliveries)
  817. assert_load_products_in_selected_contract(db, tenant_no, load, plan_products=products)
  818. for idx, delivery in enumerate(resolved_deliveries, start=1):
  819. assert_delivery_products_in_selected_contract(db, tenant_no, delivery, idx)
  820. return load, resolved_deliveries
  821. def collect_stop_qty_warnings(
  822. products: Optional[list],
  823. load: PlanLoadBody,
  824. deliveries: list[PlanDeliveryBody],
  825. ) -> list[str]:
  826. plan_rows = products or []
  827. load_rows = load.products or []
  828. delivery_rows: list[PlanStopProductBody] = []
  829. for delivery in deliveries:
  830. delivery_rows.extend(delivery.products or [])
  831. return warn_qty_imbalance(plan_rows, load_rows, delivery_rows)
  832. def sync_plan_legacy_from_stops(
  833. plan: PlPlan,
  834. load: PlanLoadBody,
  835. deliveries: list[PlanDeliveryBody],
  836. ) -> None:
  837. plan.supplier_id = load.customer_id
  838. plan.supplier_name = load.customer_name
  839. plan.pickup_address = load.address
  840. plan.pickup_lng = load.lng
  841. plan.pickup_lat = load.lat
  842. plan.pickup_time = load.load_time
  843. plan.pickup_contact = load.contact
  844. plan.pickup_notes = load.notes
  845. if not deliveries:
  846. # Incomplete mobile drafts keep the load while clearing stale legacy customer fields.
  847. plan.customer_id = None
  848. plan.customer_name = None
  849. plan.destination_id = None
  850. plan.destination_name = None
  851. plan.destination_address = None
  852. plan.destination_lng = None
  853. plan.destination_lat = None
  854. plan.delivery_time = None
  855. plan.delivery_contact = None
  856. plan.destination_notes = None
  857. return
  858. first = deliveries[0]
  859. plan.customer_id = first.customer_id
  860. plan.customer_name = first.customer_name
  861. plan.destination_id = first.customer_id
  862. plan.destination_name = first.customer_name
  863. plan.destination_address = first.address
  864. plan.destination_lng = first.lng
  865. plan.destination_lat = first.lat
  866. plan.delivery_time = first.delivery_time
  867. plan.delivery_contact = first.contact
  868. plan.destination_notes = first.notes
  869. def _soft_delete_load_products_and_stop_contracts(
  870. db: Session,
  871. tenant_no: str,
  872. plan_id: str,
  873. user_id: Optional[str],
  874. ) -> None:
  875. """软删装货产品与装/送货段合同;送货段本体由 upsert 保留主键,不在此全量软删。"""
  876. now = datetime.now()
  877. load_ids = [
  878. row.id
  879. for row in db.query(PlPlanLoad)
  880. .filter(
  881. PlPlanLoad.plan_id == plan_id,
  882. PlPlanLoad.tenant_no == tenant_no,
  883. PlPlanLoad.del_flag == 0,
  884. )
  885. .all()
  886. ]
  887. if load_ids:
  888. db.query(PlPlanLoadProduct).filter(
  889. PlPlanLoadProduct.load_id.in_(load_ids),
  890. PlPlanLoadProduct.del_flag == 0,
  891. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  892. db.query(PlPlanContract).filter(
  893. PlPlanContract.plan_id == plan_id,
  894. PlPlanContract.tenant_no == tenant_no,
  895. PlPlanContract.del_flag == 0,
  896. PlPlanContract.side.in_(
  897. [PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_DELIVERY]
  898. ),
  899. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  900. def _soft_delete_delivery_products(
  901. db: Session,
  902. delivery_ids: list[str],
  903. user_id: Optional[str],
  904. now: datetime,
  905. ) -> None:
  906. if not delivery_ids:
  907. return
  908. db.query(PlPlanDeliveryProduct).filter(
  909. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  910. PlPlanDeliveryProduct.del_flag == 0,
  911. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  912. def resolve_delivery_id_for_upsert(
  913. *,
  914. plan_id: str,
  915. payload_id: Optional[str],
  916. existing_by_id: dict,
  917. ) -> tuple[str, Optional[object]]:
  918. pid = (payload_id or "").strip()
  919. if not pid:
  920. return generate_uuid(), None
  921. row = existing_by_id.get(pid)
  922. if row is None or getattr(row, "plan_id", None) != plan_id:
  923. raise HTTPException(status_code=422, detail=f"送货段 id 无效或不属于本计划:{pid}")
  924. return pid, row
  925. def _write_stop_contracts(
  926. db: Session,
  927. tenant_no: str,
  928. plan_id: str,
  929. stop_id: str,
  930. side: str,
  931. contracts: list[PlanContractLink],
  932. user_id: Optional[str],
  933. dept_id: Optional[str],
  934. order_num_start: int,
  935. ) -> int:
  936. now = datetime.now()
  937. order_num = order_num_start
  938. for link in contracts:
  939. order_num += 1
  940. db.add(
  941. PlPlanContract(
  942. id=generate_uuid(),
  943. plan_id=plan_id,
  944. side=side,
  945. stop_id=stop_id,
  946. contract_id=link.contract_id,
  947. contract_no=link.contract_no,
  948. tenant_no=tenant_no,
  949. order_num=order_num,
  950. create_by=user_id,
  951. create_dept_id=dept_id or "",
  952. create_time=now,
  953. update_by=user_id,
  954. update_time=now,
  955. del_flag=0,
  956. )
  957. )
  958. return order_num
  959. def _resolve_plan_product_id(
  960. prod: PlanStopProductBody,
  961. plan_products: list,
  962. allow_unlinked_draft: bool = False,
  963. ) -> str:
  964. if getattr(prod, 'unlinked_draft', False):
  965. if allow_unlinked_draft and not prod.plan_product_id and not getattr(prod, 'plan_product_client_key', None):
  966. return ""
  967. raise HTTPException(422, '待关联产品仅可暂存,正式保存前请选择关联装货产品')
  968. mobile_id = resolve_mobile_product_link(prod, plan_products)
  969. if mobile_id is not None:
  970. return mobile_id
  971. explicit = (prod.plan_product_id or "").strip()
  972. if explicit:
  973. for row in plan_products:
  974. if row.id == explicit:
  975. return explicit
  976. product_id = (prod.product_id or "").strip()
  977. if product_id:
  978. for row in plan_products:
  979. if (row.product_id or "").strip() == product_id:
  980. return row.id
  981. product_no = (prod.product_no or "").strip()
  982. if product_no:
  983. for row in plan_products:
  984. if (row.product_no or "").strip() == product_no:
  985. return row.id
  986. raise HTTPException(
  987. status_code=422,
  988. detail=f"无法匹配计划产品行:{prod.product_name or explicit or product_id}",
  989. )
  990. def _resolve_or_create_plan_load(
  991. db: Session,
  992. tenant_no: str,
  993. plan_id: str,
  994. load: PlanLoadBody,
  995. user_id: Optional[str],
  996. dept_id: Optional[str],
  997. now: datetime,
  998. ) -> str:
  999. """装货段与 plan 1:1;uk_pl_plan_load_plan 仅按 plan_id 唯一,软删后不可再 INSERT,须原地更新。"""
  1000. generate_dibang, dibang_direction = normalize_stop_dibang_fields(
  1001. load.customer_name,
  1002. "load",
  1003. load.generate_dibang,
  1004. load.dibang_direction,
  1005. )
  1006. existing = (
  1007. db.query(PlPlanLoad)
  1008. .filter(
  1009. PlPlanLoad.plan_id == plan_id,
  1010. PlPlanLoad.tenant_no == tenant_no,
  1011. )
  1012. .order_by(PlPlanLoad.del_flag.asc(), PlPlanLoad.update_time.desc())
  1013. .first()
  1014. )
  1015. if existing:
  1016. existing.customer_id = load.customer_id
  1017. existing.customer_name = load.customer_name
  1018. existing.address = load.address
  1019. existing.lng = load.lng
  1020. existing.lat = load.lat
  1021. existing.load_time = load.load_time
  1022. existing.contact = load.contact
  1023. existing.notes = load.notes
  1024. existing.generate_dibang = generate_dibang
  1025. existing.dibang_direction = dibang_direction
  1026. existing.remark = load.remark
  1027. existing.del_flag = 0
  1028. existing.update_by = user_id
  1029. existing.update_time = now
  1030. return existing.id
  1031. load_id = generate_uuid()
  1032. db.add(
  1033. PlPlanLoad(
  1034. id=load_id,
  1035. plan_id=plan_id,
  1036. customer_id=load.customer_id,
  1037. customer_name=load.customer_name,
  1038. address=load.address,
  1039. lng=load.lng,
  1040. lat=load.lat,
  1041. load_time=load.load_time,
  1042. contact=load.contact,
  1043. notes=load.notes,
  1044. generate_dibang=generate_dibang,
  1045. dibang_direction=dibang_direction,
  1046. tenant_no=tenant_no,
  1047. remark=load.remark,
  1048. create_by=user_id,
  1049. create_dept_id=dept_id or "",
  1050. create_time=now,
  1051. update_by=user_id,
  1052. update_time=now,
  1053. del_flag=0,
  1054. )
  1055. )
  1056. return load_id
  1057. def replace_plan_stops(
  1058. db: Session,
  1059. tenant_no: str,
  1060. plan_id: str,
  1061. load: PlanLoadBody,
  1062. deliveries: list[PlanDeliveryBody],
  1063. user_id: Optional[str],
  1064. dept_id: Optional[str],
  1065. plan_products: Optional[list] = None,
  1066. allow_unlinked_draft: bool = False,
  1067. preserve_pending_draft: bool = False,
  1068. ) -> None:
  1069. meta_enabled = settings.PL_MOBILE_PLAN_PRODUCT_META_ENABLED
  1070. snapshots = load_stop_meta(db, tenant_no, plan_id, meta_enabled)
  1071. used_snapshots = set()
  1072. _soft_delete_load_products_and_stop_contracts(db, tenant_no, plan_id, user_id)
  1073. now = datetime.now()
  1074. load_id = _resolve_or_create_plan_load(
  1075. db,
  1076. tenant_no,
  1077. plan_id,
  1078. load,
  1079. user_id,
  1080. dept_id,
  1081. now,
  1082. )
  1083. plan_product_rows = plan_products or []
  1084. for idx, prod in enumerate(load.products or []):
  1085. plan_product_id = _resolve_plan_product_id(prod, plan_product_rows)
  1086. stop_product_id = generate_uuid()
  1087. db.add(
  1088. PlPlanLoadProduct(
  1089. id=stop_product_id,
  1090. load_id=load_id,
  1091. plan_id=plan_id,
  1092. plan_product_id=plan_product_id,
  1093. product_id=prod.product_id,
  1094. product_no=prod.product_no,
  1095. product_name=prod.product_name,
  1096. model=prod.model,
  1097. unit=prod.unit or "吨",
  1098. package_spec=prod.package_spec,
  1099. delivery_method=prod.delivery_method,
  1100. contract_id=prod.contract_id,
  1101. contract_product_id=prod.contract_product_id,
  1102. plan_qty=prod.plan_qty,
  1103. tenant_no=tenant_no,
  1104. order_num=idx,
  1105. remark=prod.remark,
  1106. create_by=user_id,
  1107. create_dept_id=dept_id or "",
  1108. create_time=now,
  1109. update_by=user_id,
  1110. update_time=now,
  1111. del_flag=0,
  1112. )
  1113. )
  1114. if meta_enabled:
  1115. write_stop_meta(db, snapshots, used_snapshots, tenant_no, plan_id, 'load', load_id,
  1116. stop_product_id, prod, next((p for p in plan_product_rows if p.id == plan_product_id), None),
  1117. user_id, dept_id, now)
  1118. contract_order = 0
  1119. contract_order = _write_stop_contracts(
  1120. db,
  1121. tenant_no,
  1122. plan_id,
  1123. load_id,
  1124. PLAN_CONTRACT_SIDE_LOAD,
  1125. load.contracts or [],
  1126. user_id,
  1127. dept_id,
  1128. contract_order,
  1129. )
  1130. existing_deliveries = (
  1131. db.query(PlPlanDelivery)
  1132. .filter(
  1133. PlPlanDelivery.plan_id == plan_id,
  1134. PlPlanDelivery.tenant_no == tenant_no,
  1135. )
  1136. .all()
  1137. )
  1138. existing_by_id = {row.id: row for row in existing_deliveries}
  1139. kept_ids: set[str] = set()
  1140. for delivery in deliveries:
  1141. generate_dibang, dibang_direction = normalize_stop_dibang_fields(
  1142. delivery.customer_name,
  1143. "delivery",
  1144. delivery.generate_dibang,
  1145. delivery.dibang_direction,
  1146. )
  1147. delivery_id, existing = resolve_delivery_id_for_upsert(
  1148. plan_id=plan_id,
  1149. payload_id=delivery.id,
  1150. existing_by_id=existing_by_id,
  1151. )
  1152. kept_ids.add(delivery_id)
  1153. if existing is not None:
  1154. existing.seq = delivery.seq or 1
  1155. existing.customer_id = delivery.customer_id
  1156. existing.customer_name = delivery.customer_name
  1157. existing.address = delivery.address
  1158. existing.lng = delivery.lng
  1159. existing.lat = delivery.lat
  1160. existing.delivery_time = delivery.delivery_time
  1161. existing.contact = delivery.contact
  1162. existing.notes = delivery.notes
  1163. existing.generate_dibang = generate_dibang
  1164. existing.dibang_direction = dibang_direction
  1165. existing.remark = delivery.remark
  1166. existing.del_flag = 0
  1167. existing.update_by = user_id
  1168. existing.update_time = now
  1169. else:
  1170. db.add(
  1171. PlPlanDelivery(
  1172. id=delivery_id,
  1173. plan_id=plan_id,
  1174. seq=delivery.seq or 1,
  1175. customer_id=delivery.customer_id,
  1176. customer_name=delivery.customer_name,
  1177. address=delivery.address,
  1178. lng=delivery.lng,
  1179. lat=delivery.lat,
  1180. delivery_time=delivery.delivery_time,
  1181. contact=delivery.contact,
  1182. notes=delivery.notes,
  1183. generate_dibang=generate_dibang,
  1184. dibang_direction=dibang_direction,
  1185. tenant_no=tenant_no,
  1186. remark=delivery.remark,
  1187. create_by=user_id,
  1188. create_dept_id=dept_id or "",
  1189. create_time=now,
  1190. update_by=user_id,
  1191. update_time=now,
  1192. del_flag=0,
  1193. )
  1194. )
  1195. pending_products = []
  1196. if preserve_pending_draft:
  1197. pending_products = db.query(PlPlanDeliveryProduct).filter(
  1198. PlPlanDeliveryProduct.tenant_no == tenant_no,
  1199. PlPlanDeliveryProduct.plan_id == plan_id,
  1200. PlPlanDeliveryProduct.delivery_id == delivery_id,
  1201. PlPlanDeliveryProduct.del_flag == 0,
  1202. PlPlanDeliveryProduct.plan_product_id == "",
  1203. ).all()
  1204. _soft_delete_delivery_products(db, [delivery_id], user_id, now)
  1205. for idx, prod in enumerate(delivery.products or []):
  1206. plan_product_id = (
  1207. "" if is_existing_pending_delivery(prod, delivery_id, pending_products)
  1208. else _resolve_plan_product_id(prod, plan_product_rows, allow_unlinked_draft)
  1209. )
  1210. stop_product_id = generate_uuid()
  1211. db.add(
  1212. PlPlanDeliveryProduct(
  1213. id=stop_product_id,
  1214. delivery_id=delivery_id,
  1215. plan_id=plan_id,
  1216. plan_product_id=plan_product_id,
  1217. product_id=prod.product_id,
  1218. product_no=prod.product_no,
  1219. product_name=prod.product_name,
  1220. model=prod.model,
  1221. unit=prod.unit or "吨",
  1222. package_spec=prod.package_spec,
  1223. delivery_method=prod.delivery_method,
  1224. contract_id=prod.contract_id,
  1225. contract_product_id=prod.contract_product_id,
  1226. plan_qty=prod.plan_qty,
  1227. tenant_no=tenant_no,
  1228. order_num=idx,
  1229. remark=prod.remark,
  1230. create_by=user_id,
  1231. create_dept_id=dept_id or "",
  1232. create_time=now,
  1233. update_by=user_id,
  1234. update_time=now,
  1235. del_flag=0,
  1236. )
  1237. )
  1238. if meta_enabled:
  1239. write_stop_meta(db, snapshots, used_snapshots, tenant_no, plan_id, 'delivery', delivery_id,
  1240. stop_product_id, prod, next((p for p in plan_product_rows if p.id == plan_product_id), None),
  1241. user_id, dept_id, now)
  1242. contract_order = _write_stop_contracts(
  1243. db,
  1244. tenant_no,
  1245. plan_id,
  1246. delivery_id,
  1247. PLAN_CONTRACT_SIDE_DELIVERY,
  1248. delivery.contracts or [],
  1249. user_id,
  1250. dept_id,
  1251. contract_order,
  1252. )
  1253. removed_ids = [row.id for row in existing_deliveries if row.id not in kept_ids and row.del_flag == 0]
  1254. if removed_ids:
  1255. _soft_delete_delivery_products(db, removed_ids, user_id, now)
  1256. db.query(PlPlanDelivery).filter(
  1257. PlPlanDelivery.id.in_(removed_ids),
  1258. PlPlanDelivery.tenant_no == tenant_no,
  1259. PlPlanDelivery.del_flag == 0,
  1260. ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False)
  1261. for snapshot in snapshots:
  1262. snapshot.del_flag = 1
  1263. snapshot.update_by = user_id
  1264. snapshot.update_time = now