批次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
@@ -0,0 +1,71 @@
"""仪表盘聚合业务服务层
将 dashboard_routes 中的聚合查询与统计编排下沉到此,
路由层只做依赖注入与响应返回。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from inventory.models import Customer, Inventory, Product, PurchaseOrder, SalesOrder, Supplier, Warehouse
class DashboardService:
"""仪表盘统计服务"""
@staticmethod
async def get_dashboard(db_session: AsyncSession) -> dict:
material_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
) or 0
finished_product_count = await db_session.scalar(
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
) or 0
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True)) or 0
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True)) or 0
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True)) or 0
total_stock = await db_session.scalar(
select(func.sum(Inventory.quantity))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
total_value = await db_session.scalar(
select(func.sum(Inventory.quantity * Product.cost_price))
.join(Product, Inventory.product_id == Product.id)
.where(Product.item_type == "material")
) or 0
pending_purchase = await db_session.scalar(
select(func.count(PurchaseOrder.id)).where(PurchaseOrder.status == "pending")
) or 0
pending_sales = await db_session.scalar(
select(func.count(SalesOrder.id)).where(SalesOrder.status == "pending")
) or 0
low_stock_products = await db_session.execute(
select(Product, Inventory)
.join(Inventory, Product.id == Inventory.product_id)
.where(Product.item_type == "material")
.where(Inventory.quantity <= Product.min_stock)
.limit(10)
)
low_stock = [
{"id": p.id, "name": p.name, "sku": p.sku, "quantity": i.quantity, "min_stock": p.min_stock}
for p, i in low_stock_products.all()
]
return {
"finished_product_count": finished_product_count,
"material_count": material_count,
"supplier_count": supplier_count,
"customer_count": customer_count,
"warehouse_count": warehouse_count,
"total_stock": total_stock,
"total_value": round(total_value, 2),
"pending_purchase": pending_purchase,
"pending_sales": pending_sales,
"low_stock_products": low_stock,
}
dashboard_service = DashboardService()
@@ -0,0 +1,193 @@
"""进销存主数据业务服务层
将 customer / supplier / warehouse 这类主数据 CRUD 编排从路由层下沉到此,
路由层只做参数校验与响应组装。
"""
from datetime import datetime
from typing import List, Optional, Type
from fastapi import HTTPException
from sqlalchemy import Select, select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import Customer, Supplier, Warehouse
from ..schemas import (
CustomerCreate,
CustomerResponse,
SupplierCreate,
SupplierResponse,
WarehouseCreate,
WarehouseResponse,
)
def _generate_code(prefix: str) -> str:
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
async def _get_entity_or_404(
db_session: AsyncSession,
model: Type[Customer] | Type[Supplier] | Type[Warehouse],
entity_id: int,
detail: str,
):
result = await db_session.execute(select(model).where(model.id == entity_id))
entity = result.scalar_one_or_none()
if not entity:
raise HTTPException(status_code=404, detail=detail)
return entity
def _build_customer_response(customer: Customer) -> CustomerResponse:
return CustomerResponse.model_validate(customer)
def _build_supplier_response(supplier: Supplier) -> SupplierResponse:
return SupplierResponse.model_validate(supplier)
def _build_warehouse_response(warehouse: Warehouse) -> WarehouseResponse:
return WarehouseResponse.model_validate(warehouse)
class MasterDataService:
"""客户 / 供应商 / 仓库主数据服务"""
@staticmethod
async def list_customers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[CustomerResponse]:
query: Select = select(Customer).where(Customer.is_active == True)
if search:
query = query.where(Customer.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Customer.created_at.desc())
result = await db_session.execute(query)
return [_build_customer_response(customer) for customer in result.scalars().all()]
@staticmethod
async def create_customer(
db_session: AsyncSession,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
data = customer_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("C")
customer = Customer(**data)
db_session.add(customer)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def update_customer(
db_session: AsyncSession,
customer_id: int,
customer_data: CustomerCreate,
current_user: User,
) -> CustomerResponse:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
for key, value in customer_data.model_dump().items():
setattr(customer, key, value)
await db_session.commit()
await db_session.refresh(customer)
return _build_customer_response(customer)
@staticmethod
async def delete_customer(
db_session: AsyncSession,
customer_id: int,
current_user: User,
) -> dict:
customer = await _get_entity_or_404(db_session, Customer, customer_id, "客户不存在")
customer.is_active = False
await db_session.commit()
return {"message": "客户已删除"}
@staticmethod
async def list_suppliers(
db_session: AsyncSession,
skip: int,
limit: int,
search: Optional[str],
) -> List[SupplierResponse]:
query: Select = select(Supplier).where(Supplier.is_active == True)
if search:
query = query.where(Supplier.name.ilike(f"%{search}%"))
query = query.offset(skip).limit(limit).order_by(Supplier.created_at.desc())
result = await db_session.execute(query)
return [_build_supplier_response(supplier) for supplier in result.scalars().all()]
@staticmethod
async def create_supplier(
db_session: AsyncSession,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
data = supplier_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("S")
supplier = Supplier(**data)
db_session.add(supplier)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def update_supplier(
db_session: AsyncSession,
supplier_id: int,
supplier_data: SupplierCreate,
current_user: User,
) -> SupplierResponse:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
for key, value in supplier_data.model_dump().items():
setattr(supplier, key, value)
await db_session.commit()
await db_session.refresh(supplier)
return _build_supplier_response(supplier)
@staticmethod
async def delete_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
supplier = await _get_entity_or_404(db_session, Supplier, supplier_id, "供应商不存在")
supplier.is_active = False
await db_session.commit()
return {"message": "供应商已删除"}
@staticmethod
async def list_warehouses(
db_session: AsyncSession,
) -> List[WarehouseResponse]:
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc())
)
return [_build_warehouse_response(warehouse) for warehouse in result.scalars().all()]
@staticmethod
async def create_warehouse(
db_session: AsyncSession,
warehouse_data: WarehouseCreate,
current_user: User,
) -> WarehouseResponse:
data = warehouse_data.model_dump()
if not data.get("code"):
data["code"] = _generate_code("W")
warehouse = Warehouse(**data)
db_session.add(warehouse)
await db_session.commit()
await db_session.refresh(warehouse)
return _build_warehouse_response(warehouse)
master_data_service = MasterDataService()
+284
View File
@@ -0,0 +1,284 @@
"""物料管理业务服务层
将 material_routes 中的价格历史、价格趋势、物料供应商关联等业务编排下沉到此,
路由层只做参数校验与响应组装。
"""
from typing import List
from fastapi import HTTPException
from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.identity import User
from inventory.models import MaterialPriceHistory, MaterialSupplier, Product, Supplier
from ..schemas import (
MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse,
MaterialPriceTrendResponse,
MaterialSupplierCreate,
MaterialSupplierResponse,
PriceHistoryItem,
)
async def _get_material_product(db_session: AsyncSession, product_id: int) -> Product:
result = await db_session.execute(
select(Product).where(Product.id == product_id, Product.is_active == True)
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="仅物料类型支持价格历史管理")
return product
async def _get_active_supplier(db_session: AsyncSession, supplier_id: int, detail: str = "供应商不存在") -> Supplier:
result = await db_session.execute(
select(Supplier).where(Supplier.id == supplier_id, Supplier.is_active == True)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=400 if detail == "供应商不存在" else 404, detail=detail)
return supplier
def _build_price_history_response(
product: Product,
price_history: MaterialPriceHistory,
supplier_name: str | None,
) -> MaterialPriceHistoryResponse:
return MaterialPriceHistoryResponse(
id=price_history.id,
product_id=price_history.product_id,
product_sku=product.sku,
product_name=product.name,
price=price_history.price,
effective_date=price_history.effective_date,
supplier_id=price_history.supplier_id,
supplier_name=supplier_name,
remark=price_history.remark,
created_at=price_history.created_at,
)
def _build_material_supplier_response(
product: Product,
material_supplier: MaterialSupplier,
supplier_name: str | None,
) -> MaterialSupplierResponse:
return MaterialSupplierResponse(
id=material_supplier.id,
product_id=material_supplier.product_id,
product_sku=product.sku,
product_name=product.name,
supplier_id=material_supplier.supplier_id,
supplier_name=supplier_name,
is_primary=material_supplier.is_primary,
contact_person=material_supplier.contact_person,
contact_phone=material_supplier.contact_phone,
lead_time=material_supplier.lead_time,
min_order_quantity=material_supplier.min_order_quantity,
created_at=material_supplier.created_at,
updated_at=material_supplier.updated_at,
)
class MaterialService:
"""物料价格与供应商关联服务"""
@staticmethod
async def add_price_history(
db_session: AsyncSession,
product_id: int,
price_data: MaterialPriceHistoryCreate,
current_user: User,
) -> MaterialPriceHistoryResponse:
product = await _get_material_product(db_session, product_id)
supplier_name = None
if price_data.supplier_id:
supplier = await _get_active_supplier(db_session, price_data.supplier_id)
supplier_name = supplier.name
price_history = MaterialPriceHistory(
product_id=product_id,
price=price_data.price,
supplier_id=price_data.supplier_id,
remark=price_data.remark,
)
db_session.add(price_history)
product.cost_price = price_data.price
await db_session.commit()
await db_session.refresh(price_history)
return _build_price_history_response(product, price_history, supplier_name)
@staticmethod
async def get_price_history(
db_session: AsyncSession,
product_id: int,
limit: int,
) -> List[MaterialPriceHistoryResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(limit)
)
return [
_build_price_history_response(product, ph, supplier.name if supplier else None)
for ph, supplier in result.all()
]
@staticmethod
async def get_price_trend(
db_session: AsyncSession,
product_id: int,
months: int,
) -> MaterialPriceTrendResponse:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialPriceHistory, Supplier)
.outerjoin(Supplier, MaterialPriceHistory.supplier_id == Supplier.id)
.where(MaterialPriceHistory.product_id == product_id)
.order_by(desc(MaterialPriceHistory.effective_date))
.limit(months)
)
rows = result.all()
price_history_list = [ph for ph, _supplier in rows]
if not price_history_list:
raise HTTPException(status_code=404, detail="无价格历史记录")
current_price = price_history_list[0].price
first_price = price_history_list[-1].price
price_change = current_price - first_price
price_change_percent = (price_change / first_price * 100) if first_price > 0 else 0
return MaterialPriceTrendResponse(
product_id=product_id,
product_sku=product.sku,
product_name=product.name,
current_price=current_price,
price_change=round(price_change, 2),
price_change_percent=round(price_change_percent, 2),
price_history=[
PriceHistoryItem(
date=ph.effective_date,
price=ph.price,
supplier_name=supplier.name if supplier else None,
)
for ph, supplier in rows
],
)
@staticmethod
async def add_material_supplier(
db_session: AsyncSession,
product_id: int,
supplier_data: MaterialSupplierCreate,
current_user: User,
) -> MaterialSupplierResponse:
product = await _get_material_product(db_session, product_id)
supplier = await _get_active_supplier(db_session, supplier_data.supplier_id)
existing = await db_session.execute(
select(MaterialSupplier).where(
MaterialSupplier.product_id == product_id,
MaterialSupplier.supplier_id == supplier_data.supplier_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该供应商已关联到该物料")
if supplier_data.is_primary:
await db_session.execute(
update(MaterialSupplier)
.where(MaterialSupplier.product_id == product_id)
.values(is_primary=False)
)
material_supplier = MaterialSupplier(
product_id=product_id,
supplier_id=supplier_data.supplier_id,
is_primary=supplier_data.is_primary,
contact_person=supplier_data.contact_person,
contact_phone=supplier_data.contact_phone,
lead_time=supplier_data.lead_time,
min_order_quantity=supplier_data.min_order_quantity,
)
db_session.add(material_supplier)
await db_session.commit()
await db_session.refresh(material_supplier)
return _build_material_supplier_response(product, material_supplier, supplier.name)
@staticmethod
async def get_material_suppliers(
db_session: AsyncSession,
product_id: int,
) -> List[MaterialSupplierResponse]:
product = await _get_material_product(db_session, product_id)
result = await db_session.execute(
select(MaterialSupplier, Supplier)
.outerjoin(Supplier, MaterialSupplier.supplier_id == Supplier.id)
.where(MaterialSupplier.product_id == product_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
_build_material_supplier_response(product, ms, supplier.name if supplier else None)
for ms, supplier in result.all()
]
@staticmethod
async def remove_material_supplier(
db_session: AsyncSession,
supplier_id: int,
current_user: User,
) -> dict:
result = await db_session.execute(
select(MaterialSupplier).where(MaterialSupplier.id == supplier_id)
)
material_supplier = result.scalar_one_or_none()
if not material_supplier:
raise HTTPException(status_code=404, detail="物料供应商关联不存在")
await db_session.delete(material_supplier)
await db_session.commit()
return {"message": "物料供应商关联已删除"}
@staticmethod
async def get_supplier_materials(
db_session: AsyncSession,
supplier_id: int,
) -> List[MaterialSupplierResponse]:
supplier = await _get_active_supplier(db_session, supplier_id, detail="供应商不存在")
result = await db_session.execute(
select(MaterialSupplier, Product)
.outerjoin(Product, MaterialSupplier.product_id == Product.id)
.where(MaterialSupplier.supplier_id == supplier_id)
.order_by(MaterialSupplier.is_primary.desc(), MaterialSupplier.id.asc())
)
return [
MaterialSupplierResponse(
id=ms.id,
product_id=ms.product_id,
product_sku=product.sku if product else None,
product_name=product.name if product else None,
supplier_id=ms.supplier_id,
supplier_name=supplier.name,
is_primary=ms.is_primary,
contact_person=ms.contact_person,
contact_phone=ms.contact_phone,
lead_time=ms.lead_time,
min_order_quantity=ms.min_order_quantity,
created_at=ms.created_at,
updated_at=ms.updated_at,
)
for ms, product in result.all()
]
material_service = MaterialService()
+317
View File
@@ -0,0 +1,317 @@
"""产品管理业务服务层
将 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()