pl_inventory_service.py 7.5 KB

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