184 lines
7.0 KiB
Python
184 lines
7.0 KiB
Python
|
|
"""采购需求自动推导服务
|
|||
|
|
|
|||
|
|
销售订单确认 → 按 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 (
|
|||
|
|
Product,
|
|||
|
|
ProductMaterial,
|
|||
|
|
SalesOrder,
|
|||
|
|
SalesOrderItem,
|
|||
|
|
Inventory,
|
|||
|
|
MaterialSupplier,
|
|||
|
|
Supplier,
|
|||
|
|
)
|
|||
|
|
from ..schemas.purchase_demand_schemas import (
|
|||
|
|
PurchaseDemandItemResponse,
|
|||
|
|
PurchaseDemandResponse,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
purchase_demand_service = PurchaseDemandService()
|