pl_contract_plan_qty.py 35 KB

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