64dc85bd14
- inventory业务层下沉(薄路由+service orchestration模式): - customer/supplier/warehouse -> master_data_service - material_routes(价格历史/趋势/供应商关联)-> material_service - product_routes(CRUD/BOM/from-task跨模块桥接)-> product_service - dashboard_routes(首页统计/低库存预警)-> dashboard_service - inventory侧新增service回归覆盖(dashboard 2 / master_data 10 / material 10 / product 14),含跨模块桥接测试种子 - Pydantic v2弃用清零:全仓14处 class Config 全部迁移到 model_config = ConfigDict(from_attributes=True)(含 shared auth) - datetime.utcnow() 弃用清零:auth_service 3处统一改 datetime.now(timezone.utc) - 同步文档:STATUS / ROADMAP / TECH_DEBT(D12清偿)/ AGENTS 代码地图 测试基线:126 passed, 4 skipped(无deprecation warning) Co-Authored-By: Claude Code <noreply@anthropic.com>
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""产品管理业务服务层
|
||
|
||
将 product_routes 中不跨模块的产品 CRUD / BOM 编排下沉到此,
|
||
路由层只做参数校验与响应组装。
|
||
"""
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import delete, func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from shared.models.identity import User
|
||
from moldinsight.models import ProcessingTask, STPFile
|
||
from inventory.models import Product, ProductMaterial
|
||
from ..schemas import (
|
||
ProductBOMResponse,
|
||
ProductBOMUpdate,
|
||
ProductCreate,
|
||
ProductMaterialItemResponse,
|
||
ProductResponse,
|
||
)
|
||
|
||
|
||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, Decimal]:
|
||
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,
|
||
)
|
||
|
||
|
||
async def _get_product_or_404(db_session: AsyncSession, product_id: int, active_only: bool = False) -> Product:
|
||
query = select(Product).where(Product.id == product_id)
|
||
if active_only:
|
||
query = query.where(Product.is_active == True)
|
||
result = await db_session.execute(query)
|
||
product = result.scalar_one_or_none()
|
||
if not product:
|
||
raise HTTPException(status_code=404, detail="产品不存在")
|
||
return product
|
||
|
||
|
||
def _validate_item_type(item_type: str) -> None:
|
||
if item_type not in ["material", "finished"]:
|
||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||
|
||
|
||
async def _build_bom_response(db_session: AsyncSession, product: Product) -> ProductBOMResponse:
|
||
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,
|
||
)
|
||
|
||
|
||
class ProductService:
|
||
"""产品 CRUD 与 BOM 服务"""
|
||
|
||
@staticmethod
|
||
async def list_products(
|
||
db_session: AsyncSession,
|
||
skip: int,
|
||
limit: int,
|
||
search: Optional[str],
|
||
category: Optional[str],
|
||
item_type: Optional[str],
|
||
) -> List[ProductResponse]:
|
||
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]
|
||
|
||
@staticmethod
|
||
async def create_product(
|
||
db_session: AsyncSession,
|
||
product_data: ProductCreate,
|
||
current_user: User,
|
||
) -> ProductResponse:
|
||
_validate_item_type(product_data.item_type)
|
||
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.model_dump()
|
||
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)
|
||
|
||
@staticmethod
|
||
async def create_product_from_task(
|
||
db_session: AsyncSession,
|
||
task_id: str,
|
||
current_user: User,
|
||
) -> ProductResponse:
|
||
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
|
||
task = task_result.scalar_one_or_none()
|
||
if not task:
|
||
raise HTTPException(status_code=404, detail="分析任务不存在")
|
||
|
||
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
|
||
stp_file = stp_result.scalar_one_or_none()
|
||
if not stp_file:
|
||
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
|
||
|
||
if stp_file.product_id:
|
||
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
|
||
product = existed.scalar_one_or_none()
|
||
if product:
|
||
return _build_product_response(product, 0)
|
||
|
||
base_sku = f"MI{stp_file.id}"
|
||
sku = base_sku
|
||
n = 1
|
||
while True:
|
||
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
|
||
if not conflict.scalar_one_or_none():
|
||
break
|
||
n += 1
|
||
sku = f"{base_sku}-{n}"
|
||
|
||
name = Path(stp_file.original_filename or f"mold_{stp_file.id}").stem or f"模具分析-{stp_file.id}"
|
||
desc_parts = []
|
||
if stp_file.volume:
|
||
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
|
||
if stp_file.product_weight:
|
||
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
|
||
if stp_file.surface_area:
|
||
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
|
||
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
|
||
|
||
product = Product(
|
||
sku=sku,
|
||
name=name,
|
||
description=description,
|
||
category="模具成品",
|
||
unit="件",
|
||
item_type="finished",
|
||
cost_price=0,
|
||
sale_price=0,
|
||
min_stock=0,
|
||
max_stock=0,
|
||
)
|
||
db_session.add(product)
|
||
await db_session.flush()
|
||
stp_file.product_id = product.id
|
||
await db_session.commit()
|
||
await db_session.refresh(product)
|
||
return _build_product_response(product, 0)
|
||
|
||
@staticmethod
|
||
async def update_product(
|
||
db_session: AsyncSession,
|
||
product_id: int,
|
||
product_data: ProductCreate,
|
||
current_user: User,
|
||
) -> ProductResponse:
|
||
product = await _get_product_or_404(db_session, product_id)
|
||
_validate_item_type(product_data.item_type)
|
||
|
||
conflict = await db_session.execute(
|
||
select(Product).where(Product.sku == product_data.sku, Product.id != product_id)
|
||
)
|
||
if conflict.scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||
|
||
product_dict = product_data.model_dump()
|
||
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))
|
||
|
||
@staticmethod
|
||
async def delete_product(
|
||
db_session: AsyncSession,
|
||
product_id: int,
|
||
current_user: User,
|
||
) -> dict:
|
||
product = await _get_product_or_404(db_session, product_id)
|
||
product.is_active = False
|
||
await db_session.commit()
|
||
return {"message": "产品已删除"}
|
||
|
||
@staticmethod
|
||
async def get_product_bom(
|
||
db_session: AsyncSession,
|
||
product_id: int,
|
||
) -> ProductBOMResponse:
|
||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||
if product.item_type != "finished":
|
||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||
return await _build_bom_response(db_session, product)
|
||
|
||
@staticmethod
|
||
async def replace_product_bom(
|
||
db_session: AsyncSession,
|
||
product_id: int,
|
||
payload: ProductBOMUpdate,
|
||
current_user: User,
|
||
) -> ProductBOMResponse:
|
||
product = await _get_product_or_404(db_session, product_id, active_only=True)
|
||
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)}")
|
||
|
||
for item in payload.items:
|
||
if item.quantity <= 0:
|
||
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||
|
||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||
for item in payload.items:
|
||
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 _build_bom_response(db_session, product)
|
||
|
||
|
||
product_service = ProductService()
|