pl_inventory_service.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """计划归档按产品+品牌写入库存台账与流水。
  2. 直销单 (direct) 首期不记账;入库增加、出库扣减(允许负库存,不做不足拦截)。
  3. 调整归档时先按 plan_id 冲销旧流水再按新量重记。
  4. """
  5. from decimal import Decimal
  6. from typing import Optional
  7. from sqlalchemy.orm import Session
  8. from app.core.utils import generate_uuid
  9. from app.models.pl_models import (
  10. PlInventory,
  11. PlInventoryTransaction,
  12. PlPlan,
  13. PlPlanProduct,
  14. PlPlanProductBrandQty,
  15. )
  16. EMPTY_BRAND_ID = ""
  17. def qty_delta_for_plan(plan_type: str, qty: Decimal) -> Decimal:
  18. if plan_type == "inbound":
  19. return Decimal(str(qty))
  20. if plan_type == "outbound":
  21. return -Decimal(str(qty))
  22. return Decimal("0") # direct: 首期不记账
  23. def get_or_create_inventory(
  24. db: Session,
  25. *,
  26. tenant_no: str,
  27. product_id: str,
  28. product_no: str,
  29. product_name: str,
  30. brand_id: Optional[str],
  31. brand_name: Optional[str],
  32. unit: Optional[str],
  33. model: Optional[str],
  34. user_id: str,
  35. dept_id: str,
  36. ) -> PlInventory:
  37. brand_id = brand_id or EMPTY_BRAND_ID
  38. row = (
  39. db.query(PlInventory)
  40. .filter(
  41. PlInventory.tenant_no == tenant_no,
  42. PlInventory.product_id == product_id,
  43. PlInventory.brand_id == brand_id,
  44. PlInventory.del_flag == 0,
  45. )
  46. .first()
  47. )
  48. if row:
  49. return row
  50. row = PlInventory(
  51. id=generate_uuid(),
  52. tenant_no=tenant_no,
  53. product_id=product_id,
  54. brand_id=brand_id,
  55. brand_name=brand_name,
  56. product_no=product_no or "",
  57. product_name=product_name or "",
  58. model=model,
  59. unit=unit or "吨",
  60. stock_qty=Decimal("0"),
  61. stock_amount=Decimal("0"),
  62. create_by=user_id,
  63. create_dept_id=dept_id or "",
  64. update_by=user_id,
  65. )
  66. db.add(row)
  67. db.flush()
  68. return row
  69. def apply_brand_stock_change(
  70. db: Session,
  71. *,
  72. tenant_no: str,
  73. plan: PlPlan,
  74. plan_product: PlPlanProduct,
  75. brand_id: Optional[str],
  76. brand_name: Optional[str],
  77. qty: Decimal,
  78. user_id: str,
  79. dept_id: str,
  80. qty_delta_override: Optional[Decimal] = None,
  81. unit_price_override: Optional[Decimal] = None,
  82. ) -> None:
  83. delta = (
  84. qty_delta_override
  85. if qty_delta_override is not None
  86. else qty_delta_for_plan(plan.plan_type, Decimal(str(qty)))
  87. )
  88. if delta == 0:
  89. return
  90. inv = get_or_create_inventory(
  91. db,
  92. tenant_no=tenant_no,
  93. product_id=plan_product.product_id,
  94. product_no=plan_product.product_no,
  95. product_name=plan_product.product_name,
  96. brand_id=brand_id or EMPTY_BRAND_ID,
  97. brand_name=brand_name,
  98. unit=plan_product.unit,
  99. model=plan_product.model,
  100. user_id=user_id,
  101. dept_id=dept_id,
  102. )
  103. unit_price = unit_price_override
  104. if unit_price is None:
  105. unit_price = (
  106. plan_product.inbound_price
  107. or plan_product.outbound_price
  108. or plan_product.tax_price
  109. or Decimal("0")
  110. )
  111. unit_price = Decimal(str(unit_price))
  112. inv.stock_qty = Decimal(str(inv.stock_qty or 0)) + delta
  113. inv.stock_amount = Decimal(str(inv.stock_qty or 0)) * unit_price
  114. inv.update_by = user_id
  115. db.add(
  116. PlInventoryTransaction(
  117. id=generate_uuid(),
  118. tenant_no=tenant_no,
  119. product_id=plan_product.product_id,
  120. brand_id=brand_id or EMPTY_BRAND_ID,
  121. brand_name=brand_name,
  122. product_name=plan_product.product_name or "",
  123. transaction_type="inbound" if delta > 0 else "outbound",
  124. plan_id=plan.id,
  125. plan_no=plan.plan_no,
  126. qty=delta,
  127. unit_price=unit_price,
  128. amount=abs(delta) * unit_price,
  129. stock_after=inv.stock_qty,
  130. create_by=user_id,
  131. create_dept_id=dept_id or "",
  132. update_by=user_id,
  133. )
  134. )
  135. def reverse_plan_inventory_postings(
  136. db: Session,
  137. *,
  138. tenant_no: str,
  139. plan_id: str,
  140. user_id: str,
  141. ) -> None:
  142. """冲销该计划已产生的库存流水(调整归档前调用)。"""
  143. txns = (
  144. db.query(PlInventoryTransaction)
  145. .filter(
  146. PlInventoryTransaction.tenant_no == tenant_no,
  147. PlInventoryTransaction.plan_id == plan_id,
  148. PlInventoryTransaction.del_flag == 0,
  149. )
  150. .order_by(PlInventoryTransaction.create_time.asc())
  151. .all()
  152. )
  153. for txn in txns:
  154. brand_id = txn.brand_id or EMPTY_BRAND_ID
  155. inv = (
  156. db.query(PlInventory)
  157. .filter(
  158. PlInventory.tenant_no == tenant_no,
  159. PlInventory.product_id == txn.product_id,
  160. PlInventory.brand_id == brand_id,
  161. PlInventory.del_flag == 0,
  162. )
  163. .first()
  164. )
  165. if inv:
  166. inv.stock_qty = Decimal(str(inv.stock_qty or 0)) - Decimal(str(txn.qty or 0))
  167. unit_price = Decimal(str(txn.unit_price or 0))
  168. inv.stock_amount = Decimal(str(inv.stock_qty or 0)) * unit_price
  169. inv.update_by = user_id
  170. txn.del_flag = 1
  171. txn.update_by = user_id
  172. def apply_archive_inventory_for_plan(
  173. db: Session,
  174. *,
  175. tenant_no: str,
  176. plan: PlPlan,
  177. user_id: str,
  178. dept_id: str = "",
  179. ) -> None:
  180. """归档后按品牌量(或无品牌 actual_qty)落账;先冲销旧流水再重记。"""
  181. reverse_plan_inventory_postings(
  182. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user_id
  183. )
  184. if qty_delta_for_plan(plan.plan_type, Decimal("1")) == 0:
  185. return
  186. products = (
  187. db.query(PlPlanProduct)
  188. .filter(
  189. PlPlanProduct.plan_id == plan.id,
  190. PlPlanProduct.tenant_no == tenant_no,
  191. PlPlanProduct.del_flag == 0,
  192. )
  193. .order_by(PlPlanProduct.order_num.asc())
  194. .all()
  195. )
  196. for prod in products:
  197. brand_rows = (
  198. db.query(PlPlanProductBrandQty)
  199. .filter(
  200. PlPlanProductBrandQty.plan_product_id == prod.id,
  201. PlPlanProductBrandQty.tenant_no == tenant_no,
  202. PlPlanProductBrandQty.del_flag == 0,
  203. )
  204. .order_by(PlPlanProductBrandQty.order_num.asc())
  205. .all()
  206. )
  207. if prod.brand_mode and brand_rows:
  208. for bq in brand_rows:
  209. apply_brand_stock_change(
  210. db,
  211. tenant_no=tenant_no,
  212. plan=plan,
  213. plan_product=prod,
  214. brand_id=bq.brand_id or EMPTY_BRAND_ID,
  215. brand_name=bq.brand_name,
  216. qty=Decimal(str(bq.qty or 0)),
  217. user_id=user_id,
  218. dept_id=dept_id,
  219. )
  220. continue
  221. actual = Decimal(str(prod.actual_qty or 0))
  222. if actual <= 0:
  223. continue
  224. apply_brand_stock_change(
  225. db,
  226. tenant_no=tenant_no,
  227. plan=plan,
  228. plan_product=prod,
  229. brand_id=EMPTY_BRAND_ID,
  230. brand_name=None,
  231. qty=actual,
  232. user_id=user_id,
  233. dept_id=dept_id,
  234. )
  235. def apply_archive_inventory_for_plan_stops(
  236. db: Session,
  237. *,
  238. tenant_no: str,
  239. plan: PlPlan,
  240. user_id: str,
  241. dept_id: str = "",
  242. ) -> None:
  243. """一车多卸:内部装货出库、内部送货入库。"""
  244. from app.services.pl_plan_archive_stop_service import iter_stop_archive_inventory_lines
  245. reverse_plan_inventory_postings(
  246. db, tenant_no=tenant_no, plan_id=plan.id, user_id=user_id
  247. )
  248. product_map = {
  249. row.id: row
  250. for row in db.query(PlPlanProduct).filter(
  251. PlPlanProduct.plan_id == plan.id,
  252. PlPlanProduct.tenant_no == tenant_no,
  253. PlPlanProduct.del_flag == 0,
  254. )
  255. }
  256. for plan_product_id, qty, direction, price in iter_stop_archive_inventory_lines(
  257. db, tenant_no=tenant_no, plan_id=plan.id
  258. ):
  259. prod = product_map.get(plan_product_id)
  260. if not prod:
  261. continue
  262. delta = -qty if direction == "outbound" else qty
  263. apply_brand_stock_change(
  264. db,
  265. tenant_no=tenant_no,
  266. plan=plan,
  267. plan_product=prod,
  268. brand_id=EMPTY_BRAND_ID,
  269. brand_name=None,
  270. qty=qty,
  271. user_id=user_id,
  272. dept_id=dept_id,
  273. qty_delta_override=delta,
  274. unit_price_override=Decimal(str(price)) if price is not None else None,
  275. )