pl_contract_plan_qty.py 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216
  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. # 待安排 / 等通知 / 作废:不计入「已安排」(见 _bucket_for_plan)
  23. ProductPackageKey = Tuple[str, str]
  24. def normalize_package_spec(value: Any) -> str:
  25. if value is None:
  26. return ""
  27. return str(value).strip()
  28. def product_package_key(product_id: Any, package_spec: Any = None) -> Optional[ProductPackageKey]:
  29. pid = str(product_id or "").strip()
  30. if not pid:
  31. return None
  32. return (pid, normalize_package_spec(package_spec))
  33. def match_contract_product_for_key(
  34. products: Iterable[Any],
  35. key: Optional[ProductPackageKey],
  36. ) -> Optional[Any]:
  37. """按复合键匹配合同产品行;同键多行时 quantity 相加(防御)。"""
  38. if not key:
  39. return None
  40. matched = []
  41. for p in products or []:
  42. k = product_package_key(
  43. getattr(p, "product_id", None),
  44. getattr(p, "package_spec", None),
  45. )
  46. if k == key:
  47. matched.append(p)
  48. if not matched:
  49. return None
  50. if len(matched) == 1:
  51. return matched[0]
  52. from types import SimpleNamespace
  53. total_qty = sum(_to_float(getattr(p, "quantity", None)) for p in matched)
  54. base = matched[0]
  55. return SimpleNamespace(
  56. product_id=getattr(base, "product_id", None),
  57. package_spec=getattr(base, "package_spec", None),
  58. product_name=getattr(base, "product_name", None),
  59. model=getattr(base, "model", None),
  60. unit=getattr(base, "unit", None),
  61. quantity=total_qty,
  62. quantity_mode=getattr(base, "quantity_mode", None),
  63. )
  64. def _to_float(value: Any) -> float:
  65. if value is None:
  66. return 0.0
  67. try:
  68. return float(value)
  69. except (TypeError, ValueError):
  70. return 0.0
  71. def _bucket_for_plan(*, archive_status: Optional[str], follow_status: Optional[str], transport_status: Optional[str]) -> Optional[str]:
  72. """返回 'archived' | 'arranged_unarchived' | None(不计入)。"""
  73. follow = (follow_status or "").strip()
  74. if follow == "cancelled" or follow in ("wait_notice", "pending_arrange"):
  75. return None
  76. archive = (archive_status or "").strip() or "unarchived"
  77. if archive == "archived":
  78. return "archived"
  79. # 已安排未归档(含 archive draft 暂存)
  80. transport = (transport_status or "").strip()
  81. if transport == "arranged" or follow in ("arranged", "completed"):
  82. return "arranged_unarchived"
  83. return None
  84. def merge_product_qty_maps(
  85. *,
  86. archived: Dict[ProductPackageKey, float],
  87. arranged_unarchived: Dict[ProductPackageKey, float],
  88. ) -> Dict[ProductPackageKey, Dict[str, float]]:
  89. """(product_id, package_spec) -> {archivedQty, arrangedUnarchivedQty, arrangedTotalQty}"""
  90. keys = set(archived) | set(arranged_unarchived)
  91. out: Dict[ProductPackageKey, Dict[str, float]] = {}
  92. for key in keys:
  93. a = round(float(archived.get(key, 0.0)), 3)
  94. u = round(float(arranged_unarchived.get(key, 0.0)), 3)
  95. out[key] = {
  96. "archivedQty": a,
  97. "arrangedUnarchivedQty": u,
  98. "arrangedTotalQty": round(a + u, 3),
  99. }
  100. return out
  101. # plan:计划数量;archive_actual:装货 inbound / 送货 outbound,null 回退 plan_qty
  102. QTY_MODE_PLAN = "plan"
  103. QTY_MODE_ARCHIVE_ACTUAL = "archive_actual"
  104. def _resolve_stop_product_qty(
  105. row: Any,
  106. *,
  107. stop_kind: str,
  108. qty_mode: str,
  109. ) -> float:
  110. """按模式解析段产品计入量。"""
  111. if qty_mode == QTY_MODE_ARCHIVE_ACTUAL:
  112. attr = "inbound_qty" if stop_kind == "load" else "outbound_qty"
  113. raw = getattr(row, attr, None)
  114. if raw is not None:
  115. return _to_float(raw)
  116. return _to_float(getattr(row, "plan_qty", None))
  117. return _to_float(getattr(row, "plan_qty", None))
  118. def accumulate_stop_products(
  119. target: DefaultDict[ProductPackageKey, float],
  120. products: Iterable[Any],
  121. *,
  122. stop_kind: str = "load",
  123. qty_mode: str = QTY_MODE_PLAN,
  124. ) -> None:
  125. for row in products or []:
  126. key = product_package_key(
  127. getattr(row, "product_id", None),
  128. getattr(row, "package_spec", None),
  129. )
  130. if not key:
  131. continue
  132. qty = _resolve_stop_product_qty(row, stop_kind=stop_kind, qty_mode=qty_mode)
  133. if qty:
  134. target[key] += qty
  135. def resolve_linked_plan_scopes(
  136. link_rows: Iterable[PlPlanContract],
  137. legacy_plan_ids: Iterable[str],
  138. ) -> Dict[str, Dict[str, Any]]:
  139. """
  140. plan_id -> {
  141. load_ids: set[str] | None, # None = 该侧全部装货段
  142. delivery_ids: set[str] | None,
  143. use_all_stops: bool, # 遗留主表合同:优先送货段,否则装货段,各产品只计一次
  144. }
  145. """
  146. scopes: Dict[str, Dict[str, Any]] = {}
  147. def _ensure(plan_id: str) -> Dict[str, Any]:
  148. if plan_id not in scopes:
  149. scopes[plan_id] = {
  150. "load_ids": set(),
  151. "delivery_ids": set(),
  152. "use_all_stops": False,
  153. "has_explicit": False,
  154. }
  155. return scopes[plan_id]
  156. for row in link_rows or []:
  157. plan_id = str(getattr(row, "plan_id", None) or "").strip()
  158. if not plan_id:
  159. continue
  160. scope = _ensure(plan_id)
  161. side = (getattr(row, "side", None) or "").strip()
  162. stop_id = str(getattr(row, "stop_id", None) or "").strip()
  163. scope["has_explicit"] = True
  164. if side in (PLAN_CONTRACT_SIDE_LOAD, PLAN_CONTRACT_SIDE_SUPPLIER):
  165. if stop_id:
  166. scope["load_ids"].add(stop_id)
  167. else:
  168. scope["load_ids"] = None # type: ignore[assignment]
  169. elif side in (PLAN_CONTRACT_SIDE_DELIVERY, PLAN_CONTRACT_SIDE_DEMANDER):
  170. if stop_id:
  171. scope["delivery_ids"].add(stop_id)
  172. else:
  173. scope["delivery_ids"] = None # type: ignore[assignment]
  174. elif stop_id:
  175. # 未知 side 但有 stop:两边都尝试匹配时由调用方按 id 过滤
  176. scope["load_ids"].add(stop_id)
  177. scope["delivery_ids"].add(stop_id)
  178. for plan_id in legacy_plan_ids or []:
  179. pid = str(plan_id or "").strip()
  180. if not pid:
  181. continue
  182. scope = _ensure(pid)
  183. if not scope["has_explicit"]:
  184. scope["use_all_stops"] = True
  185. return scopes
  186. def sum_qtys_for_scope(
  187. *,
  188. load_products_by_load: Dict[str, list],
  189. delivery_products_by_delivery: Dict[str, list],
  190. all_load_ids: Set[str],
  191. all_delivery_ids: Set[str],
  192. scope: Dict[str, Any],
  193. qty_mode: str = QTY_MODE_PLAN,
  194. ) -> Dict[ProductPackageKey, float]:
  195. acc: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  196. if scope.get("use_all_stops"):
  197. # 遗留:优先送货段数量;无送货则用装货,避免装+送双计
  198. if all_delivery_ids:
  199. for did in all_delivery_ids:
  200. accumulate_stop_products(
  201. acc,
  202. delivery_products_by_delivery.get(did, []),
  203. stop_kind="delivery",
  204. qty_mode=qty_mode,
  205. )
  206. else:
  207. for lid in all_load_ids:
  208. accumulate_stop_products(
  209. acc,
  210. load_products_by_load.get(lid, []),
  211. stop_kind="load",
  212. qty_mode=qty_mode,
  213. )
  214. return dict(acc)
  215. load_ids = scope.get("load_ids")
  216. delivery_ids = scope.get("delivery_ids")
  217. if load_ids is None:
  218. for lid in all_load_ids:
  219. accumulate_stop_products(
  220. acc,
  221. load_products_by_load.get(lid, []),
  222. stop_kind="load",
  223. qty_mode=qty_mode,
  224. )
  225. elif load_ids:
  226. for lid in load_ids:
  227. accumulate_stop_products(
  228. acc,
  229. load_products_by_load.get(lid, []),
  230. stop_kind="load",
  231. qty_mode=qty_mode,
  232. )
  233. if delivery_ids is None:
  234. for did in all_delivery_ids:
  235. accumulate_stop_products(
  236. acc,
  237. delivery_products_by_delivery.get(did, []),
  238. stop_kind="delivery",
  239. qty_mode=qty_mode,
  240. )
  241. elif delivery_ids:
  242. for did in delivery_ids:
  243. accumulate_stop_products(
  244. acc,
  245. delivery_products_by_delivery.get(did, []),
  246. stop_kind="delivery",
  247. qty_mode=qty_mode,
  248. )
  249. return dict(acc)
  250. def aggregate_contract_product_plan_qtys(
  251. db: Session,
  252. *,
  253. tenant_no: str,
  254. contract_id: str,
  255. ) -> Dict[ProductPackageKey, Dict[str, float]]:
  256. """返回 (productId, packageSpec) -> 三数量字段。"""
  257. cid = (contract_id or "").strip()
  258. if not cid:
  259. return {}
  260. link_rows = (
  261. db.query(PlPlanContract)
  262. .filter(
  263. PlPlanContract.tenant_no == tenant_no,
  264. PlPlanContract.del_flag == 0,
  265. PlPlanContract.contract_id == cid,
  266. )
  267. .all()
  268. )
  269. link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
  270. legacy_plans = (
  271. db.query(PlPlan)
  272. .filter(
  273. PlPlan.tenant_no == tenant_no,
  274. PlPlan.del_flag == 0,
  275. PlPlan.contract_id == cid,
  276. )
  277. .all()
  278. )
  279. legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
  280. scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
  281. plan_ids = list(scopes.keys())
  282. if not plan_ids:
  283. return {}
  284. plans = (
  285. db.query(PlPlan)
  286. .filter(
  287. PlPlan.tenant_no == tenant_no,
  288. PlPlan.del_flag == 0,
  289. PlPlan.id.in_(plan_ids),
  290. PlPlan.follow_status != "cancelled",
  291. )
  292. .all()
  293. )
  294. plan_by_id = {p.id: p for p in plans}
  295. loads = (
  296. db.query(PlPlanLoad)
  297. .filter(
  298. PlPlanLoad.tenant_no == tenant_no,
  299. PlPlanLoad.del_flag == 0,
  300. PlPlanLoad.plan_id.in_(plan_ids),
  301. )
  302. .all()
  303. )
  304. deliveries = (
  305. db.query(PlPlanDelivery)
  306. .filter(
  307. PlPlanDelivery.tenant_no == tenant_no,
  308. PlPlanDelivery.del_flag == 0,
  309. PlPlanDelivery.plan_id.in_(plan_ids),
  310. )
  311. .all()
  312. )
  313. load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  314. delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  315. for row in loads:
  316. load_ids_by_plan[row.plan_id].add(row.id)
  317. for row in deliveries:
  318. delivery_ids_by_plan[row.plan_id].add(row.id)
  319. all_load_ids = {row.id for row in loads}
  320. all_delivery_ids = {row.id for row in deliveries}
  321. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  322. if all_load_ids:
  323. for prod in (
  324. db.query(PlPlanLoadProduct)
  325. .filter(
  326. PlPlanLoadProduct.tenant_no == tenant_no,
  327. PlPlanLoadProduct.del_flag == 0,
  328. PlPlanLoadProduct.load_id.in_(all_load_ids),
  329. )
  330. .all()
  331. ):
  332. load_products_by_load[prod.load_id].append(prod)
  333. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  334. if all_delivery_ids:
  335. for prod in (
  336. db.query(PlPlanDeliveryProduct)
  337. .filter(
  338. PlPlanDeliveryProduct.tenant_no == tenant_no,
  339. PlPlanDeliveryProduct.del_flag == 0,
  340. PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
  341. )
  342. .all()
  343. ):
  344. delivery_products_by_delivery[prod.delivery_id].append(prod)
  345. archived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  346. arranged_unarchived: DefaultDict[ProductPackageKey, float] = defaultdict(float)
  347. for plan_id, scope in scopes.items():
  348. plan = plan_by_id.get(plan_id)
  349. if not plan:
  350. continue
  351. bucket = _bucket_for_plan(
  352. archive_status=plan.archive_status,
  353. follow_status=plan.follow_status,
  354. transport_status=plan.transport_status,
  355. )
  356. if not bucket:
  357. continue
  358. qty_mode = (
  359. QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
  360. )
  361. qtys = sum_qtys_for_scope(
  362. load_products_by_load=load_products_by_load,
  363. delivery_products_by_delivery=delivery_products_by_delivery,
  364. all_load_ids=load_ids_by_plan.get(plan_id, set()),
  365. all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
  366. scope=scope,
  367. qty_mode=qty_mode,
  368. )
  369. target = archived if bucket == "archived" else arranged_unarchived
  370. for pid, qty in qtys.items():
  371. target[pid] += qty
  372. return merge_product_qty_maps(archived=dict(archived), arranged_unarchived=dict(arranged_unarchived))
  373. def attach_plan_qty_fields_to_products(
  374. products: list,
  375. qty_by_product_id: Dict[ProductPackageKey, Dict[str, float]],
  376. ) -> None:
  377. for item in products or []:
  378. if not isinstance(item, dict):
  379. continue
  380. key = product_package_key(
  381. item.get("productId"),
  382. item.get("packageSpec") or item.get("package_spec"),
  383. )
  384. stats = (qty_by_product_id.get(key) if key else None) or {
  385. "archivedQty": 0.0,
  386. "arrangedUnarchivedQty": 0.0,
  387. "arrangedTotalQty": 0.0,
  388. }
  389. item["archivedQty"] = stats["archivedQty"]
  390. item["arrangedUnarchivedQty"] = stats["arrangedUnarchivedQty"]
  391. item["arrangedTotalQty"] = stats["arrangedTotalQty"]
  392. def plan_ids_linked_to_contract(
  393. db: Session,
  394. *,
  395. tenant_no: str,
  396. contract_id: str,
  397. ) -> Set[str]:
  398. cid = (contract_id or "").strip()
  399. if not cid:
  400. return set()
  401. ids: Set[str] = set()
  402. for (plan_id,) in (
  403. db.query(PlPlanContract.plan_id)
  404. .filter(
  405. PlPlanContract.tenant_no == tenant_no,
  406. PlPlanContract.del_flag == 0,
  407. PlPlanContract.contract_id == cid,
  408. )
  409. .distinct()
  410. .all()
  411. ):
  412. if plan_id:
  413. ids.add(str(plan_id))
  414. for (plan_id,) in (
  415. db.query(PlPlan.id)
  416. .filter(
  417. PlPlan.tenant_no == tenant_no,
  418. PlPlan.del_flag == 0,
  419. PlPlan.contract_id == cid,
  420. )
  421. .all()
  422. ):
  423. if plan_id:
  424. ids.add(str(plan_id))
  425. return ids
  426. def contract_ids_linked_to_plan(
  427. db: Session,
  428. *,
  429. tenant_no: str,
  430. plan: PlPlan,
  431. ) -> Set[str]:
  432. ids: Set[str] = set()
  433. if plan.contract_id:
  434. ids.add(str(plan.contract_id).strip())
  435. for (cid,) in (
  436. db.query(PlPlanContract.contract_id)
  437. .filter(
  438. PlPlanContract.tenant_no == tenant_no,
  439. PlPlanContract.del_flag == 0,
  440. PlPlanContract.plan_id == plan.id,
  441. )
  442. .distinct()
  443. .all()
  444. ):
  445. if cid:
  446. ids.add(str(cid).strip())
  447. return {x for x in ids if x}
  448. def auto_start_execute_approved_contracts(
  449. db: Session,
  450. *,
  451. tenant_no: str,
  452. plan: PlPlan,
  453. user_id: Optional[str] = None,
  454. ) -> list[str]:
  455. """将关联且状态为 approved 的合同置为 executing(不 commit)。返回已推进的合同 id。"""
  456. from datetime import datetime
  457. from app.models.crm_models import CrmContract
  458. started: list[str] = []
  459. for cid in contract_ids_linked_to_plan(db, tenant_no=tenant_no, plan=plan):
  460. row = (
  461. db.query(CrmContract)
  462. .filter(
  463. CrmContract.id == cid,
  464. CrmContract.tenant_no == tenant_no,
  465. CrmContract.del_flag == 0,
  466. )
  467. .first()
  468. )
  469. if not row or row.status != "approved":
  470. continue
  471. row.status = "executing"
  472. row.update_by = user_id
  473. row.update_time = datetime.now()
  474. started.append(cid)
  475. return started
  476. # =============================================================================
  477. # 安排运输:合同数量硬拦 / 成功后提醒
  478. # =============================================================================
  479. FRAMEWORK_CONTRACT_TYPES = frozenset({"framework", "framework_sale", "framework_purchase"})
  480. def effective_arranged_total(
  481. *,
  482. arranged_total: float,
  483. old_plan_qty: float,
  484. new_plan_qty: float,
  485. ) -> float:
  486. """已安排合计 − 本计划旧量 + 本计划新量。"""
  487. return round(
  488. _to_float(arranged_total) - _to_float(old_plan_qty) + _to_float(new_plan_qty),
  489. 3,
  490. )
  491. def contract_product_has_fixed_qty(
  492. *,
  493. contract_type: Optional[str],
  494. quantity: Any,
  495. quantity_mode: Optional[str],
  496. ) -> bool:
  497. """合同产品行是否按固定数量做超量硬拦。"""
  498. ctype = (contract_type or "").strip()
  499. mode = (quantity_mode or "").strip() or "fixed"
  500. if ctype in FRAMEWORK_CONTRACT_TYPES and mode == "customer_demand":
  501. return False
  502. qty = _to_float(quantity)
  503. return qty > 0
  504. def evaluate_product_qty_for_arrange(
  505. *,
  506. has_fixed_qty: bool,
  507. contract_qty: Optional[float],
  508. arranged_total: float,
  509. old_plan_qty: float,
  510. new_plan_qty: float,
  511. ) -> Optional[Dict[str, Any]]:
  512. """
  513. 返回:
  514. - None: 跳过(本次无量)
  515. - kind=violation | remaining | framework_cumulative
  516. """
  517. new_qty = _to_float(new_plan_qty)
  518. if new_qty <= 0:
  519. return None
  520. old_qty = _to_float(old_plan_qty)
  521. arranged = _to_float(arranged_total)
  522. effective = effective_arranged_total(
  523. arranged_total=arranged,
  524. old_plan_qty=old_qty,
  525. new_plan_qty=new_qty,
  526. )
  527. if not has_fixed_qty:
  528. return {
  529. "kind": "framework_cumulative",
  530. "arrangedTotalQty": effective,
  531. "thisPlanQty": round(new_qty, 3),
  532. "effectiveTotal": effective,
  533. }
  534. cq = _to_float(contract_qty)
  535. overflow = round(effective - cq, 3)
  536. base = {
  537. "contractQty": round(cq, 3),
  538. "arrangedTotalQty": effective,
  539. "thisPlanQty": round(new_qty, 3),
  540. "oldPlanQty": round(old_qty, 3),
  541. "newPlanQty": round(new_qty, 3),
  542. "effectiveTotal": effective,
  543. }
  544. if overflow > 0:
  545. return {**base, "kind": "violation", "overflowQty": overflow}
  546. return {
  547. **base,
  548. "kind": "remaining",
  549. "remainingQty": round(cq - effective, 3),
  550. }
  551. CONTRACT_QTY_VIOLATION_CODE = "CONTRACT_QTY_OVERFLOW"
  552. def _stop_kind_from_scope(scope: Dict[str, Any]) -> str:
  553. load_ids = scope.get("load_ids")
  554. delivery_ids = scope.get("delivery_ids")
  555. has_load = load_ids is None or bool(load_ids)
  556. has_delivery = delivery_ids is None or bool(delivery_ids)
  557. if scope.get("use_all_stops"):
  558. return "delivery"
  559. if has_load and not has_delivery:
  560. return "load"
  561. if has_delivery and not has_load:
  562. return "delivery"
  563. if has_load:
  564. return "load"
  565. return "delivery"
  566. def _plan_scope_for_contract(
  567. *,
  568. plan_id: str,
  569. contract_id: str,
  570. link_rows: list[PlPlanContract],
  571. legacy_contract_id: Optional[str],
  572. ) -> Dict[str, Any]:
  573. cid = (contract_id or "").strip()
  574. links = [r for r in link_rows if str(r.contract_id or "").strip() == cid]
  575. legacy_ids: list[str] = []
  576. if not links and legacy_contract_id and str(legacy_contract_id).strip() == cid:
  577. legacy_ids = [plan_id]
  578. scopes = resolve_linked_plan_scopes(links, legacy_ids)
  579. return scopes.get(plan_id) or {
  580. "load_ids": set(),
  581. "delivery_ids": set(),
  582. "use_all_stops": False,
  583. "has_explicit": False,
  584. }
  585. def _load_plan_stop_product_maps(
  586. db: Session,
  587. *,
  588. tenant_no: str,
  589. plan_id: str,
  590. ) -> tuple[Set[str], Set[str], Dict[str, list], Dict[str, list]]:
  591. loads = (
  592. db.query(PlPlanLoad)
  593. .filter(
  594. PlPlanLoad.tenant_no == tenant_no,
  595. PlPlanLoad.del_flag == 0,
  596. PlPlanLoad.plan_id == plan_id,
  597. )
  598. .all()
  599. )
  600. deliveries = (
  601. db.query(PlPlanDelivery)
  602. .filter(
  603. PlPlanDelivery.tenant_no == tenant_no,
  604. PlPlanDelivery.del_flag == 0,
  605. PlPlanDelivery.plan_id == plan_id,
  606. )
  607. .all()
  608. )
  609. load_ids = {row.id for row in loads}
  610. delivery_ids = {row.id for row in deliveries}
  611. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  612. if load_ids:
  613. for prod in (
  614. db.query(PlPlanLoadProduct)
  615. .filter(
  616. PlPlanLoadProduct.tenant_no == tenant_no,
  617. PlPlanLoadProduct.del_flag == 0,
  618. PlPlanLoadProduct.load_id.in_(load_ids),
  619. )
  620. .all()
  621. ):
  622. load_products_by_load[prod.load_id].append(prod)
  623. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  624. if delivery_ids:
  625. for prod in (
  626. db.query(PlPlanDeliveryProduct)
  627. .filter(
  628. PlPlanDeliveryProduct.tenant_no == tenant_no,
  629. PlPlanDeliveryProduct.del_flag == 0,
  630. PlPlanDeliveryProduct.delivery_id.in_(delivery_ids),
  631. )
  632. .all()
  633. ):
  634. delivery_products_by_delivery[prod.delivery_id].append(prod)
  635. return load_ids, delivery_ids, dict(load_products_by_load), dict(delivery_products_by_delivery)
  636. def collect_plan_contract_qty_checks(
  637. db: Session,
  638. *,
  639. tenant_no: str,
  640. plan_id: str,
  641. ) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
  642. """
  643. 返回 (violations, reminders)。
  644. 在写运输前调用;reminders 可缓存到成功响应,勿在状态变更后再算。
  645. """
  646. from app.models.crm_models import CrmContract, CrmContractProduct
  647. plan = (
  648. db.query(PlPlan)
  649. .filter(
  650. PlPlan.id == plan_id,
  651. PlPlan.tenant_no == tenant_no,
  652. PlPlan.del_flag == 0,
  653. )
  654. .first()
  655. )
  656. if not plan:
  657. return [], []
  658. link_rows = (
  659. db.query(PlPlanContract)
  660. .filter(
  661. PlPlanContract.tenant_no == tenant_no,
  662. PlPlanContract.del_flag == 0,
  663. PlPlanContract.plan_id == plan_id,
  664. )
  665. .all()
  666. )
  667. contract_ids: Set[str] = set()
  668. for row in link_rows:
  669. cid = str(row.contract_id or "").strip()
  670. if cid:
  671. contract_ids.add(cid)
  672. legacy_cid = str(plan.contract_id or "").strip()
  673. if legacy_cid:
  674. contract_ids.add(legacy_cid)
  675. if not contract_ids:
  676. return [], []
  677. load_ids, delivery_ids, load_products_by_load, delivery_products_by_delivery = (
  678. _load_plan_stop_product_maps(db, tenant_no=tenant_no, plan_id=plan_id)
  679. )
  680. in_bucket = (
  681. _bucket_for_plan(
  682. archive_status=plan.archive_status,
  683. follow_status=plan.follow_status,
  684. transport_status=plan.transport_status,
  685. )
  686. is not None
  687. )
  688. violations: list[Dict[str, Any]] = []
  689. reminders: list[Dict[str, Any]] = []
  690. for cid in sorted(contract_ids):
  691. contract = (
  692. db.query(CrmContract)
  693. .filter(
  694. CrmContract.id == cid,
  695. CrmContract.tenant_no == tenant_no,
  696. CrmContract.del_flag == 0,
  697. )
  698. .first()
  699. )
  700. if not contract:
  701. continue
  702. scope = _plan_scope_for_contract(
  703. plan_id=plan_id,
  704. contract_id=cid,
  705. link_rows=link_rows,
  706. legacy_contract_id=legacy_cid,
  707. )
  708. plan_qtys = sum_qtys_for_scope(
  709. load_products_by_load=load_products_by_load,
  710. delivery_products_by_delivery=delivery_products_by_delivery,
  711. all_load_ids=load_ids,
  712. all_delivery_ids=delivery_ids,
  713. scope=scope,
  714. )
  715. if not plan_qtys:
  716. continue
  717. qty_map = aggregate_contract_product_plan_qtys(
  718. db, tenant_no=tenant_no, contract_id=cid
  719. )
  720. products = (
  721. db.query(CrmContractProduct)
  722. .filter(
  723. CrmContractProduct.contract_id == cid,
  724. CrmContractProduct.tenant_no == tenant_no,
  725. CrmContractProduct.del_flag == 0,
  726. )
  727. .all()
  728. )
  729. stop_kind = _stop_kind_from_scope(scope)
  730. contract_type = (contract.contract_type or "").strip()
  731. contract_no = contract.contract_no or ""
  732. customer_name = (contract.customer_name or "").strip()
  733. for key, new_qty_raw in plan_qtys.items():
  734. new_qty = _to_float(new_qty_raw)
  735. if new_qty <= 0:
  736. continue
  737. if not isinstance(key, tuple) or len(key) != 2:
  738. continue
  739. pid, pkg = key[0], key[1]
  740. cp = match_contract_product_for_key(products, key)
  741. # 计划产品不在合同明细:不校验数量(归属另有规则)
  742. if not cp:
  743. continue
  744. arranged_total = _to_float((qty_map.get(key) or {}).get("arrangedTotalQty"))
  745. if in_bucket:
  746. old_qty = new_qty
  747. # arranged 已含当前库内量;effective = arranged - old + new = arranged
  748. else:
  749. old_qty = 0.0
  750. has_fixed = contract_product_has_fixed_qty(
  751. contract_type=contract_type,
  752. quantity=cp.quantity,
  753. quantity_mode=cp.quantity_mode,
  754. )
  755. evaluated = evaluate_product_qty_for_arrange(
  756. has_fixed_qty=has_fixed,
  757. contract_qty=_to_float(cp.quantity) if has_fixed else None,
  758. arranged_total=arranged_total,
  759. old_plan_qty=old_qty,
  760. new_plan_qty=new_qty,
  761. )
  762. if not evaluated:
  763. continue
  764. item = {
  765. "contractId": cid,
  766. "contractNo": contract_no,
  767. "contractType": contract_type,
  768. "customerName": customer_name,
  769. "stopKind": stop_kind,
  770. "productId": pid,
  771. "packageSpec": pkg or None,
  772. "productName": cp.product_name or "",
  773. "model": (cp.model or "").strip() or None,
  774. "unit": (cp.unit or "").strip() or None,
  775. "thisPlanQty": evaluated.get("thisPlanQty", round(new_qty, 3)),
  776. "arrangedTotalQty": evaluated["arrangedTotalQty"],
  777. }
  778. kind = evaluated["kind"]
  779. if kind == "violation":
  780. violations.append(
  781. {
  782. **item,
  783. "contractQty": evaluated["contractQty"],
  784. "oldPlanQty": evaluated["oldPlanQty"],
  785. "newPlanQty": evaluated["newPlanQty"],
  786. "effectiveTotal": evaluated["effectiveTotal"],
  787. "overflowQty": evaluated["overflowQty"],
  788. # 与提醒表同列:剩余可为负,表示超量
  789. "remainingQty": round(
  790. evaluated["contractQty"] - evaluated["effectiveTotal"], 3
  791. ),
  792. }
  793. )
  794. elif kind == "remaining":
  795. reminders.append(
  796. {
  797. **item,
  798. "mode": "remaining",
  799. "contractQty": evaluated["contractQty"],
  800. "remainingQty": evaluated["remainingQty"],
  801. }
  802. )
  803. elif kind == "framework_cumulative":
  804. reminders.append(
  805. {
  806. **item,
  807. "mode": "framework_cumulative",
  808. }
  809. )
  810. return violations, reminders
  811. def assert_contract_qty_for_arrange(
  812. db: Session,
  813. *,
  814. tenant_no: str,
  815. plan_id: str,
  816. ) -> list[Dict[str, Any]]:
  817. """超限则 422;通过则返回 reminders(供成功响应复用)。"""
  818. from fastapi import HTTPException
  819. violations, reminders = collect_plan_contract_qty_checks(
  820. db, tenant_no=tenant_no, plan_id=plan_id
  821. )
  822. if violations:
  823. raise HTTPException(
  824. status_code=422,
  825. detail={
  826. "code": CONTRACT_QTY_VIOLATION_CODE,
  827. "message": "合同产品安排数量超过合同数量",
  828. "items": violations,
  829. },
  830. )
  831. return reminders
  832. def build_contract_qty_reminder_payload(
  833. reminders: list[Dict[str, Any]],
  834. ) -> Optional[Dict[str, Any]]:
  835. if not reminders:
  836. return None
  837. return {"items": reminders}
  838. def validate_stop_products_package_against_contract(
  839. *,
  840. contract_products: Iterable[Any],
  841. stop_products: Iterable[Any],
  842. ) -> tuple[bool, Optional[str]]:
  843. """
  844. 绑合同时:计划段产品包装必填,且必须属于本合同该产品的包装集合。
  845. 返回 (ok, error_message)。
  846. """
  847. allowed_by_pid: DefaultDict[str, Set[str]] = defaultdict(set)
  848. contract_pids: Set[str] = set()
  849. for row in contract_products or []:
  850. pid = str(getattr(row, "product_id", None) or "").strip()
  851. if not pid:
  852. continue
  853. contract_pids.add(pid)
  854. pkg = normalize_package_spec(getattr(row, "package_spec", None))
  855. if pkg:
  856. allowed_by_pid[pid].add(pkg)
  857. for row in stop_products or []:
  858. pid = str(getattr(row, "product_id", None) or "").strip()
  859. if not pid or pid not in contract_pids:
  860. continue
  861. allowed = allowed_by_pid.get(pid) or set()
  862. if not allowed:
  863. continue
  864. pkg = normalize_package_spec(getattr(row, "package_spec", None))
  865. name = (
  866. str(getattr(row, "product_name", None) or "").strip()
  867. or pid
  868. )
  869. if not pkg:
  870. return False, f"请选择包装规格:{name}"
  871. if pkg not in allowed:
  872. return False, f"包装规格不在合同明细中:{name}({pkg})"
  873. return True, None
  874. def get_contract_product_arrange_qty_detail(
  875. db: Session,
  876. *,
  877. tenant_no: str,
  878. contract_id: str,
  879. product_id: str,
  880. package_spec: Optional[str] = None,
  881. exclude_plan_id: Optional[str] = None,
  882. ) -> Dict[str, Any]:
  883. """
  884. 表单「可安排数量」与问号弹窗数据。
  885. arrangedQty / availableQty 均不含 exclude_plan_id(若该计划已在已安排桶中)。
  886. """
  887. from app.models.crm_models import CrmContract, CrmContractProduct
  888. cid = (contract_id or "").strip()
  889. pid = (product_id or "").strip()
  890. pkg = normalize_package_spec(package_spec)
  891. key = product_package_key(pid, pkg)
  892. exclude_id = (exclude_plan_id or "").strip() or None
  893. empty = {
  894. "contractId": cid,
  895. "contractNo": None,
  896. "customerName": None,
  897. "productId": pid,
  898. "packageSpec": pkg or None,
  899. "productName": None,
  900. "hasFixedQty": False,
  901. "contractQty": None,
  902. "arrangedQty": 0.0,
  903. "availableQty": None,
  904. "plans": [],
  905. }
  906. if not cid or not pid or not key:
  907. return empty
  908. contract = (
  909. db.query(CrmContract)
  910. .filter(
  911. CrmContract.id == cid,
  912. CrmContract.tenant_no == tenant_no,
  913. CrmContract.del_flag == 0,
  914. )
  915. .first()
  916. )
  917. if not contract:
  918. return empty
  919. candidates = (
  920. db.query(CrmContractProduct)
  921. .filter(
  922. CrmContractProduct.contract_id == cid,
  923. CrmContractProduct.product_id == pid,
  924. CrmContractProduct.tenant_no == tenant_no,
  925. CrmContractProduct.del_flag == 0,
  926. )
  927. .all()
  928. )
  929. cp = match_contract_product_for_key(candidates, key)
  930. contract_type = (contract.contract_type or "").strip()
  931. has_fixed = False
  932. contract_qty: Optional[float] = None
  933. if cp:
  934. has_fixed = contract_product_has_fixed_qty(
  935. contract_type=contract_type,
  936. quantity=cp.quantity,
  937. quantity_mode=cp.quantity_mode,
  938. )
  939. if has_fixed:
  940. contract_qty = round(_to_float(cp.quantity), 3)
  941. qty_map = aggregate_contract_product_plan_qtys(
  942. db, tenant_no=tenant_no, contract_id=cid
  943. )
  944. arranged_including = _to_float((qty_map.get(key) or {}).get("arrangedTotalQty"))
  945. link_rows = (
  946. db.query(PlPlanContract)
  947. .filter(
  948. PlPlanContract.tenant_no == tenant_no,
  949. PlPlanContract.del_flag == 0,
  950. PlPlanContract.contract_id == cid,
  951. )
  952. .all()
  953. )
  954. link_plan_ids = {str(r.plan_id) for r in link_rows if r.plan_id}
  955. legacy_plans = (
  956. db.query(PlPlan)
  957. .filter(
  958. PlPlan.tenant_no == tenant_no,
  959. PlPlan.del_flag == 0,
  960. PlPlan.contract_id == cid,
  961. )
  962. .all()
  963. )
  964. legacy_only_ids = {p.id for p in legacy_plans if p.id not in link_plan_ids}
  965. scopes = resolve_linked_plan_scopes(link_rows, legacy_only_ids)
  966. plan_ids = list(scopes.keys())
  967. exclude_qty = 0.0
  968. plans_out: list[Dict[str, Any]] = []
  969. if plan_ids:
  970. plans = (
  971. db.query(PlPlan)
  972. .filter(
  973. PlPlan.tenant_no == tenant_no,
  974. PlPlan.del_flag == 0,
  975. PlPlan.id.in_(plan_ids),
  976. PlPlan.follow_status != "cancelled",
  977. )
  978. .all()
  979. )
  980. plan_by_id = {p.id: p for p in plans}
  981. loads = (
  982. db.query(PlPlanLoad)
  983. .filter(
  984. PlPlanLoad.tenant_no == tenant_no,
  985. PlPlanLoad.del_flag == 0,
  986. PlPlanLoad.plan_id.in_(plan_ids),
  987. )
  988. .all()
  989. )
  990. deliveries = (
  991. db.query(PlPlanDelivery)
  992. .filter(
  993. PlPlanDelivery.tenant_no == tenant_no,
  994. PlPlanDelivery.del_flag == 0,
  995. PlPlanDelivery.plan_id.in_(plan_ids),
  996. )
  997. .all()
  998. )
  999. load_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  1000. delivery_ids_by_plan: DefaultDict[str, Set[str]] = defaultdict(set)
  1001. for row in loads:
  1002. load_ids_by_plan[row.plan_id].add(row.id)
  1003. for row in deliveries:
  1004. delivery_ids_by_plan[row.plan_id].add(row.id)
  1005. all_load_ids = {row.id for row in loads}
  1006. all_delivery_ids = {row.id for row in deliveries}
  1007. load_products_by_load: DefaultDict[str, list] = defaultdict(list)
  1008. if all_load_ids:
  1009. for prod in (
  1010. db.query(PlPlanLoadProduct)
  1011. .filter(
  1012. PlPlanLoadProduct.tenant_no == tenant_no,
  1013. PlPlanLoadProduct.del_flag == 0,
  1014. PlPlanLoadProduct.load_id.in_(all_load_ids),
  1015. )
  1016. .all()
  1017. ):
  1018. load_products_by_load[prod.load_id].append(prod)
  1019. delivery_products_by_delivery: DefaultDict[str, list] = defaultdict(list)
  1020. if all_delivery_ids:
  1021. for prod in (
  1022. db.query(PlPlanDeliveryProduct)
  1023. .filter(
  1024. PlPlanDeliveryProduct.tenant_no == tenant_no,
  1025. PlPlanDeliveryProduct.del_flag == 0,
  1026. PlPlanDeliveryProduct.delivery_id.in_(all_delivery_ids),
  1027. )
  1028. .all()
  1029. ):
  1030. delivery_products_by_delivery[prod.delivery_id].append(prod)
  1031. for plan_id, scope in scopes.items():
  1032. plan = plan_by_id.get(plan_id)
  1033. if not plan:
  1034. continue
  1035. bucket = _bucket_for_plan(
  1036. archive_status=plan.archive_status,
  1037. follow_status=plan.follow_status,
  1038. transport_status=plan.transport_status,
  1039. )
  1040. if not bucket:
  1041. continue
  1042. qty_mode = (
  1043. QTY_MODE_ARCHIVE_ACTUAL if bucket == "archived" else QTY_MODE_PLAN
  1044. )
  1045. qtys = sum_qtys_for_scope(
  1046. load_products_by_load=load_products_by_load,
  1047. delivery_products_by_delivery=delivery_products_by_delivery,
  1048. all_load_ids=load_ids_by_plan.get(plan_id, set()),
  1049. all_delivery_ids=delivery_ids_by_plan.get(plan_id, set()),
  1050. scope=scope,
  1051. qty_mode=qty_mode,
  1052. )
  1053. pq = _to_float(qtys.get(key))
  1054. if pq <= 0:
  1055. continue
  1056. if exclude_id and str(plan_id) == exclude_id:
  1057. exclude_qty = pq
  1058. continue
  1059. plan_date = getattr(plan, "plan_date", None)
  1060. plans_out.append(
  1061. {
  1062. "planId": plan_id,
  1063. "planNo": plan.plan_no or "",
  1064. "planDate": plan_date.isoformat() if plan_date else None,
  1065. "planQty": round(pq, 3),
  1066. "planType": getattr(plan, "plan_type", None),
  1067. "belongsCompany": getattr(plan, "belongs_company", None),
  1068. "supplierName": getattr(plan, "supplier_name", None),
  1069. "customerName": getattr(plan, "customer_name", None),
  1070. "licensePlate": getattr(plan, "license_plate", None),
  1071. "driverName": getattr(plan, "driver_name", None),
  1072. "followUserName": getattr(plan, "follow_user_name", None),
  1073. "followStatus": plan.follow_status,
  1074. "transportStatus": plan.transport_status,
  1075. "archiveStatus": plan.archive_status,
  1076. "createTime": plan.create_time.isoformat(sep=" ", timespec="seconds")
  1077. if getattr(plan, "create_time", None)
  1078. else None,
  1079. }
  1080. )
  1081. plans_out.sort(key=lambda x: (x.get("planDate") or "", x.get("planNo") or ""), reverse=True)
  1082. arranged_qty = round(max(0.0, arranged_including - exclude_qty), 3)
  1083. available_qty = None
  1084. if has_fixed and contract_qty is not None:
  1085. available_qty = round(contract_qty - arranged_qty, 3)
  1086. return {
  1087. "contractId": cid,
  1088. "contractNo": contract.contract_no or "",
  1089. "customerName": (contract.customer_name or "").strip() or None,
  1090. "productId": pid,
  1091. "packageSpec": pkg or None,
  1092. "productName": (cp.product_name if cp else None) or None,
  1093. "hasFixedQty": has_fixed,
  1094. "contractQty": contract_qty,
  1095. "arrangedQty": arranged_qty,
  1096. "availableQty": available_qty,
  1097. "plans": plans_out,
  1098. }