Files
geMoldInsight/src/inventory/services/purchase_demand_service.py
T

276 lines
11 KiB
Python
Raw Normal View History

2026-07-30 10:30:50 +08:00
"""采购需求自动推导服务
销售订单确认 → 按 BOM 展开物料需求 → 对比当前库存 → 自动生成采购建议(缺多少、建议供应商、预计金额)
"""
from math import ceil
from decimal import Decimal
from typing import List
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.models.database import (
2026-07-30 11:01:00 +08:00
User,
2026-07-30 10:30:50 +08:00
Product,
ProductMaterial,
SalesOrder,
SalesOrderItem,
Inventory,
MaterialSupplier,
Supplier,
2026-07-30 11:01:00 +08:00
PurchaseOrder,
PurchaseOrderItem,
2026-07-30 10:30:50 +08:00
)
2026-07-30 11:01:00 +08:00
from ..utils import generate_order_no
2026-07-30 10:30:50 +08:00
from ..schemas.purchase_demand_schemas import (
PurchaseDemandItemResponse,
PurchaseDemandResponse,
2026-07-30 11:01:00 +08:00
PurchaseDemandConvertRequest,
PurchaseDemandConvertResponse,
PurchaseOrderCreatedResponse,
2026-07-30 10:30:50 +08:00
)
class PurchaseDemandService:
"""采购需求推导服务"""
@staticmethod
async def calculate_demands(
db_session: AsyncSession,
sales_order_ids: List[int],
) -> PurchaseDemandResponse:
"""
核心算法:
1. 批量查询销售订单 + 明细项
2. 按 BOM 展开所有成品所需的物料(含损耗率)
3. 聚合跨订单的同一物料需求量
4. 对比当前库存,计算缺口
5. 查询 MaterialSupplier 推荐主供应商
"""
# ── 1. 查询销售订单 ──
order_result = await db_session.execute(
select(SalesOrder).where(SalesOrder.id.in_(sales_order_ids))
)
orders = order_result.scalars().all()
if not orders:
raise HTTPException(status_code=404, detail="未找到有效的销售订单")
order_ids_found = [o.id for o in orders]
order_nos = [o.order_no for o in orders]
# ── 2. 查询订单明细(成品列表) ──
item_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id.in_(order_ids_found))
)
order_items = item_result.scalars().all()
if not order_items:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# ── 3. 按 BOM 展开物料需求 ──
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# 按 finished_product_id 分组 BOM
bom_by_finished: dict = {}
for bom, material in bom_rows:
bom_by_finished.setdefault(int(bom.finished_product_id), []).append((bom, material))
# 聚合需求量:material_id → { material, required_qty }
required_qty_map: dict = {}
for order_item in order_items:
bom_items = bom_by_finished.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = (
Decimal(str(order_item.quantity))
* Decimal(str(bom.quantity or 0))
* (1 + Decimal(str(bom.loss_rate or 0)))
)
entry = required_qty_map.setdefault(
material.id,
{"material": material, "required_qty": Decimal("0")},
)
entry["required_qty"] += qty
if not required_qty_map:
return PurchaseDemandResponse(
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
# ── 4. 对比当前库存 ──
material_ids = list(required_qty_map.keys())
stock_result = await db_session.execute(
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
.where(Inventory.product_id.in_(material_ids))
.group_by(Inventory.product_id)
)
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
# ── 5. 查询物料-供应商关联(推荐主供应商) ──
ms_result = await db_session.execute(
select(MaterialSupplier, Supplier)
.join(Supplier, MaterialSupplier.supplier_id == Supplier.id)
.where(MaterialSupplier.product_id.in_(material_ids))
.where(Supplier.is_active == True)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
ms_rows = ms_result.all()
# 每个物料取第一个(优先 is_primary=True)
supplier_map: dict = {}
for ms, supplier in ms_rows:
if ms.product_id not in supplier_map:
supplier_map[ms.product_id] = {
"supplier_id": supplier.id,
"supplier_name": supplier.name,
"lead_time": ms.lead_time,
}
# ── 6. 组装响应 ──
items: List[PurchaseDemandItemResponse] = []
total_estimated_cost = Decimal("0")
shortage_count = 0
for material_id, entry in required_qty_map.items():
material = entry["material"]
required_qty = int(ceil(entry["required_qty"]))
available_qty = stock_map.get(material_id, Decimal("0"))
shortage_qty = max(required_qty - int(available_qty), 0)
unit_cost = Decimal(str(material.cost_price or 0))
estimated_cost = Decimal(str(shortage_qty)) * unit_cost
total_estimated_cost += estimated_cost
if shortage_qty > 0:
shortage_count += 1
suggested = supplier_map.get(material_id)
items.append(
PurchaseDemandItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
required_quantity=Decimal(str(required_qty)),
available_quantity=available_qty,
shortage_quantity=Decimal(str(shortage_qty)),
unit_cost=unit_cost,
estimated_cost=estimated_cost,
suggested_supplier_id=suggested["supplier_id"] if suggested else None,
suggested_supplier_name=suggested["supplier_name"] if suggested else None,
supplier_lead_time=suggested["lead_time"] if suggested else None,
)
)
# 按缺口数量降序排列(最缺的排最前)
items.sort(key=lambda x: (x.shortage_quantity, x.estimated_cost), reverse=True)
return PurchaseDemandResponse(
items=items,
total_estimated_cost=total_estimated_cost,
shortage_count=shortage_count,
source_order_ids=order_ids_found,
source_order_nos=order_nos,
)
2026-07-30 11:01:00 +08:00
@staticmethod
async def convert_to_purchase_orders(
db_session: AsyncSession,
payload: PurchaseDemandConvertRequest,
current_user: User,
) -> PurchaseDemandConvertResponse:
"""将采购需求按供应商分组,每组生成一张草稿采购单(status=pending)。
只转换 quantity>0 的条目;物料/供应商需存在且启用。
事务由 get_db_session 统一提交(路由返回成功即 commit,异常即 rollback)。
"""
valid_items = [it for it in payload.items if it.quantity > 0]
skipped = len(payload.items) - len(valid_items)
if not valid_items:
raise HTTPException(status_code=400, detail="没有可转换的有效需求(数量需 > 0)")
material_ids = {it.material_id for it in valid_items}
supplier_ids = {it.supplier_id for it in valid_items}
# 校验物料
mat_result = await db_session.execute(
select(Product)
.where(Product.id.in_(material_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
materials = {m.id: m for m in mat_result.scalars().all()}
if len(materials) != len(material_ids):
raise HTTPException(status_code=400, detail="部分物料不存在或非物料类型")
# 校验供应商
sup_result = await db_session.execute(
select(Supplier)
.where(Supplier.id.in_(supplier_ids))
.where(Supplier.is_active == True)
)
suppliers = {s.id: s for s in sup_result.scalars().all()}
if len(suppliers) != len(supplier_ids):
raise HTTPException(status_code=400, detail="部分供应商不存在或已停用")
# 按供应商分组生成采购单
groups: dict = {}
for it in valid_items:
groups.setdefault(it.supplier_id, []).append(it)
created: List[PurchaseOrderCreatedResponse] = []
for supplier_id, items in groups.items():
po = PurchaseOrder(
order_no=generate_order_no("PO"),
supplier_id=supplier_id,
expected_date=payload.expected_date,
remark=payload.remark or "由采购需求一键生成",
operator_id=current_user.id,
status="pending",
)
db_session.add(po)
await db_session.flush()
total = Decimal("0")
for it in items:
amount = Decimal(str(it.quantity)) * Decimal(str(it.unit_cost))
total += amount
db_session.add(
PurchaseOrderItem(
order_id=po.id,
product_id=it.material_id,
quantity=int(it.quantity),
unit_price=Decimal(str(it.unit_cost)),
amount=amount,
)
)
po.total_amount = total
created.append(
PurchaseOrderCreatedResponse(
purchase_order_id=po.id,
order_no=po.order_no,
supplier_id=supplier_id,
supplier_name=suppliers[supplier_id].name,
item_count=len(items),
total_amount=total,
)
)
await db_session.flush()
return PurchaseDemandConvertResponse(created_orders=created, skipped=skipped)
2026-07-30 10:30:50 +08:00
purchase_demand_service = PurchaseDemandService()