feat(pl): post brand-level inventory on plan archive

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-28 09:34:07 +08:00
1 parent 32533948e3
commit cc2edfc61f
5 files changed
+293

No files matched your search

+6
View File
@@ -85,10 +85,12 @@ def set_warning_threshold(
) -> dict:
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
brand_id = payload.brand_id or ""
inventory = (
db.query(PlInventory)
.filter(
PlInventory.product_id == payload.product_id,
PlInventory.brand_id == brand_id,
PlInventory.tenant_no == tenant_no,
PlInventory.del_flag == 0,
)
@@ -99,12 +101,16 @@ def set_warning_threshold(
assert_pl_row_in_scope(db, inventory, PlInventory, user, tenant_no)
inventory.low_stock_warning = payload.low_stock_warning
inventory.high_stock_warning = payload.high_stock_warning
if payload.brand_name is not None:
inventory.brand_name = payload.brand_name
else:
# 创建库存记录
inventory = PlInventory(
id=generate_uuid(),
tenant_no=tenant_no,
product_id=payload.product_id,
brand_id=brand_id,
brand_name=payload.brand_name,
product_no="",
product_name="",
unit="吨",
+8
View File
@@ -58,6 +58,7 @@ from app.services.pl_inventory_link_read import build_transport_inventory_link_r
from app.services.pl_io_order_service import get_active_io_order_for_plan, void_io_orders_for_plan
from app.services.pl_site_link_read import build_transport_site_link_read_from_row
from app.services.pl_transport_site_service import revoke_site_links_for_transport
from app.services.pl_inventory_service import apply_archive_inventory_for_plan
from app.services.pl_plan_service import (
apply_archive_freight,
apply_archive_product_quantities,
@@ -1177,6 +1178,13 @@ def archive_plan(
apply_archive_product_quantities(
db, tenant_no=tenant_no, plan=plan, items=payload.products, user_id=user.id
)
apply_archive_inventory_for_plan(
db,
tenant_no=tenant_no,
plan=plan,
user_id=user.id,
dept_id=user.dept_id or "",
)
if payload.freight is not None:
apply_archive_freight(
db, tenant_no=tenant_no, plan=plan, freight=payload.freight, user_id=user.id
+8
View File
@@ -14,6 +14,8 @@ from pydantic import BaseModel, Field
class InventoryRead(BaseModel):
id: str
product_id: str = Field(alias="productId")
brand_id: str = Field(default="", alias="brandId")
brand_name: Optional[str] = Field(default=None, alias="brandName")
product_no: str = Field(alias="productNo")
product_name: str = Field(alias="productName")
model: Optional[str] = None
@@ -36,6 +38,8 @@ class InventoryRead(BaseModel):
class InventoryTransactionRead(BaseModel):
id: str
product_id: str = Field(alias="productId")
brand_id: str = Field(default="", alias="brandId")
brand_name: Optional[str] = Field(default=None, alias="brandName")
product_name: str = Field(alias="productName")
transaction_type: str = Field(alias="transactionType")
plan_id: Optional[str] = Field(default=None, alias="planId")
@@ -57,6 +61,8 @@ class InventoryTransactionRead(BaseModel):
class InventoryWarningRead(BaseModel):
id: str
product_id: str = Field(alias="productId")
brand_id: str = Field(default="", alias="brandId")
brand_name: Optional[str] = Field(default=None, alias="brandName")
product_name: str = Field(alias="productName")
warning_type: str = Field(alias="warningType")
current_qty: Decimal = Field(alias="currentQty")
@@ -77,6 +83,8 @@ class InventoryWarningUpdate(BaseModel):
class InventoryWarningThreshold(BaseModel):
product_id: str = Field(..., alias="productId", description="产品ID")
brand_id: str = Field(default="", alias="brandId", description="品牌ID,无品牌为空串")
brand_name: Optional[str] = Field(default=None, alias="brandName", description="品牌名称")
low_stock_warning: Optional[Decimal] = Field(default=None, alias="lowStockWarning", description="最低库存预警值")
high_stock_warning: Optional[Decimal] = Field(default=None, alias="highStockWarning", description="最高库存预警值")
model_config = {"populate_by_name": True}
@@ -0,0 +1,256 @@
"""计划归档按产品+品牌写入库存台账与流水。
直销单 (direct) 首期不记账;入库增加、出库扣减;出库不足硬拦截。
调整归档时先按 plan_id 冲销旧流水再按新量重记。
"""
from decimal import Decimal
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.pl_models import (
PlInventory,
PlInventoryTransaction,
PlPlan,
PlPlanProduct,
PlPlanProductBrandQty,
)
EMPTY_BRAND_ID = ""
def qty_delta_for_plan(plan_type: str, qty: Decimal) -> Decimal:
if plan_type == "inbound":
return Decimal(str(qty))
if plan_type == "outbound":
return -Decimal(str(qty))
return Decimal("0") # direct: 首期不记账
def assert_outbound_enough(*, stock_qty: Decimal, outbound_qty: Decimal, brand_label: str) -> None:
if Decimal(str(outbound_qty)) > Decimal(str(stock_qty)):
raise ValueError(f"{brand_label}库存不足")
def get_or_create_inventory(
db: Session,
*,
tenant_no: str,
product_id: str,
product_no: str,
product_name: str,
brand_id: Optional[str],
brand_name: Optional[str],
unit: Optional[str],
model: Optional[str],
user_id: str,
dept_id: str,
) -> PlInventory:
brand_id = brand_id or EMPTY_BRAND_ID
row = (
db.query(PlInventory)
.filter(
PlInventory.tenant_no == tenant_no,
PlInventory.product_id == product_id,
PlInventory.brand_id == brand_id,
PlInventory.del_flag == 0,
)
.first()
)
if row:
return row
row = PlInventory(
id=generate_uuid(),
tenant_no=tenant_no,
product_id=product_id,
brand_id=brand_id,
brand_name=brand_name,
product_no=product_no or "",
product_name=product_name or "",
model=model,
unit=unit or "吨",
stock_qty=Decimal("0"),
stock_amount=Decimal("0"),
create_by=user_id,
create_dept_id=dept_id or "",
update_by=user_id,
)
db.add(row)
db.flush()
return row
def apply_brand_stock_change(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
plan_product: PlPlanProduct,
brand_id: Optional[str],
brand_name: Optional[str],
qty: Decimal,
user_id: str,
dept_id: str,
) -> None:
delta = qty_delta_for_plan(plan.plan_type, Decimal(str(qty)))
if delta == 0:
return
inv = get_or_create_inventory(
db,
tenant_no=tenant_no,
product_id=plan_product.product_id,
product_no=plan_product.product_no,
product_name=plan_product.product_name,
brand_id=brand_id or EMPTY_BRAND_ID,
brand_name=brand_name,
unit=plan_product.unit,
model=plan_product.model,
user_id=user_id,
dept_id=dept_id,
)
if delta < 0:
try:
assert_outbound_enough(
stock_qty=Decimal(str(inv.stock_qty or 0)),
outbound_qty=-delta,
brand_label=brand_name or plan_product.product_name or "",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
unit_price = (
plan_product.inbound_price
or plan_product.outbound_price
or plan_product.tax_price
or Decimal("0")
)
unit_price = Decimal(str(unit_price))
inv.stock_qty = Decimal(str(inv.stock_qty or 0)) + delta
inv.stock_amount = Decimal(str(inv.stock_qty or 0)) * unit_price
inv.update_by = user_id
db.add(
PlInventoryTransaction(
id=generate_uuid(),
tenant_no=tenant_no,
product_id=plan_product.product_id,
brand_id=brand_id or EMPTY_BRAND_ID,
brand_name=brand_name,
product_name=plan_product.product_name or "",
transaction_type="inbound" if delta > 0 else "outbound",
plan_id=plan.id,
plan_no=plan.plan_no,
qty=delta,
unit_price=unit_price,
amount=abs(delta) * unit_price,
stock_after=inv.stock_qty,
create_by=user_id,
create_dept_id=dept_id or "",
update_by=user_id,
)
)
def reverse_plan_inventory_postings(
db: Session,
*,
tenant_no: str,
plan_id: str,
user_id: str,
) -> None:
"""冲销该计划已产生的库存流水(调整归档前调用)。"""
txns = (
db.query(PlInventoryTransaction)
.filter(
PlInventoryTransaction.tenant_no == tenant_no,
PlInventoryTransaction.plan_id == plan_id,
PlInventoryTransaction.del_flag == 0,
)
.order_by(PlInventoryTransaction.create_time.asc())
.all()
)
for txn in txns:
brand_id = txn.brand_id or EMPTY_BRAND_ID
inv = (
db.query(PlInventory)
.filter(
PlInventory.tenant_no == tenant_no,
PlInventory.product_id == txn.product_id,
PlInventory.brand_id == brand_id,
PlInventory.del_flag == 0,
)
.first()
)
if inv:
inv.stock_qty = Decimal(str(inv.stock_qty or 0)) - Decimal(str(txn.qty or 0))
unit_price = Decimal(str(txn.unit_price or 0))
inv.stock_amount = Decimal(str(inv.stock_qty or 0)) * unit_price
inv.update_by = user_id
txn.del_flag = 1
txn.update_by = user_id
def apply_archive_inventory_for_plan(
db: Session,
*,
tenant_no: str,
plan: PlPlan,
user_id: str,
dept_id: str = "",
) -> None:
"""归档后按品牌量(或无品牌 actual_qty)落账;先冲销旧流水再重记。"""
reverse_plan_inventory_postings(
db, tenant_no=tenant_no, plan_id=plan.id, user_id=user_id
)
if qty_delta_for_plan(plan.plan_type, Decimal("1")) == 0:
return
products = (
db.query(PlPlanProduct)
.filter(
PlPlanProduct.plan_id == plan.id,
PlPlanProduct.tenant_no == tenant_no,
PlPlanProduct.del_flag == 0,
)
.order_by(PlPlanProduct.order_num.asc())
.all()
)
for prod in products:
brand_rows = (
db.query(PlPlanProductBrandQty)
.filter(
PlPlanProductBrandQty.plan_product_id == prod.id,
PlPlanProductBrandQty.tenant_no == tenant_no,
PlPlanProductBrandQty.del_flag == 0,
)
.order_by(PlPlanProductBrandQty.order_num.asc())
.all()
)
if prod.brand_mode and brand_rows:
for bq in brand_rows:
apply_brand_stock_change(
db,
tenant_no=tenant_no,
plan=plan,
plan_product=prod,
brand_id=bq.brand_id or EMPTY_BRAND_ID,
brand_name=bq.brand_name,
qty=Decimal(str(bq.qty or 0)),
user_id=user_id,
dept_id=dept_id,
)
continue
actual = Decimal(str(prod.actual_qty or 0))
if actual <= 0:
continue
apply_brand_stock_change(
db,
tenant_no=tenant_no,
plan=plan,
plan_product=prod,
brand_id=EMPTY_BRAND_ID,
brand_name=None,
qty=actual,
user_id=user_id,
dept_id=dept_id,
)
+15
View File
@@ -47,3 +47,18 @@ def test_no_brand_ignores_rows():
actual_qty=Decimal("5"),
rows=[],
)
def test_apply_stock_change_outbound_insufficient():
from app.services.pl_inventory_service import assert_outbound_enough
with pytest.raises(ValueError, match="库存不足"):
assert_outbound_enough(stock_qty=Decimal("1"), outbound_qty=Decimal("2"), brand_label="君正")
def test_delta_for_plan_type():
from app.services.pl_inventory_service import qty_delta_for_plan
assert qty_delta_for_plan("inbound", Decimal("3")) == Decimal("3")
assert qty_delta_for_plan("outbound", Decimal("3")) == Decimal("-3")
assert qty_delta_for_plan("direct", Decimal("3")) == Decimal("0")