批次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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user