pl_contract_plan_qty.py 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969
  1. """合同产品在运输计划上的安排数量汇总。"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from typing import Any, DefaultDict, Dict, Iterable, Optional, Set, Tuple
  5. from sqlalchemy.orm import Session
  6. from app.models.pl_models import (
  7. PlPlan,
  8. PlPlanContract,
  9. PlPlanDelivery,
  10. PlPlanDeliveryProduct,
  11. PlPlanLoad,
  12. PlPlanLoadProduct,
  13. )
  14. from app.services.pl_plan_service import (
  15. PLAN_CONTRACT_SIDE_DEMANDER,
  16. PLAN_CONTRACT_SIDE_SUPPLIER,
  17. )
  18. from app.services.pl_plan_stop_service import (
  19. PLAN_CONTRACT_SIDE_DELIVERY,
  20. PLAN_CONTRACT_SIDE_LOAD,
  21. )
  22. from app.utils.crm_package_spec import decode_package_specs
  23. # 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan)
  24. ProductPackageKey = Tuple[str, str]
  25. LINE_OCCUPANCY_PREFIX = "__line__"
  26. PKG_DISPLAY_PREFIX = "__pkg__"
  27. def line_occupancy_key(contract_product_id: Any) -> Optional[ProductPackageKey]:
  28. cpid = str(contract_product_id or "").strip()
  29. if not cpid:
  30. return None
  31. return (LINE_OCCUPANCY_PREFIX, cpid)
  32. def is_line_occupancy_key(key: Any) -> bool:
  33. return (
  34. isinstance(key, tuple)
  35. and len(key) == 2
  36. and key[0] == LINE_OCCUPANCY_PREFIX
  37. and bool(str(key[1] or "").strip())
  38. )
  39. def normalize_package_spec(value: Any) -> str:
  40. if value is None:
  41. return ""
  42. return str(value).strip()
  43. def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
  44. pid = str(product_id or "").strip()
  45. if not pid:
  46. return None
  47. return (pid, normalize_package_spec(package_spec))
  48. def package_display_key(contract_product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
  49. """按合同明细行拆开展示包装数量;不参与行剩余占用,避免与 __line__ 双计。"""
  50. cpid = str(contract_product_id or "").strip()
  51. if not cpid:
  52. return None
  53. return (PKG_DISPLAY_PREFIX, f"{cpid}\x1f{normalize_package_spec(package_spec)}")
  54. def _stats_for_package_breakdown(
  55. qty_map: Dict[ProductPackageKey, Dict[str, float]],
  56. pool_key: Optional[ProductPackageKey],
  57. zeros: Dict[str, float],
  58. *,
  59. contract_product_id: Any = None,
  60. include_pool_key: bool = True,
  61. ) -> Dict[str, float]:
  62. out = {k: 0.0 for k in zeros}
  63. if pool_key:
  64. dkey = package_display_key(contract_product_id, pool_key[1])
  65. sources = []
  66. if dkey:
  67. sources.append(qty_map.get(dkey))
  68. if include_pool_key:
  69. sources.append(qty_map.get(pool_key))
  70. for src in sources:
  71. if not src:
  72. continue
  73. for k in zeros:
  74. out[k] = round(out[k] + _to_float(src.get(k)), 3)
  75. return out
  76. def _to_float(value: Any) -> float:
  77. if value is None:
  78. return 0.0
  79. try:
  80. return float(value)
  81. except (TypeError, ValueError):
  82. return 0.0
  83. def contract_line_package_specs(row: Any) -> list[str]:
  84. """从合同产品行(ORM / dict / SimpleNamespace)解析包装列表。"""
  85. if isinstance(row, dict):
  86. raw_list = row.get("packageSpecs") or row.get("package_specs")
  87. if isinstance(raw_list, (list, tuple)) and raw_list:
  88. return decode_package_specs(raw_list)
  89. return decode_package_specs(row.get("packageSpec") or row.get("package_spec"))
  90. raw_list = getattr(row, "package_specs", None)
  91. if isinstance(raw_list, (list, tuple)) and raw_list:
  92. return decode_package_specs(raw_list)
  93. return decode_package_specs(getattr(row, "package_spec", None))
  94. def pool_keys_for_contract_line(row: Any) -> list[ProductPackageKey]:
  95. pid = ""
  96. if isinstance(row, dict):
  97. pid = str(row.get("productId") or row.get("product_id") or "").strip()
  98. else:
  99. pid = str(getattr(row, "product_id", None) or "").strip()
  100. if not pid:
  101. return []
  102. keys: list[ProductPackageKey] = []
  103. for spec in contract_line_package_specs(row):
  104. key = product_package_key(pid, spec)
  105. if key:
  106. keys.append(key)
  107. return keys
  108. def sum_qty_stats_for_keys(
  109. qty_map: Dict[ProductPackageKey, Dict[str, float]],
  110. keys: Iterable[ProductPackageKey],
  111. ) -> Dict[str, float]:
  112. archived = 0.0
  113. unarchived = 0.0
  114. for key in keys or []:
  115. stats = qty_map.get(key) or {}
  116. archived += _to_float(stats.get("archivedQty"))
  117. unarchived += _to_float(stats.get("arrangedUnarchivedQty"))
  118. archived = round(archived, 3)
  119. unarchived = round(unarchived, 3)
  120. return {
  121. "archivedQty": archived,
  122. "arrangedUnarchivedQty": unarchived,
  123. "arrangedTotalQty": round(archived + unarchived, 3),
  124. }
  125. def contract_product_id_of(row: Any) -> str:
  126. if isinstance(row, dict):
  127. return str(row.get("id") or row.get("contractProductId") or "").strip()
  128. return str(getattr(row, "id", None) or getattr(row, "contract_product_id", None) or "").strip()
  129. def match_first_contract_product_for_key(
  130. products: Iterable[Any],
  131. key: Optional[ProductPackageKey],
  132. ) -> Optional[Any]:
  133. """按 productId + 包装∈行内集合匹配第一行(同包装多行不合并)。"""
  134. if not key:
  135. return None
  136. pid, pkg = key[0], key[1]
  137. for p in products or []:
  138. if isinstance(p, dict):
  139. row_pid = str(p.get("productId") or p.get("product_id") or "").strip()
  140. else:
  141. row_pid = str(getattr(p, "product_id", None) or "").strip()
  142. if row_pid != pid:
  143. continue
  144. specs = contract_line_package_specs(p)
  145. if pkg in specs:
  146. return p
  147. return None
  148. def occupancy_keys_for_contract_line(
  149. row: Any,
  150. all_products: Optional[Iterable[Any]] = None,
  151. ) -> list[ProductPackageKey]:
  152. """行占用键:绑定行用 __line__;无绑定时仅第一匹配行承接包装键(历史计划)。"""
  153. keys: list[ProductPackageKey] = []
  154. cpid = contract_product_id_of(row)
  155. line_key = line_occupancy_key(cpid)
  156. if line_key:
  157. keys.append(line_key)
  158. peers = list(all_products or [])
  159. for pkg_key in pool_keys_for_contract_line(row):
  160. first = match_first_contract_product_for_key(peers, pkg_key) if peers else row
  161. if first is None:
  162. continue
  163. if cpid and contract_product_id_of(first) != cpid:
  164. continue
  165. keys.append(pkg_key)
  166. return keys
  167. def match_contract_product_for_key(
  168. products: Iterable[Any],
  169. key: Optional[ProductPackageKey],
  170. ) -> Optional[Any]:
  171. """按 productId + 包装∈行内集合 匹配合同产品行;同包装脏数据多行时 quantity 相加。"""
  172. if not key:
  173. return None
  174. pid, pkg = key[0], key[1]
  175. matched = []
  176. for p in products or []:
  177. row_pid = ""
  178. if isinstance(p, dict):
  179. row_pid = str(p.get("productId") or p.get("product_id") or "").strip()
  180. else:
  181. row_pid = str(getattr(p, "product_id", None) or "").strip()
  182. if row_pid != pid:
  183. continue
  184. specs = contract_line_package_specs(p)
  185. if pkg in specs:
  186. matched.append(p)
  187. if not matched:
  188. return None
  189. if len(matched) == 1:
  190. return matched[0]
  191. from types import SimpleNamespace
  192. total_qty = 0.0
  193. for p in matched:
  194. if isinstance(p, dict):
  195. total_qty += _to_float(p.get("quantity"))
  196. else:
  197. total_qty += _to_float(getattr(p, "quantity", None))
  198. base = matched[0]
  199. if isinstance(base, dict):
  200. return SimpleNamespace(
  201. id=base.get("id"),
  202. product_id=base.get("productId") or base.get("product_id"),
  203. package_spec=encode_specs_for_attr(contract_line_package_specs(base)),
  204. product_name=base.get("productName") or base.get("product_name"),
  205. model=base.get("model"),
  206. unit=base.get("unit"),
  207. quantity=total_qty,
  208. quantity_mode=base.get("quantityMode") or base.get("quantity_mode"),
  209. )
  210. return SimpleNamespace(
  211. id=getattr(base, "id", None),
  212. product_id=getattr(base, "product_id", None),
  213. package_spec=getattr(base, "package_spec", None),
  214. product_name=getattr(base, "product_name", None),
  215. model=getattr(base, "model", None),
  216. unit=getattr(base, "unit", None),
  217. quantity=total_qty,
  218. quantity_mode=getattr(base, "quantity_mode", None),
  219. )
  220. def encode_specs_for_attr(specs: list[str]) -> str:
  221. from app.utils.crm_package_spec import encode_package_specs
  222. return encode_package_specs(specs)
  223. def _bucket_for_plan(*, archive_status: Optional[str], follow_status: Optional[str], transport_status: Optional[str]) -> Optional[str]:
  224. """返回 'archived' | 'arranged_unarchived' | None(不计入)。"""
  225. follow = (follow_status or "").strip()
  226. if follow == "cancelled" or follow in ("wait_notice", "pending_arrange"):
  227. return None
  228. archive = (archive_status or "").strip() or "unarchived"
  229. if archive == "archived":
  230. return "archived"
  231. # 已安排未归档(含 archive draft 暂存)
  232. transport = (transport_status or "").strip()
  233. if transport == "arranged" or follow in ("arranged", "completed"):
  234. return "arranged_unarchived"
  235. return None
  236. def merge_product_qty_maps(
  237. *,
  238. archived: Dict[ProductPackageKey, float],
  239. arranged_unarchived: Dict[ProductPackageKey, float],
  240. ) -> Dict[ProductPackageKey, Dict[str, float]]:
  241. """(product_id, package_spec) -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}"""
  242. keys = set(archived) | set(arranged_unarchived)
  243. out: Dict[ProductPackageKey, Dict[str, float]] = {}
  244. for key in keys:
  245. a = round(float(archived.get(key, 0.0)), 3)
  246. u = round(float(arranged_unarchived.get(key, 0.0)), 3)
  247. out[key] = {
  248. "archivedQty": a,
  249. "arrangedUnarchivedQty": u,
  250. "arrangedTotalQty": round(a + u, 3),
  251. }
  252. return out
  253. # plan:计划数量;archive_actual:装货 inbound / 送货 outbound,null 回退 plan_qty
  254. QTY_MODE_PLAN = "plan"
  255. QTY_MODE_ARCHIVE_ACTUAL = "archive_actual"
  256. def _resolve_stop_product_qty(
  257. row: Any,
  258. *,
  259. stop_kind: str,
  260. qty_mode: str,
  261. ) -> float:
  262. """按模式解析段产品计入量。"""
  263. if qty_mode == QTY_MODE_ARCHIVE_ACTUAL:
  264. attr = "inbound_qty" if stop_kind == "load" else "outbound_qty"
  265. raw = getattr(row, attr, None)
  266. if raw is not None:
  267. return _to_float(raw)
  268. return _to_float(getattr(row, "plan_qty", None))
  269. return _to_float(getattr(row, "plan_qty", None))
  270. def accumulate_stop_products(
  271. target: DefaultDict[ProductPackageKey, float],
  272. products: Iterable[Any],
  273. *,
  274. stop_kind: str = "load",
  275. qty_mode: str = QTY_MODE_PLAN,
  276. ) -> None:
  277. for row in products or []:
  278. qty = _resolve_stop_product_qty(row, stop_kind=stop_kind, qty_mode=qty_mode)
  279. if not qty:
  280. continue
  281. line_key = line_occupancy_key(
  282. getattr(row, "contract_product_id", None)
  283. if not isinstance(row, dict)
  284. else row.get("contractProductId") or row.get("contract_product_id")
  285. )
  286. key = product_package_key(
  287. getattr(row, "product_id", None)
  288. if not isinstance(row, dict)
  289. else row.get("productId") or row.get("product_id"),
  290. getattr(row, "package_spec", None)
  291. if not isinstance(row, dict)
  292. else row.get("packageSpec") or row.get("package_spec"),
  293. )
  294. display_key = (
  295. package_display_key(line_key[1], key[1] if key else "") if line_key else None
  296. )
  297. if line_key:
  298. target[line_key] += qty
  299. if display_key:
  300. target[display_key] += qty
  301. continue
  302. if not key:
  303. continue
  304. target[key] += qty
  305. def resolve_linked_plan_scopes(
  306. link_rows: Iterable[PlPlanContract],
  307. legacy_plan_ids: Iterable[str],
  308. ) -> Dict[str, Dict[str, Any]]:
  309. """
  310. plan_id -> {
  311. load_ids: set[str] | None, # None = 该侧全部装货段
  312. delivery_ids: set[str] | None,
  313. use_all_stops: bool, # 遗留主表合同:优先送货段,否则装货段,各产品只计一次
  314. }
  315. """
  316. scopes: Dict[str, Dict[str, Any]] = {}
  317. def _ensure(plan_id: str) -> Dict[str, Any]:
  318. if plan_id not in scopes:
  319. scopes[plan_id] = {
  320. "load_ids": set(),
  321. "delivery_ids": set(),
  322. "use_all_stops": False,
  323. "has_explicit": False,
  324. }
  325. return scopes[plan_id]
  326. for row in link_rows or []:
  327. plan_id = str(getattr(row, "plan_id", None) or "").strip()
  328. if not plan_id:
  329. continue
  330. scope = _ensure(plan_id)
  331. side = (getattr(row, "side", None) or "").strip()
  332. stop_id = str(getattr(row, "stop_id", None) or "").strip()
  333. scope["has_explicit"] = True
  334. if side in (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER):
  335. if stop_id:
  336. scope["load_ids"].add(stop_id)
  337. else:
  338. scope["load_ids"] = None # type: ignore[assignment]
  339. elif side in (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER):
  340. if stop_id:
  341. scope["delivery_ids"].add(stop_id)
  342. else:
  343. scope["delivery_ids"] = None # type: ignore[assignment]
  344. elif stop_id:
  345. # 未知 side 但有 stop:两边都尝试匹配时由调用方按 id 过滤
  346. scope["load_ids"].add(stop_id)
  347. scope["delivery_ids"].add(stop_id)
  348. for plan_id in legacy_plan_ids or []:
  349. pid = str(plan_id or "").strip()
  350. if not pid:
  351. continue
  352. scope = _ensure(pid)
  353. if not scope["has_explicit"]:
  354. scope["use_all_stops"] = True
  355. return scopes
  356. def sum_qtys_for_scope(
  357. *,
  358. load_products_by_load: Dict[str, list],
  359. delivery_products_by_delivery: Dict[str, list],
  360. all_load_ids: Set[str],
  361. all_delivery_ids: Set[str],
  362. scope: Dict[str, Any],
  363. qty_mode: str = QTY_MODE_PLAN,
  364. ) -> Dict[ProductPackageKey, float]:
  365. acc: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  366. if scope.get("use_all_stops"):
  367. # 遗留:优先送货段数量;无送货则用装货,避免装+送双计
  368. if all_delivery_ids:
  369. for did in all_delivery_ids:
  370. accumulate_stop_products(
  371. acc,
  372. delivery_products_by_delivery.get(did, []),
  373. stop_kind="delivery",
  374. qty_mode=qty_mode,
  375. )
  376. else:
  377. for lid in all_load_ids:
  378. accumulate_stop_products(
  379. acc,
  380. load_products_by_load.get(lid, []),
  381. stop_kind="load",
  382. qty_mode=qty_mode,
  383. )
  384. return dict(acc)
  385. load_ids = scope.get("load_ids")
  386. delivery_ids = scope.get("delivery_ids")
  387. if load_ids is None:
  388. for lid in all_load_ids:
  389. accumulate_stop_products(
  390. acc,
  391. load_products_by_load.get(lid, []),
  392. stop_kind="load",
  393. qty_mode=qty_mode,
  394. )
  395. elif load_ids:
  396. for lid in load_ids:
  397. accumulate_stop_products(
  398. acc,
  399. load_products_by_load.get(lid, []),
  400. stop_kind="load",
  401. qty_mode=qty_mode,
  402. )
  403. if delivery_ids is None:
  404. for did in all_delivery_ids:
  405. accumulate_stop_products(
  406. acc,
  407. delivery_products_by_delivery.get(did, []),
  408. stop_kind="delivery",
  409. qty_mode=qty_mode,
  410. )
  411. elif delivery_ids:
  412. for did in delivery_ids:
  413. accumulate_stop_products(
  414. acc,
  415. delivery_products_by_delivery.get(did, []),
  416. stop_kind="delivery",
  417. qty_mode=qty_mode,
  418. )
  419. return dict(acc)
  420. def aggregate_contract_product_plan_qtys(
  421. db: Session,
  422. *,
  423. tenant_no: str,
  424. contract_id: str,
  425. ) -> Dict[ProductPackageKey, Dict[str, float]]:
  426. """返回 (productId, packageSpec) -> 三数量字段。"""
  427. cid = (contract_id or "").strip()
  428. if not cid:
  429. return {}
  430. link_rows = (
  431. db.query(PlPlanContract)
  432. .filter(
  433. PlPlanContract.tenant_no == tenant_no,
  434. PlPlanContract.del_flag == 0,
  435. PlPlanContract.contract_id == cid,
  436. )
  437. .all()
  438. )
  439. link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
  440. legacy_plans = (
  441. db.query(PlPlan)
  442. .filter(
  443. PlPlan.tenant_no == tenant_no,
  444. PlPlan.del_flag == 0,
  445. PlPlan.contract_id == cid,
  446. )
  447. .all()
  448. )
  449. legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
  450. scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
  451. plan_ids = list(scopes.keys())
  452. if not plan_ids:
  453. return {}
  454. plans = (
  455. db.query(PlPlan)
  456. .filter(
  457. PlPlan.tenant_no == tenant_no,
  458. PlPlan.del_flag == 0,
  459. PlPlan.id.in_(plan_ids),
  460. PlPlan.follow_status != "cancelled",
  461. )
  462. .all()
  463. )
  464. plan_by_id = {p.id: p for p in plans}
  465. loads = (
  466. db.query(PlPlanLoad)
  467. .filter(
  468. PlPlanLoad.tenant_no == tenant_no,
  469. PlPlanLoad.del_flag == 0,
  470. PlPlanLoad.plan_id.in_(plan_ids),
  471. )
  472. .all()
  473. )
  474. deliveries = (
  475. db.query(PlPlanDelivery)
  476. .filter(
  477. PlPlanDelivery.tenant_no == tenant_no,
  478. PlPlanDelivery.del_flag == 0,
  479. PlPlanDelivery.plan_id.in_(plan_ids),
  480. )
  481. .all()
  482. )
  483. load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  484. delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  485. for row in loads:
  486. load_ids_by_plan[row.plan_id].add(row.id)
  487. for row in deliveries:
  488. delivery_ids_by_plan[row.plan_id].add(row.id)
  489. all_load_ids = {row.id for row in loads}
  490. all_delivery_ids = {row.id for row in deliveries}
  491. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  492. if all_load_ids:
  493. for prod in (
  494. db.query(PlPlanLoadProduct)
  495. .filter(
  496. PlPlanLoadProduct.tenant_no == tenant_no,
  497. PlPlanLoadProduct.del_flag == 0,
  498. PlPlanLoadProduct.load_id.in_(all_load_ids),
  499. )
  500. .all()
  501. ):
  502. load_products_by_load[prod.load_id].append(prod)
  503. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  504. if all_delivery_ids:
  505. for prod in (
  506. db.query(PlPlanDeliveryProduct)
  507. .filter(
  508. PlPlanDeliveryProduct.tenant_no == tenant_no,
  509. PlPlanDeliveryProduct.del_flag == 0,
  510. PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
  511. )
  512. .all()
  513. ):
  514. delivery_products_by_delivery[prod.delivery_id].append(prod)
  515. archived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  516. arranged_unarchived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  517. for plan_id, scope in scopes.items():
  518. plan = plan_by_id.get(plan_id)
  519. if not plan:
  520. continue
  521. bucket = _bucket_for_plan(
  522. archive_status=plan.archive_status,
  523. follow_status=plan.follow_status,
  524. transport_status=plan.transport_status,
  525. )
  526. if not bucket:
  527. continue
  528. qty_mode = (
  529. QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
  530. )
  531. qtys = sum_qtys_for_scope(
  532. load_products_by_load=load_products_by_load,
  533. delivery_products_by_delivery=delivery_products_by_delivery,
  534. all_load_ids=load_ids_by_plan.get(plan_id, set()),
  535. all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
  536. scope=scope,
  537. qty_mode=qty_mode,
  538. )
  539. target = archived if bucket == "archived" else arranged_unarchived
  540. for pid, qty in qtys.items():
  541. target[pid] += qty
  542. return merge_product_qty_maps(archived=dict(archived), arranged_unarchived=dict(arranged_unarchived))
  543. def attach_plan_qty_fields_to_products(
  544. products: list,
  545. qty_by_product_id: Dict[ProductPackageKey, Dict[str, float]],
  546. ) -> None:
  547. zeros = {
  548. "archivedQty": 0.0,
  549. "arrangedUnarchivedQty": 0.0,
  550. "arrangedTotalQty": 0.0,
  551. }
  552. for item in products or []:
  553. if not isinstance(item, dict):
  554. continue
  555. specs = contract_line_package_specs(item)
  556. item["packageSpecs"] = specs
  557. if specs:
  558. item["packageSpec"] = specs[0]
  559. elif not item.get("packageSpec"):
  560. item["packageSpec"] = ""
  561. keys = pool_keys_for_contract_line(item)
  562. cpid = contract_product_id_of(item)
  563. breakdown: list[Dict[str, Any]] = []
  564. for key in keys:
  565. first = match_first_contract_product_for_key(products, key) if products else item
  566. include_pool = first is not None and (
  567. not cpid or contract_product_id_of(first) == cpid
  568. )
  569. stats = _stats_for_package_breakdown(
  570. qty_by_product_id,
  571. key,
  572. zeros,
  573. contract_product_id=cpid,
  574. include_pool_key=include_pool,
  575. )
  576. breakdown.append(
  577. {
  578. "packageSpec": key[1] if key else "",
  579. "archivedQty": stats["archivedQty"],
  580. "arrangedUnarchivedQty": stats["arrangedUnarchivedQty"],
  581. "arrangedTotalQty": stats["arrangedTotalQty"],
  582. }
  583. )
  584. occ_keys = occupancy_keys_for_contract_line(item, products)
  585. line_stats = (
  586. sum_qty_stats_for_keys(qty_by_product_id, occ_keys) if occ_keys else dict(zeros)
  587. )
  588. item["archivedQty"] = line_stats["archivedQty"]
  589. item["arrangedUnarchivedQty"] = line_stats["arrangedUnarchivedQty"]
  590. item["arrangedTotalQty"] = line_stats["arrangedTotalQty"]
  591. item["packageQtyBreakdown"] = breakdown
  592. line_qty = _to_float(item.get("quantity"))
  593. if line_qty > 0:
  594. item["remainingQty"] = round(line_qty - line_stats["arrangedTotalQty"], 3)
  595. else:
  596. item["remainingQty"] = None
  597. def plan_ids_linked_to_contract(
  598. db: Session,
  599. *,
  600. tenant_no: str,
  601. contract_id: str,
  602. ) -> Set[str]:
  603. cid = (contract_id or "").strip()
  604. if not cid:
  605. return set()
  606. ids: Set[str] = set()
  607. for (plan_id,) in (
  608. db.query(PlPlanContract.plan_id)
  609. .filter(
  610. PlPlanContract.tenant_no == tenant_no,
  611. PlPlanContract.del_flag == 0,
  612. PlPlanContract.contract_id == cid,
  613. )
  614. .distinct()
  615. .all()
  616. ):
  617. if plan_id:
  618. ids.add(str(plan_id))
  619. for (plan_id,) in (
  620. db.query(PlPlan.id)
  621. .filter(
  622. PlPlan.tenant_no == tenant_no,
  623. PlPlan.del_flag == 0,
  624. PlPlan.contract_id == cid,
  625. )
  626. .all()
  627. ):
  628. if plan_id:
  629. ids.add(str(plan_id))
  630. return ids
  631. def contract_ids_linked_to_plan(
  632. db: Session,
  633. *,
  634. tenant_no: str,
  635. plan: PlPlan,
  636. ) -> Set[str]:
  637. ids: Set[str] = set()
  638. if plan.contract_id:
  639. ids.add(str(plan.contract_id).strip())
  640. for (cid,) in (
  641. db.query(PlPlanContract.contract_id)
  642. .filter(
  643. PlPlanContract.tenant_no == tenant_no,
  644. PlPlanContract.del_flag == 0,
  645. PlPlanContract.plan_id == plan.id,
  646. )
  647. .distinct()
  648. .all()
  649. ):
  650. if cid:
  651. ids.add(str(cid).strip())
  652. return {x for x in ids if x}
  653. def auto_start_execute_approved_contracts(
  654. db: Session,
  655. *,
  656. tenant_no: str,
  657. plan: PlPlan,
  658. user_id: Optional[str] = None,
  659. ) -> list[str]:
  660. """将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。"""
  661. from datetime import datetime
  662. from app.models.crm_models import CrmContract
  663. started: list[str] = []
  664. for cid in contract_ids_linked_to_plan(db, tenant_no=tenant_no, plan=plan):
  665. row = (
  666. db.query(CrmContract)
  667. .filter(
  668. CrmContract.id == cid,
  669. CrmContract.tenant_no == tenant_no,
  670. CrmContract.del_flag == 0,
  671. )
  672. .first()
  673. )
  674. if not row or row.status != "approved":
  675. continue
  676. row.status = "executing"
  677. row.update_by = user_id
  678. row.update_time = datetime.now()
  679. started.append(cid)
  680. return started
  681. # =============================================================================
  682. # 安排运输:合同数量硬拦 / 成功后提醒
  683. # =============================================================================
  684. FRAMEWORK_CONTRACT_TYPES = frozenset({"framework", "framework_sale", "framework_purchase"})
  685. def effective_arranged_total(
  686. *,
  687. arranged_total: float,
  688. old_plan_qty: float,
  689. new_plan_qty: float,
  690. ) -> float:
  691. """已安排合计 − 本计划旧量 + 本计划新量。"""
  692. return round(
  693. _to_float(arranged_total) - _to_float(old_plan_qty) + _to_float(new_plan_qty),
  694. 3,
  695. )
  696. def contract_product_has_fixed_qty(
  697. *,
  698. contract_type: Optional[str],
  699. quantity: Any,
  700. quantity_mode: Optional[str],
  701. ) -> bool:
  702. """合同产品行是否按固定数量做超量硬拦。"""
  703. ctype = (contract_type or "").strip()
  704. mode = (quantity_mode or "").strip() or "fixed"
  705. if ctype in FRAMEWORK_CONTRACT_TYPES and mode == "customer_demand":
  706. return False
  707. qty = _to_float(quantity)
  708. return qty > 0
  709. def evaluate_product_qty_for_arrange(
  710. *,
  711. has_fixed_qty: bool,
  712. contract_qty: Optional[float],
  713. arranged_total: float,
  714. old_plan_qty: float,
  715. new_plan_qty: float,
  716. ) -> Optional[Dict[str, Any]]:
  717. """
  718. 返回:
  719. - None: 跳过(本次无量)
  720. - kind=violation | remaining | framework_cumulative
  721. """
  722. new_qty = _to_float(new_plan_qty)
  723. if new_qty <= 0:
  724. return None
  725. old_qty = _to_float(old_plan_qty)
  726. arranged = _to_float(arranged_total)
  727. effective = effective_arranged_total(
  728. arranged_total=arranged,
  729. old_plan_qty=old_qty,
  730. new_plan_qty=new_qty,
  731. )
  732. if not has_fixed_qty:
  733. return {
  734. "kind": "framework_cumulative",
  735. "arrangedTotalQty": effective,
  736. "thisPlanQty": round(new_qty, 3),
  737. "effectiveTotal": effective,
  738. }
  739. cq = _to_float(contract_qty)
  740. overflow = round(effective - cq, 3)
  741. base = {
  742. "contractQty": round(cq, 3),
  743. "arrangedTotalQty": effective,
  744. "thisPlanQty": round(new_qty, 3),
  745. "oldPlanQty": round(old_qty, 3),
  746. "newPlanQty": round(new_qty, 3),
  747. "effectiveTotal": effective,
  748. }
  749. if overflow > 0:
  750. return {**base, "kind": "violation", "overflowQty": overflow}
  751. return {
  752. **base,
  753. "kind": "remaining",
  754. "remainingQty": round(cq - effective, 3),
  755. }
  756. CONTRACT_QTY_VIOLATION_CODE = "CONTRACT_QTY_OVERFLOW"
  757. ARCHIVE_LINE_QTY_OVERAGE_CODE = "CONTRACT_LINE_QTY_OVERAGE"
  758. def _stop_kind_from_scope(scope: Dict[str, Any]) -> str:
  759. load_ids = scope.get("load_ids")
  760. delivery_ids = scope.get("delivery_ids")
  761. has_load = load_ids is None or bool(load_ids)
  762. has_delivery = delivery_ids is None or bool(delivery_ids)
  763. if scope.get("use_all_stops"):
  764. return "delivery"
  765. if has_load and not has_delivery:
  766. return "load"
  767. if has_delivery and not has_load:
  768. return "delivery"
  769. if has_load:
  770. return "load"
  771. return "delivery"
  772. def _plan_scope_for_contract(
  773. *,
  774. plan_id: str,
  775. contract_id: str,
  776. link_rows: list[PlPlanContract],
  777. legacy_contract_id: Optional[str],
  778. ) -> Dict[str, Any]:
  779. cid = (contract_id or "").strip()
  780. links = [r for r in link_rows if str(r.contract_id or "").strip() == cid]
  781. legacy_ids: list[str] = []
  782. if not links and legacy_contract_id and str(legacy_contract_id).strip() == cid:
  783. legacy_ids = [plan_id]
  784. scopes = resolve_linked_plan_scopes(links, legacy_ids)
  785. return scopes.get(plan_id) or {
  786. "load_ids": set(),
  787. "delivery_ids": set(),
  788. "use_all_stops": False,
  789. "has_explicit": False,
  790. }
  791. def _load_plan_stop_product_maps(
  792. db: Session,
  793. *,
  794. tenant_no: str,
  795. plan_id: str,
  796. ) -> tuple[Set[str], Set[str], Dict[str, list], Dict[str, list]]:
  797. loads = (
  798. db.query(PlPlanLoad)
  799. .filter(
  800. PlPlanLoad.tenant_no == tenant_no,
  801. PlPlanLoad.del_flag == 0,
  802. PlPlanLoad.plan_id == plan_id,
  803. )
  804. .all()
  805. )
  806. deliveries = (
  807. db.query(PlPlanDelivery)
  808. .filter(
  809. PlPlanDelivery.tenant_no == tenant_no,
  810. PlPlanDelivery.del_flag == 0,
  811. PlPlanDelivery.plan_id == plan_id,
  812. )
  813. .all()
  814. )
  815. load_ids = {row.id for row in loads}
  816. delivery_ids = {row.id for row in deliveries}
  817. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  818. if load_ids:
  819. for prod in (
  820. db.query(PlPlanLoadProduct)
  821. .filter(
  822. PlPlanLoadProduct.tenant_no == tenant_no,
  823. PlPlanLoadProduct.del_flag == 0,
  824. PlPlanLoadProduct.load_id.in_(load_ids),
  825. )
  826. .all()
  827. ):
  828. load_products_by_load[prod.load_id].append(prod)
  829. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  830. if delivery_ids:
  831. for prod in (
  832. db.query(PlPlanDeliveryProduct)
  833. .filter(
  834. PlPlanDeliveryProduct.tenant_no == tenant_no,
  835. PlPlanDeliveryProduct.del_flag == 0,
  836. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  837. )
  838. .all()
  839. ):
  840. delivery_products_by_delivery[prod.delivery_id].append(prod)
  841. return load_ids, delivery_ids, dict(load_products_by_load), dict(delivery_products_by_delivery)
  842. def collect_plan_contract_qty_checks(
  843. db: Session,
  844. *,
  845. tenant_no: str,
  846. plan_id: str,
  847. ) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
  848. """
  849. 返回 (violations, reminders)。
  850. 在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。
  851. """
  852. from app.models.crm_models import CrmContract, CrmContractProduct
  853. plan = (
  854. db.query(PlPlan)
  855. .filter(
  856. PlPlan.id == plan_id,
  857. PlPlan.tenant_no == tenant_no,
  858. PlPlan.del_flag == 0,
  859. )
  860. .first()
  861. )
  862. if not plan:
  863. return [], []
  864. link_rows = (
  865. db.query(PlPlanContract)
  866. .filter(
  867. PlPlanContract.tenant_no == tenant_no,
  868. PlPlanContract.del_flag == 0,
  869. PlPlanContract.plan_id == plan_id,
  870. )
  871. .all()
  872. )
  873. contract_ids: Set[str] = set()
  874. for row in link_rows:
  875. cid = str(row.contract_id or "").strip()
  876. if cid:
  877. contract_ids.add(cid)
  878. legacy_cid = str(plan.contract_id or "").strip()
  879. if legacy_cid:
  880. contract_ids.add(legacy_cid)
  881. if not contract_ids:
  882. return [], []
  883. load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
  884. _load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=plan_id)
  885. )
  886. in_bucket = (
  887. _bucket_for_plan(
  888. archive_status=plan.archive_status,
  889. follow_status=plan.follow_status,
  890. transport_status=plan.transport_status,
  891. )
  892. is not None
  893. )
  894. violations: list[Dict[str, Any]] = []
  895. reminders: list[Dict[str, Any]] = []
  896. for cid in sorted(contract_ids):
  897. contract = (
  898. db.query(CrmContract)
  899. .filter(
  900. CrmContract.id == cid,
  901. CrmContract.tenant_no == tenant_no,
  902. CrmContract.del_flag == 0,
  903. )
  904. .first()
  905. )
  906. if not contract:
  907. continue
  908. scope = _plan_scope_for_contract(
  909. plan_id=plan_id,
  910. contract_id=cid,
  911. link_rows=link_rows,
  912. legacy_contract_id=legacy_cid,
  913. )
  914. plan_qtys = sum_qtys_for_scope(
  915. load_products_by_load=load_products_by_load,
  916. delivery_products_by_delivery=delivery_products_by_delivery,
  917. all_load_ids=load_ids,
  918. all_delivery_ids=delivery_ids,
  919. scope=scope,
  920. )
  921. if not plan_qtys:
  922. continue
  923. qty_map = aggregate_contract_product_plan_qtys(
  924. db, tenant_no=tenant_no, contract_id=cid
  925. )
  926. products = (
  927. db.query(CrmContractProduct)
  928. .filter(
  929. CrmContractProduct.contract_id == cid,
  930. CrmContractProduct.tenant_no == tenant_no,
  931. CrmContractProduct.del_flag == 0,
  932. )
  933. .all()
  934. )
  935. stop_kind = _stop_kind_from_scope(scope)
  936. contract_type = (contract.contract_type or "").strip()
  937. contract_no = contract.contract_no or ""
  938. customer_name = (contract.customer_name or "").strip()
  939. products_by_id = {
  940. str(getattr(p, "id", None) or "").strip(): p
  941. for p in products
  942. if str(getattr(p, "id", None) or "").strip()
  943. }
  944. line_groups: Dict[str, Dict[str, Any]] = {}
  945. for key, new_qty_raw in plan_qtys.items():
  946. new_qty = _to_float(new_qty_raw)
  947. if new_qty <= 0:
  948. continue
  949. if not isinstance(key, tuple) or len(key) != 2:
  950. continue
  951. if is_line_occupancy_key(key):
  952. cp = products_by_id.get(str(key[1]))
  953. if not cp:
  954. continue
  955. pid = str(getattr(cp, "product_id", None) or "").strip()
  956. specs = contract_line_package_specs(cp)
  957. pkg = specs[0] if specs else ""
  958. elif key[0] == PKG_DISPLAY_PREFIX:
  959. continue
  960. else:
  961. pid, pkg = key[0], key[1]
  962. cp = match_first_contract_product_for_key(products, key)
  963. if not cp:
  964. continue
  965. line_id = str(getattr(cp, "id", None) or "").strip()
  966. if not line_id:
  967. specs = contract_line_package_specs(cp)
  968. line_id = f"{pid}\0{'|'.join(specs)}"
  969. group = line_groups.get(line_id)
  970. if not group:
  971. group = {
  972. "cp": cp,
  973. "pid": pid,
  974. "pool_keys": occupancy_keys_for_contract_line(cp, products),
  975. "this_plan_qty": 0.0,
  976. "packages": [],
  977. }
  978. line_groups[line_id] = group
  979. group["this_plan_qty"] += new_qty
  980. if pkg:
  981. group["packages"].append(pkg)
  982. for group in line_groups.values():
  983. cp = group["cp"]
  984. pid = group["pid"]
  985. new_qty = group["this_plan_qty"]
  986. pkg_label = "、".join(dict.fromkeys(group["packages"])) or None
  987. pool_stats = sum_qty_stats_for_keys(qty_map, group["pool_keys"])
  988. arranged_total = pool_stats["arrangedTotalQty"]
  989. if in_bucket:
  990. old_qty = new_qty
  991. # arranged 已含当前库内量;effective = arranged - old + new = arranged
  992. else:
  993. old_qty = 0.0
  994. has_fixed = contract_product_has_fixed_qty(
  995. contract_type=contract_type,
  996. quantity=cp.quantity,
  997. quantity_mode=cp.quantity_mode,
  998. )
  999. evaluated = evaluate_product_qty_for_arrange(
  1000. has_fixed_qty=has_fixed,
  1001. contract_qty=_to_float(cp.quantity) if has_fixed else None,
  1002. arranged_total=arranged_total,
  1003. old_plan_qty=old_qty,
  1004. new_plan_qty=new_qty,
  1005. )
  1006. if not evaluated:
  1007. continue
  1008. unit_price = getattr(cp, "price", None)
  1009. item = {
  1010. "contractId": cid,
  1011. "contractNo": contract_no,
  1012. "contractType": contract_type,
  1013. "customerName": customer_name,
  1014. "stopKind": stop_kind,
  1015. "contractProductId": str(getattr(cp, "id", None) or "").strip() or None,
  1016. "productId": pid,
  1017. "packageSpec": pkg_label,
  1018. "packageSpecs": contract_line_package_specs(cp),
  1019. "productName": cp.product_name or "",
  1020. "model": (cp.model or "").strip() or None,
  1021. "unit": (cp.unit or "").strip() or None,
  1022. "unitPrice": round(_to_float(unit_price), 2) if unit_price is not None else None,
  1023. "thisPlanQty": evaluated.get("thisPlanQty", round(new_qty, 3)),
  1024. "arrangedTotalQty": evaluated["arrangedTotalQty"],
  1025. }
  1026. kind = evaluated["kind"]
  1027. if kind == "violation":
  1028. violations.append(
  1029. {
  1030. **item,
  1031. "contractQty": evaluated["contractQty"],
  1032. "oldPlanQty": evaluated["oldPlanQty"],
  1033. "newPlanQty": evaluated["newPlanQty"],
  1034. "effectiveTotal": evaluated["effectiveTotal"],
  1035. "overflowQty": evaluated["overflowQty"],
  1036. # 与提醒表同列:剩余可为负,表示超量
  1037. "remainingQty": round(
  1038. evaluated["contractQty"] - evaluated["effectiveTotal"], 3
  1039. ),
  1040. }
  1041. )
  1042. elif kind == "remaining":
  1043. reminders.append(
  1044. {
  1045. **item,
  1046. "mode": "remaining",
  1047. "contractQty": evaluated["contractQty"],
  1048. "remainingQty": evaluated["remainingQty"],
  1049. }
  1050. )
  1051. elif kind == "framework_cumulative":
  1052. reminders.append(
  1053. {
  1054. **item,
  1055. "mode": "framework_cumulative",
  1056. }
  1057. )
  1058. return violations, reminders
  1059. def assert_contract_qty_for_arrange(
  1060. db: Session,
  1061. *,
  1062. tenant_no: str,
  1063. plan_id: str,
  1064. ) -> list[Dict[str, Any]]:
  1065. """超限则 422;通过则返回 reminders(供成功响应复用)。"""
  1066. from fastapi import HTTPException
  1067. violations, reminders = collect_plan_contract_qty_checks(
  1068. db, tenant_no=tenant_no, plan_id=plan_id
  1069. )
  1070. if violations:
  1071. raise HTTPException(
  1072. status_code=422,
  1073. detail={
  1074. "code": CONTRACT_QTY_VIOLATION_CODE,
  1075. "message": "合同产品安排数量超过合同数量",
  1076. "items": violations,
  1077. },
  1078. )
  1079. return reminders
  1080. def build_contract_qty_reminder_payload(
  1081. reminders: list[Dict[str, Any]],
  1082. ) -> Optional[Dict[str, Any]]:
  1083. if not reminders:
  1084. return None
  1085. return {"items": reminders}
  1086. def validate_stop_products_package_against_contract(
  1087. *,
  1088. contract_products: Iterable[Any],
  1089. stop_products: Iterable[Any],
  1090. ) -> tuple[bool, Optional[str]]:
  1091. """
  1092. 绑合同时:计划段产品包装必填,且必须属于本合同该产品的包装集合。
  1093. 返回 (ok, error_message)。
  1094. """
  1095. allowed_by_pid: DefaultDict[str, Set[str]] = defaultdict(set)
  1096. contract_pids: Set[str] = set()
  1097. for row in contract_products or []:
  1098. pid = ""
  1099. if isinstance(row, dict):
  1100. pid = str(row.get("productId") or row.get("product_id") or "").strip()
  1101. else:
  1102. pid = str(getattr(row, "product_id", None) or "").strip()
  1103. if not pid:
  1104. continue
  1105. contract_pids.add(pid)
  1106. for pkg in contract_line_package_specs(row):
  1107. if pkg:
  1108. allowed_by_pid[pid].add(pkg)
  1109. for row in stop_products or []:
  1110. pid = str(getattr(row, "product_id", None) or "").strip()
  1111. if not pid or pid not in contract_pids:
  1112. continue
  1113. allowed = allowed_by_pid.get(pid) or set()
  1114. if not allowed:
  1115. continue
  1116. pkg = normalize_package_spec(getattr(row, "package_spec", None))
  1117. name = (
  1118. str(getattr(row, "product_name", None) or "").strip()
  1119. or pid
  1120. )
  1121. if not pkg:
  1122. return False, f"请选择包装规格:{name}"
  1123. if pkg not in allowed:
  1124. return False, f"包装规格不在合同明细中:{name}({pkg})"
  1125. return True, None
  1126. def get_contract_product_arrange_qty_detail(
  1127. db: Session,
  1128. *,
  1129. tenant_no: str,
  1130. contract_id: str,
  1131. product_id: str = "",
  1132. package_spec: Optional[str] = None,
  1133. exclude_plan_id: Optional[str] = None,
  1134. contract_product_id: Optional[str] = None,
  1135. ) -> Dict[str, Any]:
  1136. """
  1137. 表单「可安排数量」与问号弹窗数据。
  1138. arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中)。
  1139. """
  1140. from app.models.crm_models import CrmContract, CrmContractProduct
  1141. cid = (contract_id or "").strip()
  1142. cpid = str(contract_product_id or "").strip()
  1143. pid = (product_id or "").strip()
  1144. pkg = normalize_package_spec(package_spec)
  1145. key = product_package_key(pid, pkg) if pid else None
  1146. exclude_id = (exclude_plan_id or "").strip() or None
  1147. empty = {
  1148. "contractId": cid,
  1149. "contractNo": None,
  1150. "customerName": None,
  1151. "contractProductId": cpid or None,
  1152. "productId": pid,
  1153. "packageSpec": pkg or None,
  1154. "packageSpecs": [],
  1155. "packageBreakdown": [],
  1156. "productName": None,
  1157. "hasFixedQty": False,
  1158. "contractQty": None,
  1159. "arrangedQty": 0.0,
  1160. "availableQty": None,
  1161. "plans": [],
  1162. }
  1163. if not cid:
  1164. return empty
  1165. if not cpid and (not pid or not key):
  1166. return empty
  1167. contract = (
  1168. db.query(CrmContract)
  1169. .filter(
  1170. CrmContract.id == cid,
  1171. CrmContract.tenant_no == tenant_no,
  1172. CrmContract.del_flag == 0,
  1173. )
  1174. .first()
  1175. )
  1176. if not contract:
  1177. return empty
  1178. all_lines = (
  1179. db.query(CrmContractProduct)
  1180. .filter(
  1181. CrmContractProduct.contract_id == cid,
  1182. CrmContractProduct.tenant_no == tenant_no,
  1183. CrmContractProduct.del_flag == 0,
  1184. )
  1185. .all()
  1186. )
  1187. cp = None
  1188. if cpid:
  1189. cp = next((row for row in all_lines if str(row.id) == cpid), None)
  1190. if cp:
  1191. pid = str(cp.product_id or "").strip()
  1192. specs = contract_line_package_specs(cp)
  1193. if not pkg and specs:
  1194. pkg = specs[0]
  1195. key = product_package_key(pid, pkg) if pid else key
  1196. elif key:
  1197. cp = match_first_contract_product_for_key(all_lines, key)
  1198. contract_type = (contract.contract_type or "").strip()
  1199. has_fixed = False
  1200. contract_qty: Optional[float] = None
  1201. package_specs: list[str] = []
  1202. pool_keys: list[ProductPackageKey] = [key] if key else []
  1203. occupancy_keys: list[ProductPackageKey] = list(pool_keys)
  1204. if cp:
  1205. package_specs = contract_line_package_specs(cp)
  1206. pool_keys = pool_keys_for_contract_line(cp) or pool_keys
  1207. occupancy_keys = occupancy_keys_for_contract_line(cp, all_lines) or occupancy_keys
  1208. has_fixed = contract_product_has_fixed_qty(
  1209. contract_type=contract_type,
  1210. quantity=cp.quantity,
  1211. quantity_mode=cp.quantity_mode,
  1212. )
  1213. if has_fixed:
  1214. contract_qty = round(_to_float(cp.quantity), 3)
  1215. qty_map = aggregate_contract_product_plan_qtys(
  1216. db, tenant_no=tenant_no, contract_id=cid
  1217. )
  1218. arranged_including = sum_qty_stats_for_keys(qty_map, occupancy_keys)["arrangedTotalQty"]
  1219. line_cpid = contract_product_id_of(cp) if cp else cpid
  1220. package_breakdown = []
  1221. for pool_key in pool_keys:
  1222. first = match_first_contract_product_for_key(all_lines, pool_key) if all_lines else cp
  1223. include_pool = first is not None and (
  1224. not line_cpid or contract_product_id_of(first) == line_cpid
  1225. )
  1226. stats = _stats_for_package_breakdown(
  1227. qty_map,
  1228. pool_key,
  1229. {
  1230. "archivedQty": 0.0,
  1231. "arrangedUnarchivedQty": 0.0,
  1232. "arrangedTotalQty": 0.0,
  1233. },
  1234. contract_product_id=line_cpid,
  1235. include_pool_key=include_pool,
  1236. )
  1237. package_breakdown.append(
  1238. {
  1239. "packageSpec": pool_key[1],
  1240. "archivedQty": stats["archivedQty"],
  1241. "arrangedUnarchivedQty": stats["arrangedUnarchivedQty"],
  1242. "arrangedTotalQty": stats["arrangedTotalQty"],
  1243. }
  1244. )
  1245. link_rows = (
  1246. db.query(PlPlanContract)
  1247. .filter(
  1248. PlPlanContract.tenant_no == tenant_no,
  1249. PlPlanContract.del_flag == 0,
  1250. PlPlanContract.contract_id == cid,
  1251. )
  1252. .all()
  1253. )
  1254. link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
  1255. legacy_plans = (
  1256. db.query(PlPlan)
  1257. .filter(
  1258. PlPlan.tenant_no == tenant_no,
  1259. PlPlan.del_flag == 0,
  1260. PlPlan.contract_id == cid,
  1261. )
  1262. .all()
  1263. )
  1264. legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
  1265. scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
  1266. plan_ids = list(scopes.keys())
  1267. exclude_qty = 0.0
  1268. plans_out: list[Dict[str, Any]] = []
  1269. if plan_ids:
  1270. plans = (
  1271. db.query(PlPlan)
  1272. .filter(
  1273. PlPlan.tenant_no == tenant_no,
  1274. PlPlan.del_flag == 0,
  1275. PlPlan.id.in_(plan_ids),
  1276. PlPlan.follow_status != "cancelled",
  1277. )
  1278. .all()
  1279. )
  1280. plan_by_id = {p.id: p for p in plans}
  1281. loads = (
  1282. db.query(PlPlanLoad)
  1283. .filter(
  1284. PlPlanLoad.tenant_no == tenant_no,
  1285. PlPlanLoad.del_flag == 0,
  1286. PlPlanLoad.plan_id.in_(plan_ids),
  1287. )
  1288. .all()
  1289. )
  1290. deliveries = (
  1291. db.query(PlPlanDelivery)
  1292. .filter(
  1293. PlPlanDelivery.tenant_no == tenant_no,
  1294. PlPlanDelivery.del_flag == 0,
  1295. PlPlanDelivery.plan_id.in_(plan_ids),
  1296. )
  1297. .all()
  1298. )
  1299. load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  1300. delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  1301. for row in loads:
  1302. load_ids_by_plan[row.plan_id].add(row.id)
  1303. for row in deliveries:
  1304. delivery_ids_by_plan[row.plan_id].add(row.id)
  1305. all_load_ids = {row.id for row in loads}
  1306. all_delivery_ids = {row.id for row in deliveries}
  1307. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  1308. if all_load_ids:
  1309. for prod in (
  1310. db.query(PlPlanLoadProduct)
  1311. .filter(
  1312. PlPlanLoadProduct.tenant_no == tenant_no,
  1313. PlPlanLoadProduct.del_flag == 0,
  1314. PlPlanLoadProduct.load_id.in_(all_load_ids),
  1315. )
  1316. .all()
  1317. ):
  1318. load_products_by_load[prod.load_id].append(prod)
  1319. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  1320. if all_delivery_ids:
  1321. for prod in (
  1322. db.query(PlPlanDeliveryProduct)
  1323. .filter(
  1324. PlPlanDeliveryProduct.tenant_no == tenant_no,
  1325. PlPlanDeliveryProduct.del_flag == 0,
  1326. PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
  1327. )
  1328. .all()
  1329. ):
  1330. delivery_products_by_delivery[prod.delivery_id].append(prod)
  1331. for plan_id, scope in scopes.items():
  1332. plan = plan_by_id.get(plan_id)
  1333. if not plan:
  1334. continue
  1335. bucket = _bucket_for_plan(
  1336. archive_status=plan.archive_status,
  1337. follow_status=plan.follow_status,
  1338. transport_status=plan.transport_status,
  1339. )
  1340. if not bucket:
  1341. continue
  1342. qty_mode = (
  1343. QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
  1344. )
  1345. qtys = sum_qtys_for_scope(
  1346. load_products_by_load=load_products_by_load,
  1347. delivery_products_by_delivery=delivery_products_by_delivery,
  1348. all_load_ids=load_ids_by_plan.get(plan_id, set()),
  1349. all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
  1350. scope=scope,
  1351. qty_mode=qty_mode,
  1352. )
  1353. pq = 0.0
  1354. for occ_key in occupancy_keys:
  1355. pq += _to_float(qtys.get(occ_key))
  1356. if pq <= 0:
  1357. continue
  1358. if exclude_id and str(plan_id) == exclude_id:
  1359. exclude_qty = pq
  1360. continue
  1361. plan_date = getattr(plan, "plan_date", None)
  1362. plans_out.append(
  1363. {
  1364. "planId": plan_id,
  1365. "planNo": plan.plan_no or "",
  1366. "planDate": plan_date.isoformat() if plan_date else None,
  1367. "planQty": round(pq, 3),
  1368. "planType": getattr(plan, "plan_type", None),
  1369. "belongsCompany": getattr(plan, "belongs_company", None),
  1370. "supplierName": getattr(plan, "supplier_name", None),
  1371. "customerName": getattr(plan, "customer_name", None),
  1372. "licensePlate": getattr(plan, "license_plate", None),
  1373. "driverName": getattr(plan, "driver_name", None),
  1374. "followUserName": getattr(plan, "follow_user_name", None),
  1375. "followStatus": plan.follow_status,
  1376. "transportStatus": plan.transport_status,
  1377. "archiveStatus": plan.archive_status,
  1378. "createTime": plan.create_time.isoformat(sep=" ", timespec="seconds")
  1379. if getattr(plan, "create_time", None)
  1380. else None,
  1381. }
  1382. )
  1383. plans_out.sort(key=lambda x: (x.get("planDate") or "", x.get("planNo") or ""), reverse=True)
  1384. arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
  1385. available_qty = None
  1386. if has_fixed and contract_qty is not None:
  1387. available_qty = round(contract_qty - arranged_qty, 3)
  1388. return {
  1389. "contractId": cid,
  1390. "contractNo": contract.contract_no or "",
  1391. "customerName": (contract.customer_name or "").strip() or None,
  1392. "contractProductId": str(getattr(cp, "id", None) or "").strip() or cpid or None,
  1393. "productId": pid,
  1394. "packageSpec": pkg or None,
  1395. "packageSpecs": package_specs or ([pkg] if pkg else []),
  1396. "packageBreakdown": package_breakdown,
  1397. "productName": (cp.product_name if cp else None) or None,
  1398. "hasFixedQty": has_fixed,
  1399. "contractQty": contract_qty,
  1400. "arrangedQty": arranged_qty,
  1401. "availableQty": available_qty,
  1402. "plans": plans_out,
  1403. }
  1404. def _exclude_plan_qty_for_keys(
  1405. db: Session,
  1406. *,
  1407. tenant_no: str,
  1408. contract_id: str,
  1409. exclude_plan_id: str,
  1410. occupancy_keys: list[ProductPackageKey],
  1411. ) -> float:
  1412. """当前编辑计划在已安排桶中时,从占用中扣回本计划用量(轻量,不拼 plans 列表)。"""
  1413. cid = (contract_id or "").strip()
  1414. exclude_id = (exclude_plan_id or "").strip()
  1415. if not cid or not exclude_id or not occupancy_keys:
  1416. return 0.0
  1417. link_rows = (
  1418. db.query(PlPlanContract)
  1419. .filter(
  1420. PlPlanContract.tenant_no == tenant_no,
  1421. PlPlanContract.del_flag == 0,
  1422. PlPlanContract.contract_id == cid,
  1423. PlPlanContract.plan_id == exclude_id,
  1424. )
  1425. .all()
  1426. )
  1427. legacy = (
  1428. db.query(PlPlan)
  1429. .filter(
  1430. PlPlan.tenant_no == tenant_no,
  1431. PlPlan.del_flag == 0,
  1432. PlPlan.id == exclude_id,
  1433. PlPlan.contract_id == cid,
  1434. )
  1435. .first()
  1436. )
  1437. legacy_only = {exclude_id} if legacy and not link_rows else set()
  1438. scopes = resolve_linked_plan_scopes(link_rows, legacy_only)
  1439. scope = scopes.get(exclude_id)
  1440. if not scope:
  1441. return 0.0
  1442. plan = (
  1443. db.query(PlPlan)
  1444. .filter(
  1445. PlPlan.tenant_no == tenant_no,
  1446. PlPlan.del_flag == 0,
  1447. PlPlan.id == exclude_id,
  1448. PlPlan.follow_status != "cancelled",
  1449. )
  1450. .first()
  1451. )
  1452. if not plan:
  1453. return 0.0
  1454. bucket = _bucket_for_plan(
  1455. archive_status=plan.archive_status,
  1456. follow_status=plan.follow_status,
  1457. transport_status=plan.transport_status,
  1458. )
  1459. if not bucket:
  1460. return 0.0
  1461. qty_mode = QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
  1462. loads = (
  1463. db.query(PlPlanLoad)
  1464. .filter(
  1465. PlPlanLoad.tenant_no == tenant_no,
  1466. PlPlanLoad.del_flag == 0,
  1467. PlPlanLoad.plan_id == exclude_id,
  1468. )
  1469. .all()
  1470. )
  1471. deliveries = (
  1472. db.query(PlPlanDelivery)
  1473. .filter(
  1474. PlPlanDelivery.tenant_no == tenant_no,
  1475. PlPlanDelivery.del_flag == 0,
  1476. PlPlanDelivery.plan_id == exclude_id,
  1477. )
  1478. .all()
  1479. )
  1480. load_ids = {row.id for row in loads}
  1481. delivery_ids = {row.id for row in deliveries}
  1482. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  1483. if load_ids:
  1484. for prod in (
  1485. db.query(PlPlanLoadProduct)
  1486. .filter(
  1487. PlPlanLoadProduct.tenant_no == tenant_no,
  1488. PlPlanLoadProduct.del_flag == 0,
  1489. PlPlanLoadProduct.load_id.in_(load_ids),
  1490. )
  1491. .all()
  1492. ):
  1493. load_products_by_load[prod.load_id].append(prod)
  1494. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  1495. if delivery_ids:
  1496. for prod in (
  1497. db.query(PlPlanDeliveryProduct)
  1498. .filter(
  1499. PlPlanDeliveryProduct.tenant_no == tenant_no,
  1500. PlPlanDeliveryProduct.del_flag == 0,
  1501. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  1502. )
  1503. .all()
  1504. ):
  1505. delivery_products_by_delivery[prod.delivery_id].append(prod)
  1506. qtys = sum_qtys_for_scope(
  1507. load_products_by_load=load_products_by_load,
  1508. delivery_products_by_delivery=delivery_products_by_delivery,
  1509. all_load_ids=load_ids,
  1510. all_delivery_ids=delivery_ids,
  1511. scope=scope,
  1512. qty_mode=qty_mode,
  1513. )
  1514. total = 0.0
  1515. for occ_key in occupancy_keys:
  1516. total += _to_float(qtys.get(occ_key))
  1517. return total
  1518. def batch_contract_product_arrange_qty_summaries(
  1519. db: Session,
  1520. *,
  1521. tenant_no: str,
  1522. items: list[Dict[str, Any]],
  1523. ) -> list[Dict[str, Any]]:
  1524. """
  1525. 表单打开时批量取「可安排数量」摘要(不含 plans 明细)。
  1526. 同合同只聚合一次用量,供装/送货产品表展示,避免 N 次详情接口。
  1527. """
  1528. from app.models.crm_models import CrmContract, CrmContractProduct
  1529. if not items:
  1530. return []
  1531. by_contract: DefaultDict[str, list[tuple[int, Dict[str, Any]]]] = defaultdict(list)
  1532. for idx, raw in enumerate(items):
  1533. if not isinstance(raw, dict):
  1534. continue
  1535. cid = str(raw.get("contractId") or raw.get("contract_id") or "").strip()
  1536. if not cid:
  1537. continue
  1538. by_contract[cid].append((idx, raw))
  1539. out: list[Optional[Dict[str, Any]]] = [None] * len(items)
  1540. for cid, indexed in by_contract.items():
  1541. contract = (
  1542. db.query(CrmContract)
  1543. .filter(
  1544. CrmContract.id == cid,
  1545. CrmContract.tenant_no == tenant_no,
  1546. CrmContract.del_flag == 0,
  1547. )
  1548. .first()
  1549. )
  1550. if not contract:
  1551. for idx, raw in indexed:
  1552. out[idx] = {
  1553. "key": str(raw.get("key") or ""),
  1554. "contractId": cid,
  1555. "contractNo": None,
  1556. "customerName": None,
  1557. "contractProductId": str(
  1558. raw.get("contractProductId") or raw.get("contract_product_id") or ""
  1559. ).strip()
  1560. or None,
  1561. "productId": str(raw.get("productId") or raw.get("product_id") or "").strip(),
  1562. "packageSpec": normalize_package_spec(
  1563. raw.get("packageSpec") or raw.get("package_spec")
  1564. )
  1565. or None,
  1566. "packageSpecs": [],
  1567. "packageBreakdown": [],
  1568. "productName": None,
  1569. "hasFixedQty": False,
  1570. "contractQty": None,
  1571. "arrangedQty": 0.0,
  1572. "availableQty": None,
  1573. "plans": [],
  1574. }
  1575. continue
  1576. all_lines = (
  1577. db.query(CrmContractProduct)
  1578. .filter(
  1579. CrmContractProduct.contract_id == cid,
  1580. CrmContractProduct.tenant_no == tenant_no,
  1581. CrmContractProduct.del_flag == 0,
  1582. )
  1583. .all()
  1584. )
  1585. qty_map = aggregate_contract_product_plan_qtys(
  1586. db, tenant_no=tenant_no, contract_id=cid
  1587. )
  1588. contract_type = (contract.contract_type or "").strip()
  1589. exclude_cache: Dict[str, Dict[Tuple[ProductPackageKey, ...], float]] = {}
  1590. for idx, raw in indexed:
  1591. cpid = str(
  1592. raw.get("contractProductId") or raw.get("contract_product_id") or ""
  1593. ).strip()
  1594. pid = str(raw.get("productId") or raw.get("product_id") or "").strip()
  1595. pkg = normalize_package_spec(raw.get("packageSpec") or raw.get("package_spec"))
  1596. key = product_package_key(pid, pkg) if pid else None
  1597. exclude_id = str(
  1598. raw.get("excludePlanId") or raw.get("exclude_plan_id") or ""
  1599. ).strip()
  1600. client_key = str(raw.get("key") or "")
  1601. cp = None
  1602. if cpid:
  1603. cp = next((row for row in all_lines if str(row.id) == cpid), None)
  1604. if cp:
  1605. pid = str(cp.product_id or "").strip()
  1606. specs = contract_line_package_specs(cp)
  1607. if not pkg and specs:
  1608. pkg = specs[0]
  1609. key = product_package_key(pid, pkg) if pid else key
  1610. elif key:
  1611. cp = match_first_contract_product_for_key(all_lines, key)
  1612. has_fixed = False
  1613. contract_qty: Optional[float] = None
  1614. package_specs: list[str] = []
  1615. pool_keys: list[ProductPackageKey] = [key] if key else []
  1616. occupancy_keys: list[ProductPackageKey] = list(pool_keys)
  1617. if cp:
  1618. package_specs = contract_line_package_specs(cp)
  1619. pool_keys = pool_keys_for_contract_line(cp) or pool_keys
  1620. occupancy_keys = (
  1621. occupancy_keys_for_contract_line(cp, all_lines) or occupancy_keys
  1622. )
  1623. has_fixed = contract_product_has_fixed_qty(
  1624. contract_type=contract_type,
  1625. quantity=cp.quantity,
  1626. quantity_mode=cp.quantity_mode,
  1627. )
  1628. if has_fixed:
  1629. contract_qty = round(_to_float(cp.quantity), 3)
  1630. arranged_including = sum_qty_stats_for_keys(qty_map, occupancy_keys)[
  1631. "arrangedTotalQty"
  1632. ]
  1633. exclude_qty = 0.0
  1634. if exclude_id and occupancy_keys:
  1635. occ_tuple = tuple(occupancy_keys)
  1636. by_occ = exclude_cache.setdefault(exclude_id, {})
  1637. if occ_tuple not in by_occ:
  1638. by_occ[occ_tuple] = _exclude_plan_qty_for_keys(
  1639. db,
  1640. tenant_no=tenant_no,
  1641. contract_id=cid,
  1642. exclude_plan_id=exclude_id,
  1643. occupancy_keys=list(occupancy_keys),
  1644. )
  1645. exclude_qty = by_occ[occ_tuple]
  1646. arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
  1647. available_qty = None
  1648. if has_fixed and contract_qty is not None:
  1649. available_qty = round(contract_qty - arranged_qty, 3)
  1650. out[idx] = {
  1651. "key": client_key,
  1652. "contractId": cid,
  1653. "contractNo": contract.contract_no or "",
  1654. "customerName": (contract.customer_name or "").strip() or None,
  1655. "contractProductId": str(getattr(cp, "id", None) or "").strip()
  1656. or cpid
  1657. or None,
  1658. "productId": pid,
  1659. "packageSpec": pkg or None,
  1660. "packageSpecs": package_specs or ([pkg] if pkg else []),
  1661. "packageBreakdown": [],
  1662. "productName": (cp.product_name if cp else None) or None,
  1663. "hasFixedQty": has_fixed,
  1664. "contractQty": contract_qty,
  1665. "arrangedQty": arranged_qty,
  1666. "availableQty": available_qty,
  1667. "plans": [],
  1668. }
  1669. return [row if row is not None else {"key": "", "plans": [], "hasFixedQty": False, "arrangedQty": 0.0, "productId": "", "contractId": ""} for row in out]
  1670. def collect_archive_line_overages(
  1671. db: Session,
  1672. *,
  1673. tenant_no: str,
  1674. plan_id: str,
  1675. load_block: Any,
  1676. delivery_blocks: Optional[Iterable[Any]] = None,
  1677. ) -> list[Dict[str, Any]]:
  1678. """正式归档:实际数量超过合同行剩余时返回超交明细(不含确认标志)。"""
  1679. from app.models.crm_models import CrmContract, CrmContractProduct
  1680. pid = (plan_id or "").strip()
  1681. if not pid:
  1682. return []
  1683. load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
  1684. _load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=pid)
  1685. )
  1686. actual_by_id: Dict[str, float] = {}
  1687. if load_block is not None:
  1688. for item in getattr(load_block, "products", None) or []:
  1689. row_id = str(getattr(item, "id", None) or "").strip()
  1690. if not row_id:
  1691. continue
  1692. qty = getattr(item, "inbound_qty", None)
  1693. if qty is not None:
  1694. actual_by_id[row_id] = _to_float(qty)
  1695. for block in delivery_blocks or []:
  1696. for item in getattr(block, "products", None) or []:
  1697. row_id = str(getattr(item, "id", None) or "").strip()
  1698. if not row_id:
  1699. continue
  1700. qty = getattr(item, "outbound_qty", None)
  1701. if qty is not None:
  1702. actual_by_id[row_id] = _to_float(qty)
  1703. rows: list[Any] = []
  1704. for load_id in load_ids:
  1705. rows.extend(load_products_by_load.get(load_id, []))
  1706. for did in delivery_ids:
  1707. rows.extend(delivery_products_by_delivery.get(did, []))
  1708. qty_map_by_contract: Dict[str, Dict[ProductPackageKey, Dict[str, float]]] = {}
  1709. lines_by_contract: Dict[str, list] = {}
  1710. overages: list[Dict[str, Any]] = []
  1711. for row in rows:
  1712. cpid = str(getattr(row, "contract_product_id", None) or "").strip()
  1713. if not cpid:
  1714. continue
  1715. row_id = str(getattr(row, "id", None) or "").strip()
  1716. if row_id not in actual_by_id:
  1717. continue
  1718. actual = actual_by_id[row_id]
  1719. contract_id = str(getattr(row, "contract_id", None) or "").strip()
  1720. cp = (
  1721. db.query(CrmContractProduct)
  1722. .filter(
  1723. CrmContractProduct.id == cpid,
  1724. CrmContractProduct.tenant_no == tenant_no,
  1725. CrmContractProduct.del_flag == 0,
  1726. )
  1727. .first()
  1728. )
  1729. if not cp:
  1730. continue
  1731. if not contract_id:
  1732. contract_id = str(cp.contract_id or "").strip()
  1733. contract = (
  1734. db.query(CrmContract)
  1735. .filter(
  1736. CrmContract.id == contract_id,
  1737. CrmContract.tenant_no == tenant_no,
  1738. CrmContract.del_flag == 0,
  1739. )
  1740. .first()
  1741. )
  1742. if not contract:
  1743. continue
  1744. contract_type = (contract.contract_type or "").strip()
  1745. has_fixed = contract_product_has_fixed_qty(
  1746. contract_type=contract_type,
  1747. quantity=cp.quantity,
  1748. quantity_mode=cp.quantity_mode,
  1749. )
  1750. if not has_fixed:
  1751. continue
  1752. if contract_id not in qty_map_by_contract:
  1753. qty_map_by_contract[contract_id] = aggregate_contract_product_plan_qtys(
  1754. db, tenant_no=tenant_no, contract_id=contract_id
  1755. )
  1756. lines_by_contract[contract_id] = (
  1757. db.query(CrmContractProduct)
  1758. .filter(
  1759. CrmContractProduct.contract_id == contract_id,
  1760. CrmContractProduct.tenant_no == tenant_no,
  1761. CrmContractProduct.del_flag == 0,
  1762. )
  1763. .all()
  1764. )
  1765. occ_keys = occupancy_keys_for_contract_line(
  1766. cp, lines_by_contract.get(contract_id) or [cp]
  1767. )
  1768. arranged_total = sum_qty_stats_for_keys(
  1769. qty_map_by_contract[contract_id], occ_keys
  1770. )["arrangedTotalQty"]
  1771. this_occupancy = _to_float(getattr(row, "plan_qty", None))
  1772. inbound = getattr(row, "inbound_qty", None)
  1773. outbound = getattr(row, "outbound_qty", None)
  1774. if inbound is not None:
  1775. this_occupancy = _to_float(inbound)
  1776. elif outbound is not None:
  1777. this_occupancy = _to_float(outbound)
  1778. evaluated = evaluate_product_qty_for_arrange(
  1779. has_fixed_qty=True,
  1780. contract_qty=_to_float(cp.quantity),
  1781. arranged_total=arranged_total,
  1782. old_plan_qty=this_occupancy,
  1783. new_plan_qty=actual,
  1784. )
  1785. if not evaluated or evaluated.get("kind") != "violation":
  1786. continue
  1787. specs = contract_line_package_specs(cp)
  1788. overages.append(
  1789. {
  1790. "stopProductId": row_id,
  1791. "contractId": contract_id,
  1792. "contractNo": contract.contract_no or "",
  1793. "contractProductId": cpid,
  1794. "productId": str(cp.product_id or ""),
  1795. "productName": cp.product_name or "",
  1796. "packageSpec": "、".join(specs) if specs else getattr(row, "package_spec", None),
  1797. "unitPrice": round(_to_float(cp.price), 2) if cp.price is not None else None,
  1798. "contractQty": evaluated["contractQty"],
  1799. "remainingQty": round(
  1800. evaluated["contractQty"] - (arranged_total - this_occupancy), 3
  1801. ),
  1802. "actualQty": round(actual, 3),
  1803. "overflowQty": evaluated["overflowQty"],
  1804. }
  1805. )
  1806. return overages