pl_plan_execution_lock.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. """运输计划是否已经开始,以及强制作废时冲销下游单据。
  2. 已开始:已过磅、地磅出入库已执行、现场已回传,或入库/出库实际数量被改过。
  3. 未开始的计划仍可调整和普通作废。已开始的计划普通用户不能再调整或作废。
  4. """
  5. from __future__ import annotations
  6. from dataclasses import dataclass, field
  7. from datetime import datetime
  8. from typing import Optional
  9. from sqlalchemy import or_
  10. from sqlalchemy.orm import Session
  11. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  12. from app.models.pl_models import (
  13. PlInboundOrder,
  14. PlInboundOrderItem,
  15. PlOutboundOrder,
  16. PlOutboundOrderItem,
  17. PlTransport,
  18. PlTransportDbLink,
  19. PlTransportSiteLink,
  20. PlTransportSiteStopFeedback,
  21. )
  22. from app.services.pl_dibang_bridge_service import (
  23. _REVOKABLE_INVENTORY_STATUSES,
  24. _subtract_plan_contribution,
  25. )
  26. from app.services.pl_io_order_service import void_io_orders_for_plan
  27. LOCK_WEIGHED = "已过磅"
  28. LOCK_INVENTORY = "地磅出入库已执行"
  29. LOCK_SITE = "现场已回传"
  30. LOCK_MANUAL = "入库或出库实际数量已修改"
  31. def inventory_status_is_executed(status: Optional[str]) -> bool:
  32. text = (status or "").strip()
  33. if not text:
  34. return False
  35. return text not in _REVOKABLE_INVENTORY_STATUSES
  36. def pick_execution_lock_reason(
  37. *,
  38. weighed: bool,
  39. inventory_executed: bool,
  40. site_feedback: bool,
  41. manual_qty: bool,
  42. ) -> Optional[str]:
  43. if weighed:
  44. return LOCK_WEIGHED
  45. if inventory_executed:
  46. return LOCK_INVENTORY
  47. if site_feedback:
  48. return LOCK_SITE
  49. if manual_qty:
  50. return LOCK_MANUAL
  51. return None
  52. def execution_lock_message(reason: str, action: str) -> str:
  53. if action == "arrange":
  54. return f"该计划{reason},不能再调整,请联系管理员处理"
  55. return f"该计划{reason},不能作废,请联系管理员处理"
  56. @dataclass
  57. class _Signals:
  58. weighed: set[str] = field(default_factory=set)
  59. inventory_executed: set[str] = field(default_factory=set)
  60. site_feedback: set[str] = field(default_factory=set)
  61. manual_qty: set[str] = field(default_factory=set)
  62. def load_plan_execution_locks(
  63. db: Session, tenant_no: str, plan_ids: list[str]
  64. ) -> dict[str, Optional[str]]:
  65. ids = [item for item in dict.fromkeys(plan_ids) if item]
  66. result: dict[str, Optional[str]] = {item: None for item in ids}
  67. if not ids:
  68. return result
  69. signals = _collect_signals(db, tenant_no, ids)
  70. for plan_id in ids:
  71. result[plan_id] = pick_execution_lock_reason(
  72. weighed=plan_id in signals.weighed,
  73. inventory_executed=plan_id in signals.inventory_executed,
  74. site_feedback=plan_id in signals.site_feedback,
  75. manual_qty=plan_id in signals.manual_qty,
  76. )
  77. return result
  78. def plan_execution_lock_reason(
  79. db: Session, tenant_no: str, plan_id: str
  80. ) -> Optional[str]:
  81. return load_plan_execution_locks(db, tenant_no, [plan_id]).get(plan_id)
  82. @dataclass
  83. class ForceVoidImpact:
  84. weighing_slips: list[str] = field(default_factory=list)
  85. inventories: list[str] = field(default_factory=list)
  86. io_orders: list[str] = field(default_factory=list)
  87. def notice(self) -> str:
  88. lines = [
  89. "强制作废会把这趟已开始的记录全部冲销:地磅单作废,地磅月计划占用量退回,"
  90. "入库单和出库单作废,合同占用量退回。单据和回传仍保留可查。"
  91. ]
  92. if self.weighing_slips:
  93. lines.append("地磅单:" + "、".join(self.weighing_slips))
  94. if self.inventories:
  95. lines.append("地磅出入库:" + "、".join(self.inventories))
  96. if self.io_orders:
  97. lines.append("入库/出库单:" + "、".join(self.io_orders))
  98. lines.append("请填写作废原因。此操作不能恢复。")
  99. return "\n".join(lines)
  100. def collect_force_void_impact(
  101. db: Session, *, tenant_no: str, plan_id: str
  102. ) -> ForceVoidImpact:
  103. impact = ForceVoidImpact()
  104. inventories = _live_inventories(db, tenant_no, plan_id)
  105. inv_ids = [row.id for row in inventories]
  106. impact.inventories = [_doc_label(row.batch_no, row.id) for row in inventories]
  107. if inv_ids:
  108. slips = (
  109. db.query(DbWeighingSlip)
  110. .filter(
  111. DbWeighingSlip.tenant_no == tenant_no,
  112. DbWeighingSlip.del_flag == 0,
  113. DbWeighingSlip.is_voided == 0,
  114. DbWeighingSlip.inbound_outbound_id.in_(inv_ids),
  115. )
  116. .all()
  117. )
  118. impact.weighing_slips = [_doc_label(row.batch_no, row.id) for row in slips]
  119. for model in (PlInboundOrder, PlOutboundOrder):
  120. rows = (
  121. db.query(model)
  122. .filter(
  123. model.tenant_no == tenant_no,
  124. model.del_flag == 0,
  125. model.plan_id == plan_id,
  126. model.status != "voided",
  127. )
  128. .all()
  129. )
  130. impact.io_orders.extend(_doc_label(row.order_no, row.id) for row in rows)
  131. return impact
  132. def apply_force_void_downstream(
  133. db: Session,
  134. *,
  135. tenant_no: str,
  136. plan_id: str,
  137. transport_id: Optional[str],
  138. user_id: str,
  139. reason: str,
  140. ) -> None:
  141. """冲销已开始的下游:作废地磅单、退回月计划量、作废出入库单。不提交事务。"""
  142. now = datetime.now()
  143. links = _live_links(db, tenant_no, plan_id)
  144. inventories = _live_inventories(db, tenant_no, plan_id)
  145. inv_by_id = {row.id: row for row in inventories}
  146. purchase_plan_ids: set[str] = set()
  147. inv_ids = list(inv_by_id)
  148. slips = []
  149. if inv_ids:
  150. slips = (
  151. db.query(DbWeighingSlip)
  152. .filter(
  153. DbWeighingSlip.tenant_no == tenant_no,
  154. DbWeighingSlip.del_flag == 0,
  155. DbWeighingSlip.is_voided == 0,
  156. DbWeighingSlip.inbound_outbound_id.in_(inv_ids),
  157. )
  158. .all()
  159. )
  160. for slip in slips:
  161. if slip.purchase_plan_id:
  162. purchase_plan_ids.add(slip.purchase_plan_id)
  163. slip.is_voided = 1
  164. slip.void_reason = reason[:500]
  165. slip.void_time = now
  166. slip.void_by = user_id
  167. slip.update_by = user_id
  168. for link in links:
  169. if link.db_purchase_plan_id:
  170. purchase_plan_ids.add(link.db_purchase_plan_id)
  171. inventory = inv_by_id.get(link.db_inventory_id)
  172. _subtract_plan_contribution(
  173. db,
  174. tenant_no=tenant_no,
  175. link=link,
  176. inventory=inventory,
  177. user_id=user_id,
  178. )
  179. if inventory is not None:
  180. inventory.del_flag = 1
  181. inventory.update_by = user_id
  182. link.del_flag = 1
  183. link.update_by = user_id
  184. db.flush()
  185. _refresh_purchase_plan_executed_qty(
  186. db, tenant_no=tenant_no, purchase_plan_ids=purchase_plan_ids, user_id=user_id
  187. )
  188. void_io_orders_for_plan(db, tenant_no=tenant_no, plan_id=plan_id, user_id=user_id)
  189. if transport_id:
  190. transport = (
  191. db.query(PlTransport)
  192. .filter(
  193. PlTransport.id == transport_id,
  194. PlTransport.tenant_no == tenant_no,
  195. PlTransport.del_flag == 0,
  196. )
  197. .first()
  198. )
  199. if transport is not None:
  200. transport.transport_status = "cancelled"
  201. transport.update_by = user_id
  202. def _doc_label(primary: Optional[str], fallback: str) -> str:
  203. text = (primary or "").strip()
  204. return text or fallback
  205. def _live_links(db: Session, tenant_no: str, plan_id: str) -> list[PlTransportDbLink]:
  206. return (
  207. db.query(PlTransportDbLink)
  208. .filter(
  209. PlTransportDbLink.tenant_no == tenant_no,
  210. PlTransportDbLink.del_flag == 0,
  211. PlTransportDbLink.plan_id == plan_id,
  212. )
  213. .all()
  214. )
  215. def _live_inventories(db: Session, tenant_no: str, plan_id: str) -> list[DbInboundOutbound]:
  216. links = _live_links(db, tenant_no, plan_id)
  217. inv_ids = [link.db_inventory_id for link in links if link.db_inventory_id]
  218. if not inv_ids:
  219. return []
  220. return (
  221. db.query(DbInboundOutbound)
  222. .filter(
  223. DbInboundOutbound.tenant_no == tenant_no,
  224. DbInboundOutbound.del_flag == 0,
  225. DbInboundOutbound.id.in_(inv_ids),
  226. )
  227. .all()
  228. )
  229. def _collect_signals(db: Session, tenant_no: str, plan_ids: list[str]) -> _Signals:
  230. signals = _Signals()
  231. links = (
  232. db.query(PlTransportDbLink)
  233. .filter(
  234. PlTransportDbLink.tenant_no == tenant_no,
  235. PlTransportDbLink.del_flag == 0,
  236. PlTransportDbLink.plan_id.in_(plan_ids),
  237. )
  238. .all()
  239. )
  240. plans_by_inv: dict[str, set[str]] = {}
  241. for link in links:
  242. if not link.db_inventory_id:
  243. continue
  244. plans_by_inv.setdefault(link.db_inventory_id, set()).add(link.plan_id)
  245. inv_ids = list(plans_by_inv)
  246. if inv_ids:
  247. inventories = (
  248. db.query(DbInboundOutbound)
  249. .filter(
  250. DbInboundOutbound.tenant_no == tenant_no,
  251. DbInboundOutbound.del_flag == 0,
  252. DbInboundOutbound.id.in_(inv_ids),
  253. )
  254. .all()
  255. )
  256. for inventory in inventories:
  257. owners = plans_by_inv.get(inventory.id, set())
  258. if inventory_status_is_executed(inventory.status):
  259. signals.inventory_executed.update(owners)
  260. if inventory.site_feedback_time is not None:
  261. signals.site_feedback.update(owners)
  262. slip_rows = (
  263. db.query(DbWeighingSlip.inbound_outbound_id)
  264. .filter(
  265. DbWeighingSlip.tenant_no == tenant_no,
  266. DbWeighingSlip.del_flag == 0,
  267. DbWeighingSlip.is_voided == 0,
  268. DbWeighingSlip.inbound_outbound_id.in_(inv_ids),
  269. )
  270. .all()
  271. )
  272. for (inv_id,) in slip_rows:
  273. signals.weighed.update(plans_by_inv.get(inv_id, set()))
  274. _collect_io_signals(db, tenant_no, plan_ids, signals)
  275. site_link_rows = (
  276. db.query(PlTransportSiteLink.plan_id)
  277. .filter(
  278. PlTransportSiteLink.tenant_no == tenant_no,
  279. PlTransportSiteLink.del_flag == 0,
  280. PlTransportSiteLink.plan_id.in_(plan_ids),
  281. PlTransportSiteLink.site_feedback_time.isnot(None),
  282. )
  283. .all()
  284. )
  285. signals.site_feedback.update(row[0] for row in site_link_rows if row[0])
  286. stop_rows = (
  287. db.query(PlTransportSiteStopFeedback.plan_id)
  288. .filter(
  289. PlTransportSiteStopFeedback.tenant_no == tenant_no,
  290. PlTransportSiteStopFeedback.del_flag == 0,
  291. PlTransportSiteStopFeedback.plan_id.in_(plan_ids),
  292. PlTransportSiteStopFeedback.feedback_time.isnot(None),
  293. )
  294. .all()
  295. )
  296. signals.site_feedback.update(row[0] for row in stop_rows if row[0])
  297. return signals
  298. def _collect_io_signals(
  299. db: Session, tenant_no: str, plan_ids: list[str], signals: _Signals
  300. ) -> None:
  301. for header_model, item_model in (
  302. (PlInboundOrder, PlInboundOrderItem),
  303. (PlOutboundOrder, PlOutboundOrderItem),
  304. ):
  305. headers = (
  306. db.query(header_model)
  307. .filter(
  308. header_model.tenant_no == tenant_no,
  309. header_model.del_flag == 0,
  310. header_model.plan_id.in_(plan_ids),
  311. header_model.status != "voided",
  312. )
  313. .all()
  314. )
  315. if not headers:
  316. continue
  317. plan_by_order = {row.id: row.plan_id for row in headers}
  318. for row in headers:
  319. if row.site_feedback_time is not None or row.site_first_submitted_at is not None:
  320. signals.site_feedback.add(row.plan_id)
  321. manual_rows = (
  322. db.query(item_model.order_id)
  323. .filter(
  324. item_model.tenant_no == tenant_no,
  325. item_model.del_flag == 0,
  326. item_model.order_id.in_(list(plan_by_order)),
  327. item_model.qty_source == "manual",
  328. )
  329. .all()
  330. )
  331. for (order_id,) in manual_rows:
  332. plan_id = plan_by_order.get(order_id)
  333. if plan_id:
  334. signals.manual_qty.add(plan_id)
  335. def _refresh_purchase_plan_executed_qty(
  336. db: Session,
  337. *,
  338. tenant_no: str,
  339. purchase_plan_ids: set[str],
  340. user_id: str,
  341. ) -> None:
  342. for plan_id in purchase_plan_ids:
  343. plan = (
  344. db.query(DbPurchasePlan)
  345. .filter(
  346. DbPurchasePlan.id == plan_id,
  347. DbPurchasePlan.tenant_no == tenant_no,
  348. DbPurchasePlan.del_flag == 0,
  349. )
  350. .first()
  351. )
  352. if plan is None:
  353. continue
  354. slips = (
  355. db.query(DbWeighingSlip)
  356. .filter(
  357. DbWeighingSlip.is_voided == 0,
  358. DbWeighingSlip.del_flag == 0,
  359. DbWeighingSlip.tenant_no == tenant_no,
  360. or_(
  361. DbWeighingSlip.plan_no == plan.plan_no,
  362. DbWeighingSlip.purchase_plan_id == plan.id,
  363. ),
  364. )
  365. .all()
  366. )
  367. total_executed = sum(slip.net_weight or 0 for slip in slips)
  368. plan.executed_quantity = total_executed
  369. planned_quantity_kg = (plan.planned_quantity or 0) * 1000
  370. if total_executed >= planned_quantity_kg and planned_quantity_kg > 0:
  371. plan.execution_status = "已入库" if plan.business_type == "入库" else "已出库"
  372. elif total_executed > 0:
  373. plan.execution_status = "入库中" if plan.business_type == "入库" else "出库中"
  374. else:
  375. plan.execution_status = "待入库" if plan.business_type == "入库" else "待出库"
  376. plan.update_by = user_id