pl_plan_archive_stop_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. """运输计划按段归档(装货/送货段)"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from decimal import Decimal
  5. from typing import Optional
  6. from fastapi import HTTPException
  7. from sqlalchemy.orm import Session
  8. from app.models.pl_models import (
  9. PlPlan,
  10. PlPlanDelivery,
  11. PlPlanDeliveryProduct,
  12. PlPlanLoad,
  13. PlPlanLoadProduct,
  14. PlPlanProduct,
  15. )
  16. from app.schemas.pl.plan import (
  17. PlanArchiveDeliveryBlock,
  18. PlanArchiveLoadBlock,
  19. PlanArchiveStopProductItem,
  20. )
  21. from app.services.pl_brand_archive import persist_stop_product_brand_qtys
  22. from app.services.pl_internal_customer import is_internal_customer_name
  23. def _require_qty(value: Optional[Decimal], label: str) -> Decimal:
  24. if value is None:
  25. raise HTTPException(status_code=400, detail=f"{label}不能为空")
  26. qty = Decimal(str(value))
  27. if qty < 0:
  28. raise HTTPException(status_code=400, detail=f"{label}不能为负数")
  29. return qty
  30. def _apply_stop_product_archive(
  31. row: PlPlanLoadProduct | PlPlanDeliveryProduct,
  32. item: PlanArchiveStopProductItem,
  33. *,
  34. mode: str,
  35. label: str,
  36. user_id: str,
  37. strict: bool = True,
  38. ) -> None:
  39. if mode == "inbound":
  40. if item.inbound_qty is None:
  41. if strict:
  42. raise HTTPException(status_code=400, detail=f"{label}入库数量不能为空")
  43. row.inbound_qty = None
  44. row.inbound_amount = None
  45. if item.inbound_price is not None:
  46. row.inbound_price = Decimal(str(item.inbound_price))
  47. else:
  48. qty = _require_qty(item.inbound_qty, f"{label}入库数量")
  49. row.inbound_qty = qty
  50. if item.inbound_price is not None:
  51. row.inbound_price = Decimal(str(item.inbound_price))
  52. price = row.inbound_price
  53. row.inbound_amount = (qty * Decimal(str(price))) if price is not None else None
  54. else:
  55. if item.outbound_qty is None:
  56. if strict:
  57. raise HTTPException(status_code=400, detail=f"{label}出库数量不能为空")
  58. row.outbound_qty = None
  59. row.outbound_amount = None
  60. if item.outbound_price is not None:
  61. row.outbound_price = Decimal(str(item.outbound_price))
  62. else:
  63. qty = _require_qty(item.outbound_qty, f"{label}出库数量")
  64. row.outbound_qty = qty
  65. if item.outbound_price is not None:
  66. row.outbound_price = Decimal(str(item.outbound_price))
  67. price = row.outbound_price
  68. row.outbound_amount = (qty * Decimal(str(price))) if price is not None else None
  69. row.update_by = user_id
  70. def _brand_rows_from_item(item: PlanArchiveStopProductItem) -> list[dict]:
  71. if not item.brand_qtys:
  72. return []
  73. rows: list[dict] = []
  74. for bq in item.brand_qtys:
  75. row: dict = {
  76. "brandId": bq.brand_id or "",
  77. "brandName": bq.brand_name,
  78. "qty": bq.qty,
  79. }
  80. if bq.unit_price is not None:
  81. row["unitPrice"] = bq.unit_price
  82. rows.append(row)
  83. return rows
  84. def _persist_stop_brand_qtys(
  85. db: Session,
  86. *,
  87. tenant_no: str,
  88. plan: PlPlan,
  89. stop_type: str,
  90. stop_row: PlPlanLoadProduct | PlPlanDeliveryProduct,
  91. item: PlanArchiveStopProductItem,
  92. user_id: str,
  93. ) -> None:
  94. plan_product = (
  95. db.query(PlPlanProduct)
  96. .filter(
  97. PlPlanProduct.id == stop_row.plan_product_id,
  98. PlPlanProduct.tenant_no == tenant_no,
  99. PlPlanProduct.del_flag == 0,
  100. )
  101. .first()
  102. )
  103. brand_mode = plan_product.brand_mode if plan_product else None
  104. persist_stop_product_brand_qtys(
  105. db,
  106. tenant_no=tenant_no,
  107. plan_id=plan.id,
  108. plan_product_id=stop_row.plan_product_id,
  109. stop_type=stop_type,
  110. stop_product_id=stop_row.id,
  111. brand_mode=brand_mode,
  112. rows=_brand_rows_from_item(item),
  113. user_id=user_id,
  114. dept_id=getattr(stop_row, "create_dept_id", "") or "",
  115. )
  116. def apply_archive_stop_quantities(
  117. db: Session,
  118. *,
  119. tenant_no: str,
  120. plan: PlPlan,
  121. load_block: PlanArchiveLoadBlock,
  122. delivery_blocks: list[PlanArchiveDeliveryBlock],
  123. user_id: str,
  124. strict: bool = True,
  125. ) -> None:
  126. load = (
  127. db.query(PlPlanLoad)
  128. .filter(
  129. PlPlanLoad.id == load_block.stop_id,
  130. PlPlanLoad.plan_id == plan.id,
  131. PlPlanLoad.tenant_no == tenant_no,
  132. PlPlanLoad.del_flag == 0,
  133. )
  134. .first()
  135. )
  136. if not load:
  137. raise HTTPException(status_code=400, detail="装货段不存在")
  138. load_products = {
  139. row.id: row
  140. for row in db.query(PlPlanLoadProduct).filter(
  141. PlPlanLoadProduct.load_id == load.id,
  142. PlPlanLoadProduct.plan_id == plan.id,
  143. PlPlanLoadProduct.tenant_no == tenant_no,
  144. PlPlanLoadProduct.del_flag == 0,
  145. )
  146. }
  147. if not load_products:
  148. raise HTTPException(status_code=400, detail="装货段无产品明细")
  149. if strict and len(load_block.products) != len(load_products):
  150. raise HTTPException(status_code=400, detail="装货段归档产品行不完整")
  151. for item in load_block.products:
  152. row = load_products.get(item.id)
  153. if not row:
  154. raise HTTPException(status_code=400, detail=f"装货段产品不存在: {item.id}")
  155. _apply_stop_product_archive(
  156. row, item, mode="inbound", label="装货段", user_id=user_id, strict=strict
  157. )
  158. if item.brand_qtys is not None or strict:
  159. _persist_stop_brand_qtys(
  160. db,
  161. tenant_no=tenant_no,
  162. plan=plan,
  163. stop_type="load",
  164. stop_row=row,
  165. item=item,
  166. user_id=user_id,
  167. )
  168. deliveries = (
  169. db.query(PlPlanDelivery)
  170. .filter(
  171. PlPlanDelivery.plan_id == plan.id,
  172. PlPlanDelivery.tenant_no == tenant_no,
  173. PlPlanDelivery.del_flag == 0,
  174. )
  175. .order_by(PlPlanDelivery.seq.asc(), PlPlanDelivery.create_time.asc())
  176. .all()
  177. )
  178. delivery_map = {row.id: row for row in deliveries}
  179. if strict and len(delivery_blocks) != len(deliveries):
  180. raise HTTPException(status_code=400, detail="请填写全部送货段归档")
  181. for block in delivery_blocks:
  182. delivery = delivery_map.get(block.stop_id)
  183. if not delivery:
  184. raise HTTPException(status_code=400, detail="送货段不存在")
  185. delivery_products = {
  186. row.id: row
  187. for row in db.query(PlPlanDeliveryProduct).filter(
  188. PlPlanDeliveryProduct.delivery_id == delivery.id,
  189. PlPlanDeliveryProduct.plan_id == plan.id,
  190. PlPlanDeliveryProduct.tenant_no == tenant_no,
  191. PlPlanDeliveryProduct.del_flag == 0,
  192. )
  193. }
  194. if not delivery_products:
  195. raise HTTPException(status_code=400, detail=f"送货段「{delivery.customer_name or ''}」无产品明细")
  196. if strict and len(block.products) != len(delivery_products):
  197. raise HTTPException(status_code=400, detail=f"送货段「{delivery.customer_name or ''}」归档产品行不完整")
  198. label = f"送货段「{delivery.customer_name or delivery.seq}」"
  199. for item in block.products:
  200. row = delivery_products.get(item.id)
  201. if not row:
  202. raise HTTPException(status_code=400, detail=f"{label}产品不存在: {item.id}")
  203. _apply_stop_product_archive(
  204. row, item, mode="outbound", label=label, user_id=user_id, strict=strict
  205. )
  206. if item.brand_qtys is not None or strict:
  207. _persist_stop_brand_qtys(
  208. db,
  209. tenant_no=tenant_no,
  210. plan=plan,
  211. stop_type="delivery",
  212. stop_row=row,
  213. item=item,
  214. user_id=user_id,
  215. )
  216. def sync_plan_products_from_stop_archive(
  217. db: Session,
  218. *,
  219. tenant_no: str,
  220. plan_id: str,
  221. user_id: str,
  222. ) -> None:
  223. """将段级归档数量汇总回写 pl_plan_product(_row_to_dict 权威来源)。"""
  224. plan_products = {
  225. row.id: row
  226. for row in db.query(PlPlanProduct).filter(
  227. PlPlanProduct.plan_id == plan_id,
  228. PlPlanProduct.tenant_no == tenant_no,
  229. PlPlanProduct.del_flag == 0,
  230. )
  231. }
  232. inbound_by_plan_product: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
  233. outbound_by_plan_product: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
  234. inbound_amount_by_plan_product: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
  235. outbound_amount_by_plan_product: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
  236. load = (
  237. db.query(PlPlanLoad)
  238. .filter(
  239. PlPlanLoad.plan_id == plan_id,
  240. PlPlanLoad.tenant_no == tenant_no,
  241. PlPlanLoad.del_flag == 0,
  242. )
  243. .first()
  244. )
  245. if load:
  246. load_rows = (
  247. db.query(PlPlanLoadProduct)
  248. .filter(
  249. PlPlanLoadProduct.load_id == load.id,
  250. PlPlanLoadProduct.plan_id == plan_id,
  251. PlPlanLoadProduct.tenant_no == tenant_no,
  252. PlPlanLoadProduct.del_flag == 0,
  253. )
  254. .all()
  255. )
  256. for row in load_rows:
  257. if row.plan_product_id not in plan_products:
  258. continue
  259. qty = Decimal(str(row.inbound_qty or 0))
  260. inbound_by_plan_product[row.plan_product_id] += qty
  261. inbound_amount_by_plan_product[row.plan_product_id] += Decimal(str(row.inbound_amount or 0))
  262. delivery_rows = (
  263. db.query(PlPlanDeliveryProduct)
  264. .filter(
  265. PlPlanDeliveryProduct.plan_id == plan_id,
  266. PlPlanDeliveryProduct.tenant_no == tenant_no,
  267. PlPlanDeliveryProduct.del_flag == 0,
  268. )
  269. .all()
  270. )
  271. for row in delivery_rows:
  272. if row.plan_product_id not in plan_products:
  273. continue
  274. qty = Decimal(str(row.outbound_qty or 0))
  275. outbound_by_plan_product[row.plan_product_id] += qty
  276. outbound_amount_by_plan_product[row.plan_product_id] += Decimal(str(row.outbound_amount or 0))
  277. for plan_product_id, prod in plan_products.items():
  278. if plan_product_id in inbound_by_plan_product:
  279. prod.inbound_qty = inbound_by_plan_product[plan_product_id]
  280. if inbound_amount_by_plan_product[plan_product_id] > 0:
  281. prod.inbound_amount = inbound_amount_by_plan_product[plan_product_id]
  282. if plan_product_id in outbound_by_plan_product:
  283. prod.outbound_qty = outbound_by_plan_product[plan_product_id]
  284. if outbound_amount_by_plan_product[plan_product_id] > 0:
  285. prod.outbound_amount = outbound_amount_by_plan_product[plan_product_id]
  286. outbound_qty = outbound_by_plan_product.get(plan_product_id, Decimal("0"))
  287. if outbound_qty > 0:
  288. prod.actual_qty = outbound_qty
  289. prod.update_by = user_id
  290. def sum_delivery_outbound_qty(db: Session, *, tenant_no: str, plan_id: str) -> Decimal:
  291. total = Decimal("0")
  292. rows = (
  293. db.query(PlPlanDeliveryProduct)
  294. .filter(
  295. PlPlanDeliveryProduct.plan_id == plan_id,
  296. PlPlanDeliveryProduct.tenant_no == tenant_no,
  297. PlPlanDeliveryProduct.del_flag == 0,
  298. )
  299. .all()
  300. )
  301. for row in rows:
  302. total += Decimal(str(row.outbound_qty or 0))
  303. return total
  304. def iter_stop_archive_inventory_lines(
  305. db: Session,
  306. *,
  307. tenant_no: str,
  308. plan_id: str,
  309. ):
  310. """产出内部段库存变动:装货出库、送货入库。"""
  311. load = (
  312. db.query(PlPlanLoad)
  313. .filter(
  314. PlPlanLoad.plan_id == plan_id,
  315. PlPlanLoad.tenant_no == tenant_no,
  316. PlPlanLoad.del_flag == 0,
  317. )
  318. .first()
  319. )
  320. if load and is_internal_customer_name(load.customer_name):
  321. rows = (
  322. db.query(PlPlanLoadProduct)
  323. .filter(
  324. PlPlanLoadProduct.load_id == load.id,
  325. PlPlanLoadProduct.plan_id == plan_id,
  326. PlPlanLoadProduct.tenant_no == tenant_no,
  327. PlPlanLoadProduct.del_flag == 0,
  328. )
  329. .all()
  330. )
  331. for row in rows:
  332. qty = Decimal(str(row.inbound_qty or 0))
  333. if qty > 0:
  334. yield row.plan_product_id, qty, "outbound", row.inbound_price
  335. deliveries = (
  336. db.query(PlPlanDelivery)
  337. .filter(
  338. PlPlanDelivery.plan_id == plan_id,
  339. PlPlanDelivery.tenant_no == tenant_no,
  340. PlPlanDelivery.del_flag == 0,
  341. )
  342. .all()
  343. )
  344. delivery_ids = [row.id for row in deliveries]
  345. internal_delivery_ids = {
  346. row.id for row in deliveries if is_internal_customer_name(row.customer_name)
  347. }
  348. if not internal_delivery_ids:
  349. return
  350. rows = (
  351. db.query(PlPlanDeliveryProduct)
  352. .filter(
  353. PlPlanDeliveryProduct.delivery_id.in_(list(internal_delivery_ids)),
  354. PlPlanDeliveryProduct.plan_id == plan_id,
  355. PlPlanDeliveryProduct.tenant_no == tenant_no,
  356. PlPlanDeliveryProduct.del_flag == 0,
  357. )
  358. .all()
  359. )
  360. for row in rows:
  361. qty = Decimal(str(row.outbound_qty or 0))
  362. if qty > 0:
  363. yield row.plan_product_id, qty, "inbound", row.outbound_price