xxx
This commit is contained in:
@@ -26,6 +26,7 @@ from .sales_order_routes import router as sales_order_router
|
||||
from .dashboard_routes import router as dashboard_router
|
||||
from .finance_routes import router as finance_router
|
||||
from .material_routes import router as material_router
|
||||
from .purchase_demand_routes import router as purchase_demand_router
|
||||
|
||||
inventory_router = APIRouter(prefix="/api", tags=["进销存"])
|
||||
|
||||
@@ -38,6 +39,7 @@ inventory_router.include_router(stock_movement_router)
|
||||
inventory_router.include_router(purchase_order_router)
|
||||
inventory_router.include_router(sales_order_router)
|
||||
inventory_router.include_router(material_router)
|
||||
inventory_router.include_router(purchase_demand_router)
|
||||
inventory_router.include_router(dashboard_router)
|
||||
inventory_router.include_router(finance_router)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def create_customer(
|
||||
|
||||
customer = Customer(**data)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
|
||||
@@ -71,7 +71,7 @@ async def update_customer(
|
||||
for key, value in customer_data.dict().items():
|
||||
setattr(customer, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(customer)
|
||||
return CustomerResponse.from_orm(customer)
|
||||
|
||||
@@ -88,5 +88,5 @@ async def delete_customer(
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
customer.is_active = False
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
return {"message": "客户已删除"}
|
||||
|
||||
@@ -63,12 +63,12 @@ async def add_material_price_history(
|
||||
remark=price_data.remark
|
||||
)
|
||||
db_session.add(price_history)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(price_history)
|
||||
|
||||
# 更新产品的成本价格为最新价格
|
||||
product.cost_price = price_data.price
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
|
||||
return MaterialPriceHistoryResponse(
|
||||
id=price_history.id,
|
||||
@@ -212,7 +212,7 @@ async def add_material_supplier(
|
||||
min_order_quantity=supplier_data.min_order_quantity
|
||||
)
|
||||
db_session.add(material_supplier)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(material_supplier)
|
||||
|
||||
return MaterialSupplierResponse(
|
||||
@@ -281,7 +281,7 @@ async def remove_material_supplier(
|
||||
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
|
||||
|
||||
await db_session.delete(material_supplier)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
|
||||
return {"message": "物料供应商关联已删除"}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ async def create_product(
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@@ -178,7 +178,7 @@ async def create_product_from_task(
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
stp_file.product_id = product.id
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
@@ -205,7 +205,7 @@ async def update_product(
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
@@ -223,7 +223,7 @@ async def delete_product(
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
product.is_active = False
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
return {"message": "产品已删除"}
|
||||
|
||||
|
||||
@@ -321,5 +321,5 @@ async def replace_product_bom(
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
return await get_product_bom(product_id, db_session, current_user)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""采购需求推导路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.purchase_demand_service,路由只做参数校验与响应组装。
|
||||
路由前缀: /api/purchase-demands
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from ..schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
PurchaseDemandResponse,
|
||||
)
|
||||
from ..services.purchase_demand_service import purchase_demand_service
|
||||
|
||||
router = APIRouter(prefix="/purchase-demands", tags=["采购需求推导"])
|
||||
|
||||
|
||||
@router.post("/calculate", response_model=PurchaseDemandResponse)
|
||||
async def calculate_purchase_demands(
|
||||
payload: PurchaseDemandCalculateRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""根据销售订单 ID 列表,自动推导采购需求(BOM 展开 → 库存对比 → 供应商推荐)"""
|
||||
return await purchase_demand_service.calculate_demands(db_session, payload.sales_order_ids)
|
||||
@@ -51,7 +51,7 @@ async def create_supplier(
|
||||
|
||||
supplier = Supplier(**data)
|
||||
db_session.add(supplier)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
|
||||
@@ -71,7 +71,7 @@ async def update_supplier(
|
||||
for key, value in supplier_data.dict().items():
|
||||
setattr(supplier, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(supplier)
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
|
||||
@@ -88,5 +88,5 @@ async def delete_supplier(
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
supplier.is_active = False
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
return {"message": "供应商已删除"}
|
||||
|
||||
@@ -44,6 +44,6 @@ async def create_warehouse(
|
||||
|
||||
warehouse = Warehouse(**data)
|
||||
db_session.add(warehouse)
|
||||
await db_session.commit()
|
||||
await db_session.flush()
|
||||
await db_session.refresh(warehouse)
|
||||
return WarehouseResponse.from_orm(warehouse)
|
||||
|
||||
@@ -56,6 +56,11 @@ from .material_schemas import (
|
||||
MaterialSupplierResponse,
|
||||
MaterialPriceTrendResponse
|
||||
)
|
||||
from .purchase_demand_schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
PurchaseDemandItemResponse,
|
||||
PurchaseDemandResponse
|
||||
)
|
||||
|
||||
from .common_schemas import PaginatedResponse
|
||||
|
||||
@@ -81,4 +86,5 @@ __all__ = [
|
||||
"PartnerProductStatementItemResponse", "FinancePartnerProductStatementResponse",
|
||||
"MaterialPriceHistoryCreate", "MaterialPriceHistoryResponse",
|
||||
"MaterialSupplierCreate", "MaterialSupplierResponse", "MaterialPriceTrendResponse",
|
||||
"PurchaseDemandCalculateRequest", "PurchaseDemandItemResponse", "PurchaseDemandResponse",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""采购需求推导相关数据模型
|
||||
|
||||
销售订单 → BOM 展开 → 物料需求 → 对比库存 → 生成采购建议
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class PurchaseDemandCalculateRequest(BaseModel):
|
||||
"""计算采购需求的请求体"""
|
||||
sales_order_ids: List[int] = Field(..., min_length=1, description="销售订单ID列表")
|
||||
|
||||
|
||||
class PurchaseDemandItemResponse(BaseModel):
|
||||
"""单个物料的采购建议"""
|
||||
material_id: int
|
||||
material_sku: str
|
||||
material_name: str
|
||||
required_quantity: Decimal = Field(..., description="BOM 需求量")
|
||||
available_quantity: Decimal = Field(..., description="当前库存量")
|
||||
shortage_quantity: Decimal = Field(..., description="缺口数量 = required - available")
|
||||
unit_cost: Decimal = Field(..., description="物料单价")
|
||||
estimated_cost: Decimal = Field(..., description="预计采购金额 = shortage × unit_cost")
|
||||
suggested_supplier_id: Optional[int] = Field(None, description="建议供应商ID")
|
||||
suggested_supplier_name: Optional[str] = Field(None, description="建议供应商名称")
|
||||
supplier_lead_time: Optional[int] = Field(None, description="供应商交货周期(天)")
|
||||
|
||||
|
||||
class PurchaseDemandResponse(BaseModel):
|
||||
"""采购需求计算结果"""
|
||||
items: List[PurchaseDemandItemResponse] = Field(default_factory=list)
|
||||
total_estimated_cost: Decimal = Field(default=Decimal("0"), description="预计采购总金额")
|
||||
shortage_count: int = Field(default=0, description="缺货物料种类数")
|
||||
source_order_ids: List[int] = Field(default_factory=list, description="来源销售订单ID")
|
||||
source_order_nos: List[str] = Field(default_factory=list, description="来源销售订单编号")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""采购需求自动推导服务
|
||||
|
||||
销售订单确认 → 按 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()
|
||||
@@ -476,6 +476,8 @@ class SalesOrderService:
|
||||
current_user: User,
|
||||
) -> SalesOrderResponse:
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "delivered":
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止修改")
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user