261 lines
9.7 KiB
Python
261 lines
9.7 KiB
Python
|
|
"""
|
||
|
|
产品管理路由模块
|
||
|
|
|
||
|
|
提供产品信息的增删改查功能,包括:
|
||
|
|
- 产品列表查询(支持分页、搜索、分类筛选)
|
||
|
|
- 创建新产品(SKU唯一性校验)
|
||
|
|
- 更新产品信息
|
||
|
|
- 删除产品(软删除,需要管理员权限)
|
||
|
|
|
||
|
|
路由前缀: /api/products
|
||
|
|
"""
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
from sqlalchemy import select, or_, func, delete
|
||
|
|
from typing import Optional, List, Dict
|
||
|
|
from decimal import Decimal
|
||
|
|
|
||
|
|
from database.database import get_db_session
|
||
|
|
from services.auth_service import get_current_active_user, get_current_admin_user
|
||
|
|
from models.database import User, Product, ProductMaterial
|
||
|
|
from .schemas import (
|
||
|
|
ProductCreate,
|
||
|
|
ProductResponse,
|
||
|
|
ProductBOMUpdate,
|
||
|
|
ProductBOMResponse,
|
||
|
|
ProductMaterialItemResponse
|
||
|
|
)
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||
|
|
|
||
|
|
|
||
|
|
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
|
||
|
|
if not product_ids:
|
||
|
|
return {}
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(
|
||
|
|
ProductMaterial.finished_product_id,
|
||
|
|
func.coalesce(
|
||
|
|
func.sum(
|
||
|
|
Product.cost_price * ProductMaterial.quantity
|
||
|
|
),
|
||
|
|
0
|
||
|
|
)
|
||
|
|
)
|
||
|
|
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||
|
|
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||
|
|
.group_by(ProductMaterial.finished_product_id)
|
||
|
|
)
|
||
|
|
return {row[0]: Decimal(str(row[1] or 0)) for row in result.all()}
|
||
|
|
|
||
|
|
|
||
|
|
def _build_product_response(product: Product, material_cost: Decimal = Decimal("0")) -> ProductResponse:
|
||
|
|
return ProductResponse(
|
||
|
|
id=product.id,
|
||
|
|
sku=product.sku,
|
||
|
|
name=product.name,
|
||
|
|
description=product.description,
|
||
|
|
category=product.category,
|
||
|
|
unit=product.unit,
|
||
|
|
item_type=product.item_type,
|
||
|
|
cost_price=Decimal(str(product.cost_price or 0)),
|
||
|
|
sale_price=Decimal(str(product.sale_price or 0)),
|
||
|
|
min_stock=product.min_stock,
|
||
|
|
max_stock=product.max_stock,
|
||
|
|
material_cost=Decimal(str(material_cost)).quantize(Decimal("0.0001")),
|
||
|
|
is_active=product.is_active,
|
||
|
|
created_at=product.created_at,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("", response_model=List[ProductResponse])
|
||
|
|
async def list_products(
|
||
|
|
skip: int = Query(0, ge=0),
|
||
|
|
limit: int = Query(20, ge=1, le=100),
|
||
|
|
search: Optional[str] = None,
|
||
|
|
category: Optional[str] = None,
|
||
|
|
item_type: Optional[str] = None,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_active_user)
|
||
|
|
):
|
||
|
|
query = select(Product).where(Product.is_active == True)
|
||
|
|
|
||
|
|
if search:
|
||
|
|
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||
|
|
if category:
|
||
|
|
query = query.where(Product.category == category)
|
||
|
|
if item_type:
|
||
|
|
query = query.where(Product.item_type == item_type)
|
||
|
|
|
||
|
|
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||
|
|
result = await db_session.execute(query)
|
||
|
|
products = result.scalars().all()
|
||
|
|
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||
|
|
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||
|
|
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("", response_model=ProductResponse, status_code=201)
|
||
|
|
async def create_product(
|
||
|
|
product_data: ProductCreate,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_active_user)
|
||
|
|
):
|
||
|
|
if product_data.item_type not in ["material", "finished"]:
|
||
|
|
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||
|
|
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||
|
|
if existing.scalar_one_or_none():
|
||
|
|
raise HTTPException(status_code=400, detail="SKU已存在")
|
||
|
|
|
||
|
|
product_dict = product_data.dict()
|
||
|
|
if product_data.item_type == "finished":
|
||
|
|
product_dict["min_stock"] = 0
|
||
|
|
product_dict["max_stock"] = 0
|
||
|
|
product = Product(**product_dict)
|
||
|
|
db_session.add(product)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(product)
|
||
|
|
return _build_product_response(product, 0)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/{product_id}", response_model=ProductResponse)
|
||
|
|
async def update_product(
|
||
|
|
product_id: int,
|
||
|
|
product_data: ProductCreate,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_active_user)
|
||
|
|
):
|
||
|
|
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||
|
|
product = result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="产品不存在")
|
||
|
|
if product_data.item_type not in ["material", "finished"]:
|
||
|
|
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||
|
|
|
||
|
|
product_dict = product_data.dict()
|
||
|
|
if product_data.item_type == "finished":
|
||
|
|
product_dict["min_stock"] = 0
|
||
|
|
product_dict["max_stock"] = 0
|
||
|
|
|
||
|
|
for key, value in product_dict.items():
|
||
|
|
setattr(product, key, value)
|
||
|
|
|
||
|
|
await db_session.commit()
|
||
|
|
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))
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{product_id}")
|
||
|
|
async def delete_product(
|
||
|
|
product_id: int,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_admin_user)
|
||
|
|
):
|
||
|
|
result = await db_session.execute(select(Product).where(Product.id == product_id))
|
||
|
|
product = result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="产品不存在")
|
||
|
|
|
||
|
|
product.is_active = False
|
||
|
|
await db_session.commit()
|
||
|
|
return {"message": "产品已删除"}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
|
||
|
|
async def get_product_bom(
|
||
|
|
product_id: int,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_active_user)
|
||
|
|
):
|
||
|
|
product_result = await db_session.execute(
|
||
|
|
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||
|
|
)
|
||
|
|
product = product_result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="产品不存在")
|
||
|
|
if product.item_type != "finished":
|
||
|
|
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||
|
|
|
||
|
|
bom_result = await db_session.execute(
|
||
|
|
select(ProductMaterial, Product)
|
||
|
|
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||
|
|
.where(ProductMaterial.finished_product_id == product_id)
|
||
|
|
.order_by(ProductMaterial.id.asc())
|
||
|
|
)
|
||
|
|
|
||
|
|
items: List[ProductMaterialItemResponse] = []
|
||
|
|
total_material_cost = Decimal("0")
|
||
|
|
for bom, material in bom_result.all():
|
||
|
|
line_cost = Decimal(str(material.cost_price or 0)) * Decimal(str(bom.quantity))
|
||
|
|
total_material_cost += line_cost
|
||
|
|
items.append(
|
||
|
|
ProductMaterialItemResponse(
|
||
|
|
material_id=material.id,
|
||
|
|
material_sku=material.sku,
|
||
|
|
material_name=material.name,
|
||
|
|
quantity=Decimal(str(bom.quantity)),
|
||
|
|
unit_cost=Decimal(str(material.cost_price or 0)),
|
||
|
|
line_cost=line_cost,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
return ProductBOMResponse(
|
||
|
|
product_id=product.id,
|
||
|
|
product_name=product.name,
|
||
|
|
total_material_cost=total_material_cost,
|
||
|
|
items=items,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
|
||
|
|
async def replace_product_bom(
|
||
|
|
product_id: int,
|
||
|
|
payload: ProductBOMUpdate,
|
||
|
|
db_session: AsyncSession = Depends(get_db_session),
|
||
|
|
current_user: User = Depends(get_current_active_user)
|
||
|
|
):
|
||
|
|
product_result = await db_session.execute(
|
||
|
|
select(Product).where(Product.id == product_id, Product.is_active == True)
|
||
|
|
)
|
||
|
|
product = product_result.scalar_one_or_none()
|
||
|
|
if not product:
|
||
|
|
raise HTTPException(status_code=404, detail="产品不存在")
|
||
|
|
if product.item_type != "finished":
|
||
|
|
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||
|
|
|
||
|
|
material_ids = [item.material_id for item in payload.items]
|
||
|
|
if len(material_ids) != len(set(material_ids)):
|
||
|
|
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||
|
|
|
||
|
|
if material_ids:
|
||
|
|
material_result = await db_session.execute(
|
||
|
|
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||
|
|
)
|
||
|
|
materials = material_result.scalars().all()
|
||
|
|
material_map = {m.id: m for m in materials}
|
||
|
|
if len(material_map) != len(material_ids):
|
||
|
|
raise HTTPException(status_code=400, detail="存在无效物料")
|
||
|
|
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||
|
|
if invalid_materials:
|
||
|
|
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||
|
|
else:
|
||
|
|
material_map = {}
|
||
|
|
|
||
|
|
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||
|
|
|
||
|
|
for item in payload.items:
|
||
|
|
if item.quantity <= 0:
|
||
|
|
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||
|
|
db_session.add(
|
||
|
|
ProductMaterial(
|
||
|
|
finished_product_id=product_id,
|
||
|
|
material_product_id=item.material_id,
|
||
|
|
quantity=item.quantity,
|
||
|
|
loss_rate=item.loss_rate,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
await db_session.commit()
|
||
|
|
return await get_product_bom(product_id, db_session, current_user)
|