批次5:inventory服务下沉收口 + Pydantic v2 / datetime弃用清零

- 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>
This commit is contained in:
2026-09-22 16:13:45 +08:00
parent c51e6b793a
commit 64dc85bd14
32 changed files with 1495 additions and 690 deletions
+12 -246
View File
@@ -9,67 +9,20 @@
路由前缀: /api/products
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from fastapi import APIRouter, Depends, 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 pathlib import Path
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.identity import User
from moldinsight.models import STPFile, ProcessingTask
from inventory.models import Product, ProductMaterial
from ..schemas import (
ProductCreate,
ProductResponse,
ProductBOMUpdate,
ProductBOMResponse,
ProductMaterialItemResponse
)
from ..schemas import ProductBOMResponse, ProductBOMUpdate, ProductCreate, ProductResponse
from ..services.product_service import product_service
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(
@@ -81,21 +34,7 @@ async def list_products(
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]
return await product_service.list_products(db_session, skip, limit, search, category, item_type)
@router.post("", response_model=ProductResponse, status_code=201)
@@ -104,21 +43,7 @@ async def create_product(
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.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product(db_session, product_data, current_user)
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
@@ -127,62 +52,7 @@ async def create_product_from_task(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
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)
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
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.flush()
await db_session.refresh(product)
return _build_product_response(product, 0)
return await product_service.create_product_from_task(db_session, task_id, current_user)
@router.put("/{product_id}", response_model=ProductResponse)
@@ -192,25 +62,7 @@ async def update_product(
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.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))
return await product_service.update_product(db_session, product_id, product_data, current_user)
@router.delete("/{product_id}")
@@ -219,14 +71,7 @@ async def delete_product(
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.flush()
return {"message": "产品已删除"}
return await product_service.delete_product(db_session, product_id, current_user)
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -235,44 +80,7 @@ async def get_product_bom(
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,
)
return await product_service.get_product_bom(db_session, product_id)
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
@@ -282,46 +90,4 @@ async def replace_product_bom(
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.flush()
return await get_product_bom(product_id, db_session, current_user)
return await product_service.replace_product_bom(db_session, product_id, payload, current_user)