x
This commit is contained in:
@@ -1,22 +1,17 @@
|
||||
"""
|
||||
库存管理路由模块
|
||||
|
||||
提供库存信息的查询功能,包括:
|
||||
- 库存列表查询(支持分页、仓库筛选、产品筛选、低库存筛选)
|
||||
- 显示产品库存数量、锁定数量、可用数量等信息
|
||||
"""库存管理路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.inventory_service,路由只做参数校验与响应组装。
|
||||
路由前缀: /api/inventory
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from shared.models.database import User
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
from ..services.inventory_service import inventory_service
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["库存管理"])
|
||||
|
||||
@@ -31,43 +26,7 @@ async def list_inventory(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
base_query = (
|
||||
select(Inventory, Product, Warehouse)
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
||||
.where(Product.is_active == True)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Warehouse.is_active == True)
|
||||
)
|
||||
|
||||
if warehouse_id:
|
||||
base_query = base_query.where(Inventory.warehouse_id == warehouse_id)
|
||||
if product_id:
|
||||
base_query = base_query.where(Inventory.product_id == product_id)
|
||||
if low_stock:
|
||||
base_query = base_query.where(Inventory.quantity <= Product.min_stock)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
inventory_list = []
|
||||
for inv, product, warehouse in result.all():
|
||||
inventory_list.append(InventoryResponse(
|
||||
id=inv.id,
|
||||
product_id=inv.product_id,
|
||||
product_name=product.name,
|
||||
product_sku=product.sku,
|
||||
warehouse_id=inv.warehouse_id,
|
||||
warehouse_name=warehouse.name,
|
||||
quantity=inv.quantity,
|
||||
locked_quantity=inv.locked_quantity,
|
||||
available_quantity=inv.available_quantity
|
||||
))
|
||||
|
||||
return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit)
|
||||
return await inventory_service.list_inventory(db_session, warehouse_id, product_id, low_stock, skip, limit)
|
||||
|
||||
|
||||
@router.post("", response_model=InventoryResponse, status_code=201)
|
||||
@@ -76,64 +35,7 @@ async def create_inventory(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if payload.quantity < 0 or payload.locked_quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不能为负数")
|
||||
if payload.locked_quantity > payload.quantity:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
|
||||
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == payload.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 != "material":
|
||||
raise HTTPException(status_code=400, detail="库存仅支持物料")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
exists_result = await db_session.execute(
|
||||
select(Inventory).where(
|
||||
Inventory.product_id == payload.product_id,
|
||||
Inventory.warehouse_id == payload.warehouse_id
|
||||
)
|
||||
)
|
||||
if exists_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
|
||||
|
||||
inventory = Inventory(
|
||||
product_id=payload.product_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
quantity=payload.quantity,
|
||||
locked_quantity=payload.locked_quantity,
|
||||
batch_number=payload.batch_number,
|
||||
location=payload.location
|
||||
)
|
||||
db_session.add(inventory)
|
||||
try:
|
||||
await db_session.commit()
|
||||
except IntegrityError:
|
||||
# 并发创建命中 (product_id, warehouse_id) 唯一约束
|
||||
await db_session.rollback()
|
||||
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
|
||||
await db_session.refresh(inventory)
|
||||
|
||||
return InventoryResponse(
|
||||
id=inventory.id,
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
product_sku=product.sku,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.name,
|
||||
quantity=inventory.quantity,
|
||||
locked_quantity=inventory.locked_quantity,
|
||||
available_quantity=inventory.available_quantity
|
||||
)
|
||||
return await inventory_service.create_inventory(db_session, payload, current_user)
|
||||
|
||||
|
||||
@router.put("/{inventory_id}", response_model=InventoryResponse)
|
||||
@@ -143,48 +45,7 @@ async def update_inventory(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(
|
||||
select(Inventory, Product, Warehouse)
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
||||
.where(Inventory.id == inventory_id)
|
||||
.where(Product.item_type == "material")
|
||||
.with_for_update(of=Inventory)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="库存记录不存在")
|
||||
inventory, product, warehouse = row
|
||||
|
||||
if payload.quantity is not None:
|
||||
if payload.quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不能为负数")
|
||||
inventory.quantity = payload.quantity
|
||||
if payload.locked_quantity is not None:
|
||||
if payload.locked_quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能为负数")
|
||||
inventory.locked_quantity = payload.locked_quantity
|
||||
if inventory.locked_quantity > inventory.quantity:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
|
||||
|
||||
if payload.batch_number is not None:
|
||||
inventory.batch_number = payload.batch_number
|
||||
if payload.location is not None:
|
||||
inventory.location = payload.location
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(inventory)
|
||||
return InventoryResponse(
|
||||
id=inventory.id,
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
product_sku=product.sku,
|
||||
warehouse_id=warehouse.id,
|
||||
warehouse_name=warehouse.name,
|
||||
quantity=inventory.quantity,
|
||||
locked_quantity=inventory.locked_quantity,
|
||||
available_quantity=inventory.available_quantity
|
||||
)
|
||||
return await inventory_service.update_inventory(db_session, inventory_id, payload, current_user)
|
||||
|
||||
|
||||
@router.delete("/{inventory_id}")
|
||||
@@ -193,12 +54,4 @@ async def delete_inventory(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id))
|
||||
inventory = result.scalar_one_or_none()
|
||||
if not inventory:
|
||||
raise HTTPException(status_code=404, detail="库存记录不存在")
|
||||
if inventory.quantity > 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录")
|
||||
await db_session.delete(inventory)
|
||||
await db_session.commit()
|
||||
return {"message": "库存记录已删除"}
|
||||
return await inventory_service.delete_inventory(db_session, inventory_id, current_user)
|
||||
|
||||
@@ -1,203 +1,27 @@
|
||||
"""
|
||||
采购订单路由模块
|
||||
|
||||
提供采购订单的管理功能,包括:
|
||||
- 采购订单列表查询(支持分页、状态筛选)
|
||||
- 创建采购订单(自动生成订单号、计算总金额)
|
||||
- 采购订单明细管理
|
||||
"""采购订单路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.purchase_order_service,路由只做参数校验与响应组装。
|
||||
路由前缀: /api/purchase-orders
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Supplier,
|
||||
Product,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem
|
||||
)
|
||||
from shared.models.database import User
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
PurchaseOrderDetailResponse,
|
||||
PurchaseOrderItemResponse,
|
||||
PurchaseOrderReceiveRequest,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from ..utils import generate_order_no
|
||||
from ..services.purchase_order_service import purchase_order_service
|
||||
|
||||
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
|
||||
|
||||
|
||||
def _compute_receipt_status(order: PurchaseOrder) -> str:
|
||||
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
|
||||
if order.status == "cancelled":
|
||||
return "cancelled"
|
||||
if order.status in ("received", "paid"):
|
||||
return "received"
|
||||
if order.status == "partial_received":
|
||||
return "partial_received"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _compute_payment_status(order: PurchaseOrder) -> str:
|
||||
"""付款状态:未付款 / 已付款"""
|
||||
if order.status == "paid" or order.paid_date:
|
||||
return "paid"
|
||||
return "unpaid"
|
||||
|
||||
|
||||
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
|
||||
return PurchaseOrderResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_name=supplier_name,
|
||||
order_date=order.order_date,
|
||||
expected_date=order.expected_date,
|
||||
status=order.status,
|
||||
receipt_status=_compute_receipt_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
total_amount=order.total_amount,
|
||||
paid_amount=order.paid_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
received_date=order.received_date,
|
||||
paid_date=order.paid_date
|
||||
)
|
||||
|
||||
|
||||
async def _get_order_with_supplier(
|
||||
db_session: AsyncSession,
|
||||
order_id: int
|
||||
) -> tuple[PurchaseOrder, Supplier]:
|
||||
result = await db_session.execute(
|
||||
select(PurchaseOrder, Supplier)
|
||||
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
||||
.where(PurchaseOrder.id == order_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="采购订单不存在")
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def _build_purchase_order_detail(
|
||||
db_session: AsyncSession,
|
||||
order: PurchaseOrder,
|
||||
supplier_name: str
|
||||
) -> PurchaseOrderDetailResponse:
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem, Product)
|
||||
.join(Product, PurchaseOrderItem.product_id == Product.id)
|
||||
.where(PurchaseOrderItem.order_id == order.id)
|
||||
.order_by(PurchaseOrderItem.id.asc())
|
||||
)
|
||||
item_rows = item_result.all()
|
||||
|
||||
return PurchaseOrderDetailResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_id=order.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
order_date=order.order_date,
|
||||
expected_date=order.expected_date,
|
||||
status=order.status,
|
||||
receipt_status=_compute_receipt_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
total_amount=order.total_amount,
|
||||
paid_amount=order.paid_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
received_date=order.received_date,
|
||||
paid_date=order.paid_date,
|
||||
items=[
|
||||
PurchaseOrderItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
quantity=item.quantity,
|
||||
received_quantity=item.received_quantity,
|
||||
unit_price=item.unit_price,
|
||||
amount=item.amount,
|
||||
remark=item.remark
|
||||
) for item, product in item_rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_receive_warehouse(
|
||||
db_session: AsyncSession,
|
||||
warehouse_id: Optional[int]
|
||||
) -> Warehouse:
|
||||
if warehouse_id:
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
return warehouse
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
|
||||
)
|
||||
warehouse = result.scalars().first()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=400, detail="未配置可用仓库")
|
||||
return warehouse
|
||||
|
||||
|
||||
async def _apply_order_items(
|
||||
db_session: AsyncSession,
|
||||
order: PurchaseOrder,
|
||||
order_data: PurchaseOrderCreate
|
||||
) -> Decimal:
|
||||
total_amount = Decimal("0")
|
||||
for item_data in order_data.items:
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
|
||||
|
||||
# 优先使用前端传入的单价,否则使用物料成本价
|
||||
if item_data.unit_price and item_data.unit_price > 0:
|
||||
unit_price = float(item_data.unit_price)
|
||||
else:
|
||||
unit_price = product.cost_price or 0
|
||||
if unit_price <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
|
||||
|
||||
try:
|
||||
item = PurchaseOrderItem(
|
||||
order_id=order.id,
|
||||
product_id=item_data.product_id,
|
||||
quantity=int(item_data.quantity),
|
||||
unit_price=Decimal(str(unit_price)),
|
||||
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
|
||||
remark=item_data.remark or None
|
||||
)
|
||||
db_session.add(item)
|
||||
total_amount += item.amount
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
|
||||
return total_amount
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[PurchaseOrderResponse])
|
||||
async def list_purchase_orders(
|
||||
status: Optional[str] = None,
|
||||
@@ -206,26 +30,7 @@ async def list_purchase_orders(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
base_query = (
|
||||
select(PurchaseOrder, Supplier)
|
||||
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
||||
.order_by(PurchaseOrder.created_at.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
base_query = base_query.where(PurchaseOrder.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
orders = []
|
||||
for order, supplier in result.all():
|
||||
orders.append(_build_purchase_order_response(order, supplier.name))
|
||||
|
||||
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
|
||||
return await purchase_order_service.list_orders(db_session, status, skip, limit)
|
||||
|
||||
|
||||
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
|
||||
@@ -234,31 +39,7 @@ async def create_purchase_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order = PurchaseOrder(
|
||||
order_no=generate_order_no("PO"),
|
||||
supplier_id=order_data.supplier_id,
|
||||
expected_date=order_data.expected_date,
|
||||
remark=order_data.remark,
|
||||
operator_id=current_user.id,
|
||||
status="pending"
|
||||
)
|
||||
db_session.add(order)
|
||||
await db_session.flush()
|
||||
|
||||
try:
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
return _build_purchase_order_response(order, supplier.name)
|
||||
return await purchase_order_service.create_order(db_session, order_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=PurchaseOrderDetailResponse)
|
||||
@@ -267,8 +48,7 @@ async def get_purchase_order_detail(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
||||
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
||||
return await purchase_order_service.get_detail(db_session, order_id)
|
||||
|
||||
|
||||
@router.put("/{order_id}", response_model=PurchaseOrderResponse)
|
||||
@@ -278,35 +58,7 @@ async def update_purchase_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.paid_amount and order.paid_amount > 0:
|
||||
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
|
||||
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
existing_items = item_result.scalars().all()
|
||||
if any((item.received_quantity or 0) > 0 for item in existing_items):
|
||||
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
|
||||
|
||||
for item in existing_items:
|
||||
await db_session.delete(item)
|
||||
|
||||
try:
|
||||
order.supplier_id = order_data.supplier_id
|
||||
order.expected_date = order_data.expected_date
|
||||
order.remark = order_data.remark
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
order.status = "pending"
|
||||
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
||||
return await purchase_order_service.update_order(db_session, order_id, order_data, current_user)
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
@@ -315,17 +67,7 @@ async def delete_purchase_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.paid_amount and order.paid_amount > 0:
|
||||
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
|
||||
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
|
||||
await db_session.delete(order)
|
||||
await db_session.commit()
|
||||
return {"message": "采购订单已删除"}
|
||||
return await purchase_order_service.delete_order(db_session, order_id, current_user)
|
||||
|
||||
|
||||
@router.patch("/{order_id}/status")
|
||||
@@ -335,45 +77,7 @@ async def update_purchase_order_status(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
|
||||
new_status = status.get("status")
|
||||
if not new_status:
|
||||
raise HTTPException(status_code=400, detail="状态不能为空")
|
||||
|
||||
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
|
||||
if new_status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
|
||||
|
||||
# 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩
|
||||
if new_status in ("partial_received", "received"):
|
||||
raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置")
|
||||
|
||||
# 状态转换逻辑
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
|
||||
if order.status == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
|
||||
if order.status == "received" and new_status not in ("paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
|
||||
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
|
||||
if new_status == "cancelled" and order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
|
||||
|
||||
# 更新状态和对应时间
|
||||
order.status = new_status
|
||||
if new_status == "received":
|
||||
order.received_date = func.now()
|
||||
elif new_status == "paid":
|
||||
order.paid_date = func.now()
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
||||
return await purchase_order_service.update_status(db_session, order_id, status, current_user)
|
||||
|
||||
|
||||
@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
|
||||
@@ -383,89 +87,4 @@ async def receive_purchase_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.status in ("cancelled", "paid"):
|
||||
raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货")
|
||||
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
|
||||
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
item_map = {item.id: item for item in item_result.scalars().all()}
|
||||
if not item_map:
|
||||
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
|
||||
|
||||
if not payload.items:
|
||||
raise HTTPException(status_code=400, detail="请提供本次入库明细")
|
||||
|
||||
for receive_item in payload.items:
|
||||
item = item_map.get(receive_item.item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
|
||||
if receive_item.receive_quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="入库数量必须大于0")
|
||||
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
|
||||
if receive_item.receive_quantity > remaining_qty:
|
||||
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
|
||||
|
||||
for receive_item in payload.items:
|
||||
item = item_map[receive_item.item_id]
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item.product_id)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
|
||||
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.product_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=item.product_id,
|
||||
warehouse_id=warehouse.id,
|
||||
quantity=receive_item.receive_quantity,
|
||||
locked_quantity=0
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = receive_item.receive_quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - receive_item.receive_quantity
|
||||
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.product_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="purchase_in",
|
||||
quantity=receive_item.receive_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="purchase_order",
|
||||
reference_id=order.id,
|
||||
reference_no=order.order_no,
|
||||
unit_price=item.unit_price,
|
||||
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
|
||||
remark=payload.remark or f"采购单{order.order_no}到货入库",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
|
||||
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
|
||||
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
|
||||
if all_received:
|
||||
order.status = "received"
|
||||
order.received_date = func.now()
|
||||
elif any_received:
|
||||
order.status = "partial_received"
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
||||
return await purchase_order_service.receive_order(db_session, order_id, payload, current_user)
|
||||
|
||||
@@ -1,406 +1,29 @@
|
||||
"""
|
||||
销售订单路由模块
|
||||
|
||||
提供销售订单的管理功能,包括:
|
||||
- 销售订单列表查询(支持分页、状态筛选)
|
||||
- 创建销售订单(自动生成订单号、计算总金额)
|
||||
- 销售订单明细管理
|
||||
"""销售订单路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.sales_order_service,路由只做参数校验与响应组装。
|
||||
路由前缀: /api/sales-orders
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete, update
|
||||
from typing import Optional, List
|
||||
from math import ceil
|
||||
from pydantic import BaseModel, Field
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem
|
||||
)
|
||||
from shared.models.database import User
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
SalesOrderDetailResponse,
|
||||
SalesOrderItemResponse,
|
||||
SalesOrderProductionPlanResponse,
|
||||
ProductionMaterialPlanItemResponse,
|
||||
SalesOrderIssueRequest,
|
||||
SalesOrderIssueResponse,
|
||||
SalesOrderStatusUpdate,
|
||||
MaterialConsumptionRequest,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from ..utils import generate_order_no
|
||||
from ..services.sales_order_service import sales_order_service
|
||||
|
||||
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
|
||||
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
|
||||
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
|
||||
|
||||
|
||||
def _compute_delivery_status(order: SalesOrder) -> str:
|
||||
"""物流状态:制造中 / 已交付 / 已作废"""
|
||||
if order.status == "cancelled":
|
||||
return "cancelled"
|
||||
if order.status in ("delivered", "paid") or order.actual_delivery_date:
|
||||
return "delivered"
|
||||
return "manufacturing"
|
||||
|
||||
|
||||
def _compute_payment_status(order: SalesOrder) -> str:
|
||||
"""收款状态:未收款 / 已收款"""
|
||||
if order.status == "paid" or order.actual_payment_date:
|
||||
return "paid"
|
||||
return "unpaid"
|
||||
|
||||
|
||||
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
|
||||
return SalesOrderResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_name=customer_name,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
manufacturing_date=order.manufacturing_date,
|
||||
actual_delivery_date=order.actual_delivery_date,
|
||||
actual_payment_date=order.actual_payment_date,
|
||||
status=order.status,
|
||||
delivery_status=_compute_delivery_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
production_status=order.production_status or "not_started",
|
||||
production_no=order.production_no,
|
||||
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
|
||||
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
|
||||
total_amount=Decimal(str(order.total_amount or 0)),
|
||||
received_amount=Decimal(str(order.received_amount or 0)),
|
||||
remark=order.remark,
|
||||
created_at=order.created_at
|
||||
)
|
||||
|
||||
|
||||
async def _build_sales_order_detail_response(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
customer_name: str
|
||||
) -> SalesOrderDetailResponse:
|
||||
items_result = await db_session.execute(
|
||||
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc())
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
return SalesOrderDetailResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_id=order.customer_id,
|
||||
customer_name=customer_name,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
manufacturing_date=order.manufacturing_date,
|
||||
actual_delivery_date=order.actual_delivery_date,
|
||||
actual_payment_date=order.actual_payment_date,
|
||||
status=order.status,
|
||||
delivery_status=_compute_delivery_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
production_status=order.production_status or "not_started",
|
||||
production_no=order.production_no,
|
||||
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
|
||||
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
|
||||
total_amount=Decimal(str(order.total_amount or 0)),
|
||||
received_amount=Decimal(str(order.received_amount or 0)),
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
items=[
|
||||
SalesOrderItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
quantity=item.quantity,
|
||||
delivered_quantity=item.delivered_quantity,
|
||||
unit_price=Decimal(str(item.unit_price or 0)),
|
||||
amount=Decimal(str(item.amount or 0)),
|
||||
remark=item.remark
|
||||
) for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _get_sales_order_with_customer(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
) -> tuple[SalesOrder, Customer]:
|
||||
result = await db_session.execute(
|
||||
select(SalesOrder, Customer)
|
||||
.join(Customer, SalesOrder.customer_id == Customer.id)
|
||||
.where(SalesOrder.id == order_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="销售订单不存在")
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> tuple[List[ProductionMaterialPlanItemResponse], float]:
|
||||
item_result = await db_session.execute(
|
||||
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
|
||||
)
|
||||
order_items = item_result.scalars().all()
|
||||
if not order_items:
|
||||
return [], 0
|
||||
|
||||
finished_ids = list({int(i.product_id) for i in order_items})
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(finished_ids))
|
||||
.where(Product.is_active == True)
|
||||
.where(Product.item_type == "material")
|
||||
)
|
||||
bom_rows = bom_result.all()
|
||||
if not bom_rows:
|
||||
return [], 0
|
||||
|
||||
bom_by_finished_id = {}
|
||||
for bom, material in bom_rows:
|
||||
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
|
||||
|
||||
required_qty_map = {}
|
||||
for order_item in order_items:
|
||||
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
|
||||
for bom, material in bom_items:
|
||||
qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0)))
|
||||
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")})
|
||||
entry["required_qty"] += qty
|
||||
|
||||
if not required_qty_map:
|
||||
return [], Decimal("0")
|
||||
|
||||
material_ids = list(required_qty_map.keys())
|
||||
stock_result = await db_session.execute(
|
||||
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
|
||||
.where(Inventory.product_id.in_(material_ids))
|
||||
.group_by(Inventory.product_id)
|
||||
)
|
||||
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
|
||||
|
||||
plan_items = []
|
||||
planned_material_cost = Decimal("0")
|
||||
for material_id, entry in required_qty_map.items():
|
||||
material = entry["material"]
|
||||
required_qty = int(ceil(entry["required_qty"]))
|
||||
available_qty = stock_map.get(material_id, Decimal("0"))
|
||||
shortage_qty = max(required_qty - int(available_qty), 0)
|
||||
unit_cost = Decimal(str(material.cost_price or 0))
|
||||
required_cost = Decimal(str(required_qty)) * unit_cost
|
||||
planned_material_cost += required_cost
|
||||
plan_items.append(
|
||||
ProductionMaterialPlanItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
required_quantity=Decimal(str(required_qty)),
|
||||
available_quantity=available_qty,
|
||||
shortage_quantity=Decimal(str(shortage_qty)),
|
||||
unit_cost=unit_cost,
|
||||
required_cost=required_cost,
|
||||
)
|
||||
)
|
||||
|
||||
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
|
||||
return plan_items, planned_material_cost
|
||||
|
||||
|
||||
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
|
||||
)
|
||||
warehouse = warehouse_result.scalars().first()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
|
||||
return warehouse
|
||||
|
||||
|
||||
async def _issue_materials_for_order_creation(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
current_user: User
|
||||
) -> tuple[int, float, float]:
|
||||
warehouse = await _get_default_warehouse(db_session)
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
if not plan_items:
|
||||
order.production_no = order.production_no or generate_order_no("WO")
|
||||
order.production_status = "bom_missing"
|
||||
order.planned_material_cost = 0
|
||||
order.actual_material_cost = 0
|
||||
order.status = "manufacturing"
|
||||
return 0, 0, 0
|
||||
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
|
||||
if shortage_items:
|
||||
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
|
||||
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
|
||||
|
||||
production_no = order.production_no or generate_order_no("WO")
|
||||
actual_material_cost = Decimal("0")
|
||||
movement_count = 0
|
||||
|
||||
for item in plan_items:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.material_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.where(Inventory.quantity >= item.required_quantity)
|
||||
.values(quantity=Inventory.quantity - item.required_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + int(item.required_quantity)
|
||||
total_amount = item.required_quantity * item.unit_cost
|
||||
actual_material_cost += total_amount
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.material_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="issue_to_production",
|
||||
quantity=item.required_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=production_no,
|
||||
unit_price=item.unit_cost,
|
||||
total_amount=Decimal(str(total_amount)),
|
||||
remark=f"销售单{order.order_no}创建时自动扣减物料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
movement_count += 1
|
||||
|
||||
order.production_no = production_no
|
||||
order.production_status = "material_issued"
|
||||
order.planned_material_cost = planned_material_cost
|
||||
order.actual_material_cost = actual_material_cost
|
||||
order.status = "manufacturing"
|
||||
|
||||
return movement_count, planned_material_cost, actual_material_cost
|
||||
|
||||
|
||||
async def _rollback_issued_materials(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
current_user: User
|
||||
):
|
||||
movement_result = await db_session.execute(
|
||||
select(StockMovement)
|
||||
.where(StockMovement.reference_type == "sales_order")
|
||||
.where(StockMovement.reference_id == order.id)
|
||||
.where(StockMovement.movement_type == "issue_to_production")
|
||||
.order_by(StockMovement.id.asc())
|
||||
)
|
||||
movements = movement_result.scalars().all()
|
||||
if not movements:
|
||||
return
|
||||
|
||||
for movement in movements:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == movement.product_id)
|
||||
.where(Inventory.warehouse_id == movement.warehouse_id)
|
||||
.values(quantity=Inventory.quantity + movement.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=movement.product_id,
|
||||
warehouse_id=movement.warehouse_id,
|
||||
quantity=movement.quantity,
|
||||
locked_quantity=0
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = movement.quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - movement.quantity
|
||||
|
||||
revert_movement = StockMovement(
|
||||
product_id=movement.product_id,
|
||||
warehouse_id=movement.warehouse_id,
|
||||
movement_type="return_from_production",
|
||||
quantity=movement.quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=order.production_no or order.order_no,
|
||||
unit_price=movement.unit_price,
|
||||
total_amount=movement.total_amount,
|
||||
remark=f"销售单{order.order_no}变更/删除,自动回补物料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(revert_movement)
|
||||
|
||||
|
||||
async def _apply_order_items(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
order_data: SalesOrderCreate
|
||||
) -> Decimal:
|
||||
total_amount = Decimal("0")
|
||||
for item_data in order_data.items:
|
||||
product = None
|
||||
if item_data.product_id is not None:
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}")
|
||||
else:
|
||||
if not item_data.product_sku or not item_data.product_name:
|
||||
raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称")
|
||||
by_sku_result = await db_session.execute(
|
||||
select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True)
|
||||
)
|
||||
product = by_sku_result.scalar_one_or_none()
|
||||
if not product:
|
||||
product = Product(
|
||||
sku=item_data.product_sku,
|
||||
name=item_data.product_name,
|
||||
category=item_data.product_category,
|
||||
unit=item_data.product_unit or "件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=item_data.unit_price or 0,
|
||||
min_stock=0,
|
||||
max_stock=0
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
|
||||
item = SalesOrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=item_data.quantity,
|
||||
unit_price=item_data.unit_price,
|
||||
amount=item_data.quantity * item_data.unit_price,
|
||||
remark=item_data.remark
|
||||
)
|
||||
db_session.add(item)
|
||||
total_amount += item.amount
|
||||
return total_amount
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[SalesOrderResponse])
|
||||
@@ -411,26 +34,7 @@ async def list_sales_orders(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
base_query = (
|
||||
select(SalesOrder, Customer)
|
||||
.join(Customer, SalesOrder.customer_id == Customer.id)
|
||||
.order_by(SalesOrder.created_at.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
base_query = base_query.where(SalesOrder.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
orders = []
|
||||
for order, customer in result.all():
|
||||
orders.append(_build_sales_order_response(order, customer.name))
|
||||
|
||||
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
|
||||
return await sales_order_service.list_orders(db_session, status, skip, limit)
|
||||
|
||||
|
||||
@router.post("", response_model=SalesOrderResponse, status_code=201)
|
||||
@@ -439,35 +43,7 @@ async def create_sales_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
order = SalesOrder(
|
||||
order_no=generate_order_no("SO"),
|
||||
customer_id=order_data.customer_id,
|
||||
order_date=now,
|
||||
delivery_date=order_data.delivery_date,
|
||||
manufacturing_date=now.date(),
|
||||
created_at=now,
|
||||
remark=order_data.remark,
|
||||
operator_id=current_user.id,
|
||||
status="manufacturing"
|
||||
)
|
||||
db_session.add(order)
|
||||
await db_session.flush()
|
||||
|
||||
try:
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await _issue_materials_for_order_creation(db_session, order, current_user)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||
customer = customer.scalar_one()
|
||||
|
||||
return _build_sales_order_response(order, customer.name)
|
||||
return await sales_order_service.create_order(db_session, order_data, current_user)
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=SalesOrderDetailResponse)
|
||||
@@ -476,8 +52,7 @@ async def get_sales_order_detail(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
return await _build_sales_order_detail_response(db_session, order, customer.name)
|
||||
return await sales_order_service.get_detail(db_session, order_id)
|
||||
|
||||
|
||||
@router.put("/{order_id}", response_model=SalesOrderResponse)
|
||||
@@ -487,34 +62,7 @@ async def update_sales_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
|
||||
try:
|
||||
await _rollback_issued_materials(db_session, order, current_user)
|
||||
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
|
||||
|
||||
order.customer_id = order_data.customer_id
|
||||
order.delivery_date = order_data.delivery_date
|
||||
order.remark = order_data.remark
|
||||
order.production_status = "not_started"
|
||||
order.production_no = None
|
||||
order.planned_material_cost = 0
|
||||
order.actual_material_cost = 0
|
||||
order.status = "manufacturing"
|
||||
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await _issue_materials_for_order_creation(db_session, order, current_user)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||
updated_customer = customer_result.scalar_one_or_none()
|
||||
customer_name = updated_customer.name if updated_customer else "未知客户"
|
||||
return _build_sales_order_response(order, customer_name)
|
||||
return await sales_order_service.update_order(db_session, order_id, order_data, current_user)
|
||||
|
||||
|
||||
@router.patch("/{order_id}/status", response_model=SalesOrderResponse)
|
||||
@@ -524,34 +72,7 @@ async def update_sales_order_status(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if payload.status not in VALID_ORDER_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
|
||||
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
|
||||
# 状态转换守卫
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改状态")
|
||||
if order.status == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="已作废的销售订单禁止修改状态")
|
||||
if order.status == "delivered" and payload.status not in ("paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款或已作废")
|
||||
if payload.status == "cancelled" and order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的订单不能作废,请联系管理员")
|
||||
|
||||
# 根据状态更新相应的日期字段
|
||||
from datetime import datetime
|
||||
if payload.status == "delivered" and not order.actual_delivery_date:
|
||||
order.actual_delivery_date = datetime.now()
|
||||
elif payload.status == "paid" and not order.actual_payment_date:
|
||||
order.actual_payment_date = datetime.now()
|
||||
elif payload.status == "cancelled" and not order.actual_delivery_date:
|
||||
order.actual_delivery_date = datetime.now()
|
||||
|
||||
order.status = payload.status
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
return _build_sales_order_response(order, customer.name)
|
||||
return await sales_order_service.update_status(db_session, order_id, payload, current_user)
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
@@ -560,27 +81,7 @@ async def delete_sales_order(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "delivered":
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止删除,请先作废相关收款单")
|
||||
if (order.received_amount or 0) > 0:
|
||||
raise HTTPException(status_code=400, detail="该销售订单已存在收款记录(received_amount>0),禁止删除,请先作废相关收款单")
|
||||
await _rollback_issued_materials(db_session, order, current_user)
|
||||
await db_session.delete(order)
|
||||
await db_session.commit()
|
||||
return {"message": "销售订单已删除"}
|
||||
|
||||
|
||||
class MaterialConsumptionItem(BaseModel):
|
||||
material_id: int
|
||||
quantity: float = Field(gt=0)
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class MaterialConsumptionRequest(BaseModel):
|
||||
items: List[MaterialConsumptionItem] = Field(min_length=1)
|
||||
return await sales_order_service.delete_order(db_session, order_id, current_user)
|
||||
|
||||
|
||||
@router.post("/{order_id}/consume-materials")
|
||||
@@ -591,76 +92,7 @@ async def consume_materials(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""记录销售订单的物料消耗(在已发料基础上记录额外消耗,如报废/超耗)"""
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
|
||||
if order.status in ("delivered", "paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="当前订单状态不允许记录物料消耗")
|
||||
if order.production_status == "completed":
|
||||
raise HTTPException(status_code=400, detail="该销售单已完成生产,不允许记录物料消耗")
|
||||
|
||||
default_warehouse = await _get_default_warehouse(db_session)
|
||||
|
||||
# 计算总物料成本
|
||||
total_cost = Decimal("0")
|
||||
|
||||
# 处理每个物料消耗项
|
||||
for item in request.items:
|
||||
# 获取物料信息
|
||||
material = await db_session.get(Product, item.material_id)
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
|
||||
if material.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
|
||||
|
||||
# 统一转 Decimal,避免 Decimal * float 在 Postgres(Numeric) 上抛 TypeError
|
||||
consume_qty = Decimal(str(item.quantity))
|
||||
unit_cost = Decimal(str(material.cost_price or 0))
|
||||
cost = unit_cost * consume_qty
|
||||
total_cost += cost
|
||||
|
||||
# 更新物料库存(原子操作防并发)
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == material.id)
|
||||
.where(Inventory.warehouse_id == default_warehouse.id)
|
||||
.where(Inventory.quantity >= consume_qty)
|
||||
.values(quantity=Inventory.quantity - consume_qty)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
|
||||
after_qty = Decimal(str(after_qty))
|
||||
before_qty = after_qty + consume_qty
|
||||
|
||||
# 记录物料消耗
|
||||
movement = StockMovement(
|
||||
product_id=material.id,
|
||||
warehouse_id=default_warehouse.id,
|
||||
quantity=consume_qty,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
movement_type="consumption",
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
unit_price=unit_cost,
|
||||
total_amount=cost,
|
||||
operator_id=current_user.id,
|
||||
remark=item.remark
|
||||
)
|
||||
db_session.add(movement)
|
||||
|
||||
# 累加实际物料成本(创建时已记录计划发料成本,此处为额外消耗,不应覆盖)
|
||||
order.actual_material_cost = Decimal(str(order.actual_material_cost or 0)) + total_cost
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
|
||||
return {
|
||||
"message": "物料消耗记录保存成功",
|
||||
"total_cost": total_cost,
|
||||
"order": _build_sales_order_response(order, customer.name)
|
||||
}
|
||||
return await sales_order_service.consume_materials(db_session, order_id, request, current_user)
|
||||
|
||||
|
||||
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
|
||||
@@ -669,18 +101,7 @@ async def get_sales_order_production_plan(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
production_no = order.production_no or generate_order_no("WO")
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
|
||||
return SalesOrderProductionPlanResponse(
|
||||
sales_order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_name=customer.name,
|
||||
production_no=production_no,
|
||||
planned_material_cost=planned_material_cost,
|
||||
items=plan_items,
|
||||
)
|
||||
return await sales_order_service.get_production_plan(db_session, order_id)
|
||||
|
||||
|
||||
@router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse)
|
||||
@@ -690,88 +111,4 @@ async def issue_sales_order_materials(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, _ = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "delivered":
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
|
||||
if order.production_status == "completed":
|
||||
raise HTTPException(status_code=400, detail="该销售单已完成生产")
|
||||
if order.production_status == "material_issued":
|
||||
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
if not plan_items:
|
||||
raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料")
|
||||
|
||||
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
|
||||
if shortage_items:
|
||||
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
|
||||
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
|
||||
|
||||
production_no = payload.production_no or order.production_no or generate_order_no("WO")
|
||||
|
||||
actual_material_cost = Decimal("0")
|
||||
movement_count = 0
|
||||
for item in plan_items:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.material_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.where(Inventory.quantity >= item.required_quantity)
|
||||
.values(quantity=Inventory.quantity - item.required_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + int(item.required_quantity)
|
||||
total_amount = item.required_quantity * item.unit_cost
|
||||
actual_material_cost += total_amount
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.material_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="issue_to_production",
|
||||
quantity=item.required_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=production_no,
|
||||
unit_price=item.unit_cost,
|
||||
total_amount=Decimal(str(total_amount)),
|
||||
remark=payload.remark or f"销售单{order.order_no}按单生产领料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
movement_count += 1
|
||||
|
||||
order.production_no = production_no
|
||||
order.production_status = "material_issued"
|
||||
order.planned_material_cost = planned_material_cost
|
||||
order.actual_material_cost = actual_material_cost
|
||||
if order.status == "draft":
|
||||
order.status = "manufacturing"
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
cost_deviation = actual_material_cost - planned_material_cost
|
||||
cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0")
|
||||
return SalesOrderIssueResponse(
|
||||
sales_order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
production_no=production_no,
|
||||
movement_count=movement_count,
|
||||
planned_material_cost=planned_material_cost,
|
||||
actual_material_cost=actual_material_cost,
|
||||
cost_deviation=cost_deviation,
|
||||
cost_deviation_rate=cost_deviation_rate,
|
||||
production_status=order.production_status,
|
||||
)
|
||||
return await sales_order_service.issue_materials(db_session, order_id, payload, current_user)
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
"""
|
||||
库存变动路由模块
|
||||
|
||||
提供库存变动的管理功能,包括:
|
||||
- 创建库存变动记录(入库、出库、调整)
|
||||
- 库存变动历史查询(支持分页、产品筛选、变动类型筛选)
|
||||
- 自动更新库存数量
|
||||
- 库存不足校验(出库时)
|
||||
"""库存变动路由层 - 薄路由
|
||||
|
||||
业务逻辑下沉至 inventory.services.stock_movement_service,路由只做参数校验与响应组装。
|
||||
路由前缀: /api/stock-movements
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from shared.models.database import User
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
from ..services.stock_movement_service import stock_movement_service
|
||||
|
||||
router = APIRouter(prefix="/stock-movements", tags=["库存变动"])
|
||||
|
||||
INBOUND_TYPES = {
|
||||
"in", "purchase_in", "return_from_production", "outsource_return", "finish_in"
|
||||
}
|
||||
OUTBOUND_TYPES = {
|
||||
"out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out"
|
||||
}
|
||||
ADJUST_TYPES = {"adjust"}
|
||||
SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES
|
||||
|
||||
|
||||
@router.post("", response_model=StockMovementResponse, status_code=201)
|
||||
async def create_stock_movement(
|
||||
@@ -38,130 +22,7 @@ async def create_stock_movement(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的变动类型")
|
||||
if movement_data.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="数量必须大于0")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse)
|
||||
.where(Warehouse.id == movement_data.warehouse_id)
|
||||
.where(Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
product_result = await db_session.execute(
|
||||
select(Product)
|
||||
.where(Product.id == movement_data.product_id)
|
||||
.where(Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
sku_candidate = movement_data.product_sku or str(movement_data.product_id)
|
||||
product_by_sku_result = await db_session.execute(
|
||||
select(Product)
|
||||
.where(Product.sku == sku_candidate)
|
||||
.where(Product.is_active == True)
|
||||
)
|
||||
product = product_by_sku_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="库存仅管理物料,该条目不是物料")
|
||||
|
||||
resolved_product_id = product.id
|
||||
|
||||
if movement_data.movement_type in INBOUND_TYPES:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.values(quantity=Inventory.quantity + movement_data.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
quantity=movement_data.quantity,
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = movement_data.quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - movement_data.quantity
|
||||
|
||||
elif movement_data.movement_type in OUTBOUND_TYPES:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.where(Inventory.quantity >= movement_data.quantity)
|
||||
.values(quantity=Inventory.quantity - movement_data.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail="库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + movement_data.quantity
|
||||
|
||||
else:
|
||||
result = await db_session.execute(
|
||||
select(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.with_for_update()
|
||||
)
|
||||
inventory = result.scalar_one_or_none()
|
||||
if not inventory:
|
||||
inventory = Inventory(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
quantity=0,
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
else:
|
||||
before_qty = int(inventory.quantity)
|
||||
inventory.quantity = movement_data.quantity
|
||||
after_qty = movement_data.quantity
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
movement_type=movement_data.movement_type,
|
||||
quantity=movement_data.quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_no=generate_order_no("SM"),
|
||||
unit_price=movement_data.unit_price,
|
||||
total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None,
|
||||
remark=movement_data.remark,
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
await db_session.commit()
|
||||
|
||||
return StockMovementResponse(
|
||||
id=movement.id,
|
||||
product_id=product.id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
movement_type=movement.movement_type,
|
||||
quantity=movement.quantity,
|
||||
before_quantity=movement.before_quantity,
|
||||
after_quantity=movement.after_quantity,
|
||||
reference_no=movement.reference_no,
|
||||
remark=movement.remark,
|
||||
created_at=movement.created_at
|
||||
)
|
||||
return await stock_movement_service.create_movement(db_session, movement_data, current_user)
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[StockMovementResponse])
|
||||
@@ -173,37 +34,4 @@ async def list_stock_movements(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
base_query = (
|
||||
select(StockMovement, Product)
|
||||
.join(Product, StockMovement.product_id == Product.id)
|
||||
.order_by(StockMovement.created_at.desc())
|
||||
)
|
||||
|
||||
if product_id:
|
||||
base_query = base_query.where(StockMovement.product_id == product_id)
|
||||
if movement_type:
|
||||
base_query = base_query.where(StockMovement.movement_type == movement_type)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
movements = []
|
||||
for movement, product in result.all():
|
||||
movements.append(StockMovementResponse(
|
||||
id=movement.id,
|
||||
product_id=product.id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
movement_type=movement.movement_type,
|
||||
quantity=movement.quantity,
|
||||
before_quantity=movement.before_quantity,
|
||||
after_quantity=movement.after_quantity,
|
||||
reference_no=movement.reference_no,
|
||||
remark=movement.remark,
|
||||
created_at=movement.created_at
|
||||
))
|
||||
|
||||
return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit)
|
||||
return await stock_movement_service.list_movements(db_session, product_id, movement_type, skip, limit)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
库存管理工具函数模块
|
||||
|
||||
提供进销存系统通用的工具函数,包括:
|
||||
- 订单编号生成器(采购订单、销售订单、库存变动等)
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
import secrets
|
||||
|
||||
|
||||
def generate_order_no(prefix: str) -> str:
|
||||
"""生成订单编号
|
||||
|
||||
Args:
|
||||
prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动)
|
||||
|
||||
Returns:
|
||||
格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号
|
||||
"""
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
random_str = secrets.token_hex(4).upper()
|
||||
return f"{prefix}{date_str}{random_str}"
|
||||
@@ -30,7 +30,9 @@ from .sales_order_schemas import (
|
||||
SalesOrderProductionPlanResponse,
|
||||
SalesOrderIssueRequest,
|
||||
SalesOrderIssueResponse,
|
||||
SalesOrderStatusUpdate
|
||||
SalesOrderStatusUpdate,
|
||||
MaterialConsumptionItem,
|
||||
MaterialConsumptionRequest
|
||||
)
|
||||
from .finance_schemas import (
|
||||
FinanceAllocationCreate,
|
||||
|
||||
@@ -102,3 +102,13 @@ class SalesOrderIssueResponse(BaseModel):
|
||||
|
||||
class SalesOrderStatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class MaterialConsumptionItem(BaseModel):
|
||||
material_id: int
|
||||
quantity: float = Field(gt=0)
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class MaterialConsumptionRequest(BaseModel):
|
||||
items: List[MaterialConsumptionItem] = Field(min_length=1)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""库存查询业务服务层
|
||||
|
||||
将原 inventory_routes 中的业务编排(库存列表/建/改/删)下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
|
||||
|
||||
def _build_inventory_response(inv: Inventory, product: Product, warehouse: Warehouse) -> InventoryResponse:
|
||||
return InventoryResponse(
|
||||
id=inv.id,
|
||||
product_id=inv.product_id,
|
||||
product_name=product.name,
|
||||
product_sku=product.sku,
|
||||
warehouse_id=inv.warehouse_id,
|
||||
warehouse_name=warehouse.name,
|
||||
quantity=inv.quantity,
|
||||
locked_quantity=inv.locked_quantity,
|
||||
available_quantity=inv.available_quantity
|
||||
)
|
||||
|
||||
|
||||
class InventoryService:
|
||||
"""库存查询业务服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_inventory(
|
||||
db_session: AsyncSession,
|
||||
warehouse_id: Optional[int],
|
||||
product_id: Optional[int],
|
||||
low_stock: bool,
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> PaginatedResponse:
|
||||
base_query = (
|
||||
select(Inventory, Product, Warehouse)
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
||||
.where(Product.is_active == True)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Warehouse.is_active == True)
|
||||
)
|
||||
|
||||
if warehouse_id:
|
||||
base_query = base_query.where(Inventory.warehouse_id == warehouse_id)
|
||||
if product_id:
|
||||
base_query = base_query.where(Inventory.product_id == product_id)
|
||||
if low_stock:
|
||||
base_query = base_query.where(Inventory.quantity <= Product.min_stock)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
inventory_list = [_build_inventory_response(inv, product, warehouse) for inv, product, warehouse in result.all()]
|
||||
return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
async def create_inventory(
|
||||
db_session: AsyncSession,
|
||||
payload: InventoryCreate,
|
||||
current_user: User,
|
||||
) -> InventoryResponse:
|
||||
if payload.quantity < 0 or payload.locked_quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不能为负数")
|
||||
if payload.locked_quantity > payload.quantity:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
|
||||
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == payload.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 != "material":
|
||||
raise HTTPException(status_code=400, detail="库存仅支持物料")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
exists_result = await db_session.execute(
|
||||
select(Inventory).where(
|
||||
Inventory.product_id == payload.product_id,
|
||||
Inventory.warehouse_id == payload.warehouse_id
|
||||
)
|
||||
)
|
||||
if exists_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
|
||||
|
||||
inventory = Inventory(
|
||||
product_id=payload.product_id,
|
||||
warehouse_id=payload.warehouse_id,
|
||||
quantity=payload.quantity,
|
||||
locked_quantity=payload.locked_quantity,
|
||||
batch_number=payload.batch_number,
|
||||
location=payload.location
|
||||
)
|
||||
db_session.add(inventory)
|
||||
try:
|
||||
await db_session.commit()
|
||||
except IntegrityError:
|
||||
# 并发创建命中 (product_id, warehouse_id) 唯一约束
|
||||
await db_session.rollback()
|
||||
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
|
||||
await db_session.refresh(inventory)
|
||||
|
||||
return _build_inventory_response(inventory, product, warehouse)
|
||||
|
||||
@staticmethod
|
||||
async def update_inventory(
|
||||
db_session: AsyncSession,
|
||||
inventory_id: int,
|
||||
payload: InventoryUpdate,
|
||||
current_user: User,
|
||||
) -> InventoryResponse:
|
||||
result = await db_session.execute(
|
||||
select(Inventory, Product, Warehouse)
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
||||
.where(Inventory.id == inventory_id)
|
||||
.where(Product.item_type == "material")
|
||||
.with_for_update(of=Inventory)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="库存记录不存在")
|
||||
inventory, product, warehouse = row
|
||||
|
||||
if payload.quantity is not None:
|
||||
if payload.quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不能为负数")
|
||||
inventory.quantity = payload.quantity
|
||||
if payload.locked_quantity is not None:
|
||||
if payload.locked_quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能为负数")
|
||||
inventory.locked_quantity = payload.locked_quantity
|
||||
if inventory.locked_quantity > inventory.quantity:
|
||||
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
|
||||
|
||||
if payload.batch_number is not None:
|
||||
inventory.batch_number = payload.batch_number
|
||||
if payload.location is not None:
|
||||
inventory.location = payload.location
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(inventory)
|
||||
return _build_inventory_response(inventory, product, warehouse)
|
||||
|
||||
@staticmethod
|
||||
async def delete_inventory(
|
||||
db_session: AsyncSession,
|
||||
inventory_id: int,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id))
|
||||
inventory = result.scalar_one_or_none()
|
||||
if not inventory:
|
||||
raise HTTPException(status_code=404, detail="库存记录不存在")
|
||||
if inventory.quantity > 0:
|
||||
raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录")
|
||||
await db_session.delete(inventory)
|
||||
await db_session.commit()
|
||||
return {"message": "库存记录已删除"}
|
||||
|
||||
|
||||
inventory_service = InventoryService()
|
||||
@@ -0,0 +1,454 @@
|
||||
"""采购订单业务服务层
|
||||
|
||||
将原 purchase_order_routes 中的业务编排(建单/改单/删单、收货入库、状态流转)下沉到此,
|
||||
路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Supplier,
|
||||
Product,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
PurchaseOrderDetailResponse,
|
||||
PurchaseOrderItemResponse,
|
||||
PurchaseOrderReceiveRequest,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from ..utils import generate_order_no
|
||||
|
||||
|
||||
def _compute_receipt_status(order: PurchaseOrder) -> str:
|
||||
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
|
||||
if order.status == "cancelled":
|
||||
return "cancelled"
|
||||
if order.status in ("received", "paid"):
|
||||
return "received"
|
||||
if order.status == "partial_received":
|
||||
return "partial_received"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _compute_payment_status(order: PurchaseOrder) -> str:
|
||||
"""付款状态:未付款 / 已付款"""
|
||||
if order.status == "paid" or order.paid_date:
|
||||
return "paid"
|
||||
return "unpaid"
|
||||
|
||||
|
||||
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
|
||||
return PurchaseOrderResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_name=supplier_name,
|
||||
order_date=order.order_date,
|
||||
expected_date=order.expected_date,
|
||||
status=order.status,
|
||||
receipt_status=_compute_receipt_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
total_amount=order.total_amount,
|
||||
paid_amount=order.paid_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
received_date=order.received_date,
|
||||
paid_date=order.paid_date
|
||||
)
|
||||
|
||||
|
||||
async def _get_order_with_supplier(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
) -> Tuple[PurchaseOrder, Supplier]:
|
||||
result = await db_session.execute(
|
||||
select(PurchaseOrder, Supplier)
|
||||
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
||||
.where(PurchaseOrder.id == order_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="采购订单不存在")
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def _build_purchase_order_detail(
|
||||
db_session: AsyncSession,
|
||||
order: PurchaseOrder,
|
||||
supplier_name: str,
|
||||
) -> PurchaseOrderDetailResponse:
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem, Product)
|
||||
.join(Product, PurchaseOrderItem.product_id == Product.id)
|
||||
.where(PurchaseOrderItem.order_id == order.id)
|
||||
.order_by(PurchaseOrderItem.id.asc())
|
||||
)
|
||||
item_rows = item_result.all()
|
||||
|
||||
return PurchaseOrderDetailResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
supplier_id=order.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
order_date=order.order_date,
|
||||
expected_date=order.expected_date,
|
||||
status=order.status,
|
||||
receipt_status=_compute_receipt_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
total_amount=order.total_amount,
|
||||
paid_amount=order.paid_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
received_date=order.received_date,
|
||||
paid_date=order.paid_date,
|
||||
items=[
|
||||
PurchaseOrderItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
quantity=item.quantity,
|
||||
received_quantity=item.received_quantity,
|
||||
unit_price=item.unit_price,
|
||||
amount=item.amount,
|
||||
remark=item.remark
|
||||
) for item, product in item_rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_receive_warehouse(
|
||||
db_session: AsyncSession,
|
||||
warehouse_id: Optional[int],
|
||||
) -> Warehouse:
|
||||
if warehouse_id:
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
return warehouse
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
|
||||
)
|
||||
warehouse = result.scalars().first()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=400, detail="未配置可用仓库")
|
||||
return warehouse
|
||||
|
||||
|
||||
async def _apply_order_items(
|
||||
db_session: AsyncSession,
|
||||
order: PurchaseOrder,
|
||||
order_data: PurchaseOrderCreate,
|
||||
) -> Decimal:
|
||||
total_amount = Decimal("0")
|
||||
for item_data in order_data.items:
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
|
||||
if product.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
|
||||
|
||||
# 优先使用前端传入的单价,否则使用物料成本价
|
||||
if item_data.unit_price and item_data.unit_price > 0:
|
||||
unit_price = float(item_data.unit_price)
|
||||
else:
|
||||
unit_price = product.cost_price or 0
|
||||
if unit_price <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
|
||||
|
||||
try:
|
||||
item = PurchaseOrderItem(
|
||||
order_id=order.id,
|
||||
product_id=item_data.product_id,
|
||||
quantity=int(item_data.quantity),
|
||||
unit_price=Decimal(str(unit_price)),
|
||||
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
|
||||
remark=item_data.remark or None
|
||||
)
|
||||
db_session.add(item)
|
||||
total_amount += item.amount
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
|
||||
return total_amount
|
||||
|
||||
|
||||
class PurchaseOrderService:
|
||||
"""采购订单业务服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_orders(
|
||||
db_session: AsyncSession,
|
||||
status: Optional[str],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> PaginatedResponse:
|
||||
base_query = (
|
||||
select(PurchaseOrder, Supplier)
|
||||
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
|
||||
.order_by(PurchaseOrder.created_at.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
base_query = base_query.where(PurchaseOrder.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
orders = []
|
||||
for order, supplier in result.all():
|
||||
orders.append(_build_purchase_order_response(order, supplier.name))
|
||||
|
||||
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
async def create_order(
|
||||
db_session: AsyncSession,
|
||||
order_data: PurchaseOrderCreate,
|
||||
current_user: User,
|
||||
) -> PurchaseOrderResponse:
|
||||
order = PurchaseOrder(
|
||||
order_no=generate_order_no("PO"),
|
||||
supplier_id=order_data.supplier_id,
|
||||
expected_date=order_data.expected_date,
|
||||
remark=order_data.remark,
|
||||
operator_id=current_user.id,
|
||||
status="pending"
|
||||
)
|
||||
db_session.add(order)
|
||||
await db_session.flush()
|
||||
|
||||
try:
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||
|
||||
return _build_purchase_order_response(order, supplier.name)
|
||||
|
||||
@staticmethod
|
||||
async def get_detail(db_session: AsyncSession, order_id: int) -> PurchaseOrderDetailResponse:
|
||||
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
||||
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
||||
|
||||
@staticmethod
|
||||
async def update_order(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
order_data: PurchaseOrderCreate,
|
||||
current_user: User,
|
||||
) -> PurchaseOrderResponse:
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.paid_amount and order.paid_amount > 0:
|
||||
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
|
||||
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
existing_items = item_result.scalars().all()
|
||||
if any((item.received_quantity or 0) > 0 for item in existing_items):
|
||||
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
|
||||
|
||||
for item in existing_items:
|
||||
await db_session.delete(item)
|
||||
|
||||
try:
|
||||
order.supplier_id = order_data.supplier_id
|
||||
order.expected_date = order_data.expected_date
|
||||
order.remark = order_data.remark
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
order.status = "pending"
|
||||
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
||||
|
||||
@staticmethod
|
||||
async def delete_order(db_session: AsyncSession, order_id: int, current_user: User) -> dict:
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.paid_amount and order.paid_amount > 0:
|
||||
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
|
||||
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
|
||||
await db_session.delete(order)
|
||||
await db_session.commit()
|
||||
return {"message": "采购订单已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def update_status(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
status: dict,
|
||||
current_user: User,
|
||||
) -> PurchaseOrderResponse:
|
||||
order, _ = await _get_order_with_supplier(db_session, order_id)
|
||||
|
||||
new_status = status.get("status")
|
||||
if not new_status:
|
||||
raise HTTPException(status_code=400, detail="状态不能为空")
|
||||
|
||||
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
|
||||
if new_status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
|
||||
|
||||
# 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩
|
||||
if new_status in ("partial_received", "received"):
|
||||
raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置")
|
||||
|
||||
# 状态转换逻辑
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
|
||||
if order.status == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
|
||||
if order.status == "received" and new_status not in ("paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
|
||||
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
|
||||
if new_status == "cancelled" and order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
|
||||
|
||||
# 更新状态和对应时间
|
||||
order.status = new_status
|
||||
if new_status == "received":
|
||||
order.received_date = func.now()
|
||||
elif new_status == "paid":
|
||||
order.paid_date = func.now()
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
|
||||
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||
supplier = supplier_result.scalar_one_or_none()
|
||||
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
|
||||
|
||||
@staticmethod
|
||||
async def receive_order(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
payload: PurchaseOrderReceiveRequest,
|
||||
current_user: User,
|
||||
) -> PurchaseOrderDetailResponse:
|
||||
order, supplier = await _get_order_with_supplier(db_session, order_id)
|
||||
if order.status in ("cancelled", "paid"):
|
||||
raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货")
|
||||
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
|
||||
|
||||
item_result = await db_session.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
|
||||
)
|
||||
item_map = {item.id: item for item in item_result.scalars().all()}
|
||||
if not item_map:
|
||||
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
|
||||
|
||||
if not payload.items:
|
||||
raise HTTPException(status_code=400, detail="请提供本次入库明细")
|
||||
|
||||
for receive_item in payload.items:
|
||||
item = item_map.get(receive_item.item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
|
||||
if receive_item.receive_quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="入库数量必须大于0")
|
||||
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
|
||||
if receive_item.receive_quantity > remaining_qty:
|
||||
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
|
||||
|
||||
for receive_item in payload.items:
|
||||
item = item_map[receive_item.item_id]
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item.product_id)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
|
||||
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.product_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=item.product_id,
|
||||
warehouse_id=warehouse.id,
|
||||
quantity=receive_item.receive_quantity,
|
||||
locked_quantity=0
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = receive_item.receive_quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - receive_item.receive_quantity
|
||||
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.product_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="purchase_in",
|
||||
quantity=receive_item.receive_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="purchase_order",
|
||||
reference_id=order.id,
|
||||
reference_no=order.order_no,
|
||||
unit_price=item.unit_price,
|
||||
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
|
||||
remark=payload.remark or f"采购单{order.order_no}到货入库",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
|
||||
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
|
||||
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
|
||||
if all_received:
|
||||
order.status = "received"
|
||||
order.received_date = func.now()
|
||||
elif any_received:
|
||||
order.status = "partial_received"
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
return await _build_purchase_order_detail(db_session, order, supplier.name)
|
||||
|
||||
|
||||
purchase_order_service = PurchaseOrderService()
|
||||
@@ -0,0 +1,744 @@
|
||||
"""销售订单业务服务层
|
||||
|
||||
将原 sales_order_routes 中的业务编排(建单/改单/删单、BOM 物料计划、自动发料/回补、
|
||||
物料消耗、领料、状态流转)下沉到此,路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
from typing import Optional, List, Tuple
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
SalesOrderDetailResponse,
|
||||
SalesOrderItemResponse,
|
||||
SalesOrderProductionPlanResponse,
|
||||
ProductionMaterialPlanItemResponse,
|
||||
SalesOrderIssueRequest,
|
||||
SalesOrderIssueResponse,
|
||||
SalesOrderStatusUpdate,
|
||||
MaterialConsumptionRequest,
|
||||
PaginatedResponse,
|
||||
)
|
||||
from ..utils import generate_order_no
|
||||
|
||||
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
|
||||
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
|
||||
|
||||
|
||||
def _compute_delivery_status(order: SalesOrder) -> str:
|
||||
"""物流状态:制造中 / 已交付 / 已作废"""
|
||||
if order.status == "cancelled":
|
||||
return "cancelled"
|
||||
if order.status in ("delivered", "paid") or order.actual_delivery_date:
|
||||
return "delivered"
|
||||
return "manufacturing"
|
||||
|
||||
|
||||
def _compute_payment_status(order: SalesOrder) -> str:
|
||||
"""收款状态:未收款 / 已收款"""
|
||||
if order.status == "paid" or order.actual_payment_date:
|
||||
return "paid"
|
||||
return "unpaid"
|
||||
|
||||
|
||||
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
|
||||
return SalesOrderResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_name=customer_name,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
manufacturing_date=order.manufacturing_date,
|
||||
actual_delivery_date=order.actual_delivery_date,
|
||||
actual_payment_date=order.actual_payment_date,
|
||||
status=order.status,
|
||||
delivery_status=_compute_delivery_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
production_status=order.production_status or "not_started",
|
||||
production_no=order.production_no,
|
||||
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
|
||||
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
|
||||
total_amount=Decimal(str(order.total_amount or 0)),
|
||||
received_amount=Decimal(str(order.received_amount or 0)),
|
||||
remark=order.remark,
|
||||
created_at=order.created_at
|
||||
)
|
||||
|
||||
|
||||
async def _build_sales_order_detail_response(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
customer_name: str
|
||||
) -> SalesOrderDetailResponse:
|
||||
items_result = await db_session.execute(
|
||||
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc())
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
return SalesOrderDetailResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_id=order.customer_id,
|
||||
customer_name=customer_name,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
manufacturing_date=order.manufacturing_date,
|
||||
actual_delivery_date=order.actual_delivery_date,
|
||||
actual_payment_date=order.actual_payment_date,
|
||||
status=order.status,
|
||||
delivery_status=_compute_delivery_status(order),
|
||||
payment_status=_compute_payment_status(order),
|
||||
production_status=order.production_status or "not_started",
|
||||
production_no=order.production_no,
|
||||
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
|
||||
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
|
||||
total_amount=Decimal(str(order.total_amount or 0)),
|
||||
received_amount=Decimal(str(order.received_amount or 0)),
|
||||
remark=order.remark,
|
||||
created_at=order.created_at,
|
||||
items=[
|
||||
SalesOrderItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
quantity=item.quantity,
|
||||
delivered_quantity=item.delivered_quantity,
|
||||
unit_price=Decimal(str(item.unit_price or 0)),
|
||||
amount=Decimal(str(item.amount or 0)),
|
||||
remark=item.remark
|
||||
) for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _get_sales_order_with_customer(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
) -> Tuple[SalesOrder, Customer]:
|
||||
result = await db_session.execute(
|
||||
select(SalesOrder, Customer)
|
||||
.join(Customer, SalesOrder.customer_id == Customer.id)
|
||||
.where(SalesOrder.id == order_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="销售订单不存在")
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> Tuple[List[ProductionMaterialPlanItemResponse], Decimal]:
|
||||
item_result = await db_session.execute(
|
||||
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
|
||||
)
|
||||
order_items = item_result.scalars().all()
|
||||
if not order_items:
|
||||
return [], 0
|
||||
|
||||
finished_ids = list({int(i.product_id) for i in order_items})
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(finished_ids))
|
||||
.where(Product.is_active == True)
|
||||
.where(Product.item_type == "material")
|
||||
)
|
||||
bom_rows = bom_result.all()
|
||||
if not bom_rows:
|
||||
return [], 0
|
||||
|
||||
bom_by_finished_id = {}
|
||||
for bom, material in bom_rows:
|
||||
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
|
||||
|
||||
required_qty_map = {}
|
||||
for order_item in order_items:
|
||||
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
|
||||
for bom, material in bom_items:
|
||||
qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0)))
|
||||
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")})
|
||||
entry["required_qty"] += qty
|
||||
|
||||
if not required_qty_map:
|
||||
return [], Decimal("0")
|
||||
|
||||
material_ids = list(required_qty_map.keys())
|
||||
stock_result = await db_session.execute(
|
||||
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
|
||||
.where(Inventory.product_id.in_(material_ids))
|
||||
.group_by(Inventory.product_id)
|
||||
)
|
||||
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
|
||||
|
||||
plan_items = []
|
||||
planned_material_cost = Decimal("0")
|
||||
for material_id, entry in required_qty_map.items():
|
||||
material = entry["material"]
|
||||
required_qty = int(ceil(entry["required_qty"]))
|
||||
available_qty = stock_map.get(material_id, Decimal("0"))
|
||||
shortage_qty = max(required_qty - int(available_qty), 0)
|
||||
unit_cost = Decimal(str(material.cost_price or 0))
|
||||
required_cost = Decimal(str(required_qty)) * unit_cost
|
||||
planned_material_cost += required_cost
|
||||
plan_items.append(
|
||||
ProductionMaterialPlanItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
required_quantity=Decimal(str(required_qty)),
|
||||
available_quantity=available_qty,
|
||||
shortage_quantity=Decimal(str(shortage_qty)),
|
||||
unit_cost=unit_cost,
|
||||
required_cost=required_cost,
|
||||
)
|
||||
)
|
||||
|
||||
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
|
||||
return plan_items, planned_material_cost
|
||||
|
||||
|
||||
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
|
||||
)
|
||||
warehouse = warehouse_result.scalars().first()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
|
||||
return warehouse
|
||||
|
||||
|
||||
async def _issue_materials_for_order_creation(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
current_user: User
|
||||
) -> Tuple[int, Decimal, Decimal]:
|
||||
warehouse = await _get_default_warehouse(db_session)
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
if not plan_items:
|
||||
order.production_no = order.production_no or generate_order_no("WO")
|
||||
order.production_status = "bom_missing"
|
||||
order.planned_material_cost = 0
|
||||
order.actual_material_cost = 0
|
||||
order.status = "manufacturing"
|
||||
return 0, 0, 0
|
||||
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
|
||||
if shortage_items:
|
||||
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
|
||||
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
|
||||
|
||||
production_no = order.production_no or generate_order_no("WO")
|
||||
actual_material_cost = Decimal("0")
|
||||
movement_count = 0
|
||||
|
||||
for item in plan_items:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.material_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.where(Inventory.quantity >= item.required_quantity)
|
||||
.values(quantity=Inventory.quantity - item.required_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + int(item.required_quantity)
|
||||
total_amount = item.required_quantity * item.unit_cost
|
||||
actual_material_cost += total_amount
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.material_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="issue_to_production",
|
||||
quantity=item.required_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=production_no,
|
||||
unit_price=item.unit_cost,
|
||||
total_amount=Decimal(str(total_amount)),
|
||||
remark=f"销售单{order.order_no}创建时自动扣减物料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
movement_count += 1
|
||||
|
||||
order.production_no = production_no
|
||||
order.production_status = "material_issued"
|
||||
order.planned_material_cost = planned_material_cost
|
||||
order.actual_material_cost = actual_material_cost
|
||||
order.status = "manufacturing"
|
||||
|
||||
return movement_count, planned_material_cost, actual_material_cost
|
||||
|
||||
|
||||
async def _rollback_issued_materials(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
current_user: User
|
||||
):
|
||||
movement_result = await db_session.execute(
|
||||
select(StockMovement)
|
||||
.where(StockMovement.reference_type == "sales_order")
|
||||
.where(StockMovement.reference_id == order.id)
|
||||
.where(StockMovement.movement_type == "issue_to_production")
|
||||
.order_by(StockMovement.id.asc())
|
||||
)
|
||||
movements = movement_result.scalars().all()
|
||||
if not movements:
|
||||
return
|
||||
|
||||
for movement in movements:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == movement.product_id)
|
||||
.where(Inventory.warehouse_id == movement.warehouse_id)
|
||||
.values(quantity=Inventory.quantity + movement.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=movement.product_id,
|
||||
warehouse_id=movement.warehouse_id,
|
||||
quantity=movement.quantity,
|
||||
locked_quantity=0
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = movement.quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - movement.quantity
|
||||
|
||||
revert_movement = StockMovement(
|
||||
product_id=movement.product_id,
|
||||
warehouse_id=movement.warehouse_id,
|
||||
movement_type="return_from_production",
|
||||
quantity=movement.quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=order.production_no or order.order_no,
|
||||
unit_price=movement.unit_price,
|
||||
total_amount=movement.total_amount,
|
||||
remark=f"销售单{order.order_no}变更/删除,自动回补物料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(revert_movement)
|
||||
|
||||
|
||||
async def _apply_order_items(
|
||||
db_session: AsyncSession,
|
||||
order: SalesOrder,
|
||||
order_data: SalesOrderCreate
|
||||
) -> Decimal:
|
||||
total_amount = Decimal("0")
|
||||
for item_data in order_data.items:
|
||||
product = None
|
||||
if item_data.product_id is not None:
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}")
|
||||
else:
|
||||
if not item_data.product_sku or not item_data.product_name:
|
||||
raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称")
|
||||
by_sku_result = await db_session.execute(
|
||||
select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True)
|
||||
)
|
||||
product = by_sku_result.scalar_one_or_none()
|
||||
if not product:
|
||||
product = Product(
|
||||
sku=item_data.product_sku,
|
||||
name=item_data.product_name,
|
||||
category=item_data.product_category,
|
||||
unit=item_data.product_unit or "件",
|
||||
item_type="finished",
|
||||
cost_price=0,
|
||||
sale_price=item_data.unit_price or 0,
|
||||
min_stock=0,
|
||||
max_stock=0
|
||||
)
|
||||
db_session.add(product)
|
||||
await db_session.flush()
|
||||
if product.item_type != "finished":
|
||||
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
|
||||
item = SalesOrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=item_data.quantity,
|
||||
unit_price=item_data.unit_price,
|
||||
amount=item_data.quantity * item_data.unit_price,
|
||||
remark=item_data.remark
|
||||
)
|
||||
db_session.add(item)
|
||||
total_amount += item.amount
|
||||
return total_amount
|
||||
|
||||
|
||||
class SalesOrderService:
|
||||
"""销售订单业务服务"""
|
||||
|
||||
@staticmethod
|
||||
async def list_orders(
|
||||
db_session: AsyncSession,
|
||||
status: Optional[str],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> PaginatedResponse:
|
||||
base_query = (
|
||||
select(SalesOrder, Customer)
|
||||
.join(Customer, SalesOrder.customer_id == Customer.id)
|
||||
.order_by(SalesOrder.created_at.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
base_query = base_query.where(SalesOrder.status == status)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
orders = []
|
||||
for order, customer in result.all():
|
||||
orders.append(_build_sales_order_response(order, customer.name))
|
||||
|
||||
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
async def create_order(
|
||||
db_session: AsyncSession,
|
||||
order_data: SalesOrderCreate,
|
||||
current_user: User,
|
||||
) -> SalesOrderResponse:
|
||||
now = datetime.now()
|
||||
order = SalesOrder(
|
||||
order_no=generate_order_no("SO"),
|
||||
customer_id=order_data.customer_id,
|
||||
order_date=now,
|
||||
delivery_date=order_data.delivery_date,
|
||||
manufacturing_date=now.date(),
|
||||
created_at=now,
|
||||
remark=order_data.remark,
|
||||
operator_id=current_user.id,
|
||||
status="manufacturing"
|
||||
)
|
||||
db_session.add(order)
|
||||
await db_session.flush()
|
||||
|
||||
try:
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await _issue_materials_for_order_creation(db_session, order, current_user)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||
customer = customer.scalar_one()
|
||||
|
||||
return _build_sales_order_response(order, customer.name)
|
||||
|
||||
@staticmethod
|
||||
async def get_detail(db_session: AsyncSession, order_id: int) -> SalesOrderDetailResponse:
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
return await _build_sales_order_detail_response(db_session, order, customer.name)
|
||||
|
||||
@staticmethod
|
||||
async def update_order(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
order_data: SalesOrderCreate,
|
||||
current_user: User,
|
||||
) -> SalesOrderResponse:
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
|
||||
try:
|
||||
await _rollback_issued_materials(db_session, order, current_user)
|
||||
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
|
||||
|
||||
order.customer_id = order_data.customer_id
|
||||
order.delivery_date = order_data.delivery_date
|
||||
order.remark = order_data.remark
|
||||
order.production_status = "not_started"
|
||||
order.production_no = None
|
||||
order.planned_material_cost = 0
|
||||
order.actual_material_cost = 0
|
||||
order.status = "manufacturing"
|
||||
|
||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||
await _issue_materials_for_order_creation(db_session, order, current_user)
|
||||
await db_session.commit()
|
||||
except (HTTPException, Exception):
|
||||
await db_session.rollback()
|
||||
raise
|
||||
await db_session.refresh(order)
|
||||
|
||||
customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||
updated_customer = customer_result.scalar_one_or_none()
|
||||
customer_name = updated_customer.name if updated_customer else "未知客户"
|
||||
return _build_sales_order_response(order, customer_name)
|
||||
|
||||
@staticmethod
|
||||
async def update_status(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
payload: SalesOrderStatusUpdate,
|
||||
current_user: User,
|
||||
) -> SalesOrderResponse:
|
||||
if payload.status not in VALID_ORDER_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
|
||||
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
|
||||
# 状态转换守卫
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改状态")
|
||||
if order.status == "cancelled":
|
||||
raise HTTPException(status_code=400, detail="已作废的销售订单禁止修改状态")
|
||||
if order.status == "delivered" and payload.status not in ("paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款或已作废")
|
||||
if payload.status == "cancelled" and order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的订单不能作废,请联系管理员")
|
||||
|
||||
# 根据状态更新相应的日期字段
|
||||
if payload.status == "delivered" and not order.actual_delivery_date:
|
||||
order.actual_delivery_date = datetime.now()
|
||||
elif payload.status == "paid" and not order.actual_payment_date:
|
||||
order.actual_payment_date = datetime.now()
|
||||
elif payload.status == "cancelled" and not order.actual_delivery_date:
|
||||
order.actual_delivery_date = datetime.now()
|
||||
|
||||
order.status = payload.status
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
return _build_sales_order_response(order, customer.name)
|
||||
|
||||
@staticmethod
|
||||
async def delete_order(db_session: AsyncSession, order_id: int, current_user: User) -> dict:
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "delivered":
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
|
||||
if order.status == "paid":
|
||||
raise HTTPException(status_code=400, detail="已收款的销售订单禁止删除,请先作废相关收款单")
|
||||
if (order.received_amount or 0) > 0:
|
||||
raise HTTPException(status_code=400, detail="该销售订单已存在收款记录(received_amount>0),禁止删除,请先作废相关收款单")
|
||||
await _rollback_issued_materials(db_session, order, current_user)
|
||||
await db_session.delete(order)
|
||||
await db_session.commit()
|
||||
return {"message": "销售订单已删除"}
|
||||
|
||||
@staticmethod
|
||||
async def consume_materials(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
request: MaterialConsumptionRequest,
|
||||
current_user: User,
|
||||
) -> dict:
|
||||
"""记录销售订单的物料消耗(在已发料基础上记录额外消耗,如报废/超耗)"""
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
|
||||
if order.status in ("delivered", "paid", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="当前订单状态不允许记录物料消耗")
|
||||
if order.production_status == "completed":
|
||||
raise HTTPException(status_code=400, detail="该销售单已完成生产,不允许记录物料消耗")
|
||||
|
||||
default_warehouse = await _get_default_warehouse(db_session)
|
||||
|
||||
# 计算总物料成本
|
||||
total_cost = Decimal("0")
|
||||
|
||||
# 处理每个物料消耗项
|
||||
for item in request.items:
|
||||
# 获取物料信息
|
||||
material = await db_session.get(Product, item.material_id)
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
|
||||
if material.item_type != "material":
|
||||
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
|
||||
|
||||
# 统一转 Decimal,避免 Decimal * float 在 Postgres(Numeric) 上抛 TypeError
|
||||
consume_qty = Decimal(str(item.quantity))
|
||||
unit_cost = Decimal(str(material.cost_price or 0))
|
||||
cost = unit_cost * consume_qty
|
||||
total_cost += cost
|
||||
|
||||
# 更新物料库存(原子操作防并发)
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == material.id)
|
||||
.where(Inventory.warehouse_id == default_warehouse.id)
|
||||
.where(Inventory.quantity >= consume_qty)
|
||||
.values(quantity=Inventory.quantity - consume_qty)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
|
||||
after_qty = Decimal(str(after_qty))
|
||||
before_qty = after_qty + consume_qty
|
||||
|
||||
# 记录物料消耗
|
||||
movement = StockMovement(
|
||||
product_id=material.id,
|
||||
warehouse_id=default_warehouse.id,
|
||||
quantity=consume_qty,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
movement_type="consumption",
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
unit_price=unit_cost,
|
||||
total_amount=cost,
|
||||
operator_id=current_user.id,
|
||||
remark=item.remark
|
||||
)
|
||||
db_session.add(movement)
|
||||
|
||||
# 累加实际物料成本(创建时已记录计划发料成本,此处为额外消耗,不应覆盖)
|
||||
order.actual_material_cost = Decimal(str(order.actual_material_cost or 0)) + total_cost
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(order)
|
||||
|
||||
return {
|
||||
"message": "物料消耗记录保存成功",
|
||||
"total_cost": total_cost,
|
||||
"order": _build_sales_order_response(order, customer.name)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_production_plan(db_session: AsyncSession, order_id: int) -> SalesOrderProductionPlanResponse:
|
||||
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||
production_no = order.production_no or generate_order_no("WO")
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
|
||||
return SalesOrderProductionPlanResponse(
|
||||
sales_order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_name=customer.name,
|
||||
production_no=production_no,
|
||||
planned_material_cost=planned_material_cost,
|
||||
items=plan_items,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def issue_materials(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
payload: SalesOrderIssueRequest,
|
||||
current_user: User,
|
||||
) -> SalesOrderIssueResponse:
|
||||
order, _ = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.status == "delivered":
|
||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
|
||||
if order.production_status == "completed":
|
||||
raise HTTPException(status_code=400, detail="该销售单已完成生产")
|
||||
if order.production_status == "material_issued":
|
||||
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
|
||||
if not plan_items:
|
||||
raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料")
|
||||
|
||||
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
|
||||
if shortage_items:
|
||||
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
|
||||
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
|
||||
|
||||
production_no = payload.production_no or order.production_no or generate_order_no("WO")
|
||||
|
||||
actual_material_cost = Decimal("0")
|
||||
movement_count = 0
|
||||
for item in plan_items:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == item.material_id)
|
||||
.where(Inventory.warehouse_id == warehouse.id)
|
||||
.where(Inventory.quantity >= item.required_quantity)
|
||||
.values(quantity=Inventory.quantity - item.required_quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + int(item.required_quantity)
|
||||
total_amount = item.required_quantity * item.unit_cost
|
||||
actual_material_cost += total_amount
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=item.material_id,
|
||||
warehouse_id=warehouse.id,
|
||||
movement_type="issue_to_production",
|
||||
quantity=item.required_quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_type="sales_order",
|
||||
reference_id=order.id,
|
||||
reference_no=production_no,
|
||||
unit_price=item.unit_cost,
|
||||
total_amount=Decimal(str(total_amount)),
|
||||
remark=payload.remark or f"销售单{order.order_no}按单生产领料",
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
movement_count += 1
|
||||
|
||||
order.production_no = production_no
|
||||
order.production_status = "material_issued"
|
||||
order.planned_material_cost = planned_material_cost
|
||||
order.actual_material_cost = actual_material_cost
|
||||
if order.status == "draft":
|
||||
order.status = "manufacturing"
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
cost_deviation = actual_material_cost - planned_material_cost
|
||||
cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0")
|
||||
return SalesOrderIssueResponse(
|
||||
sales_order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
production_no=production_no,
|
||||
movement_count=movement_count,
|
||||
planned_material_cost=planned_material_cost,
|
||||
actual_material_cost=actual_material_cost,
|
||||
cost_deviation=cost_deviation,
|
||||
cost_deviation_rate=cost_deviation_rate,
|
||||
production_status=order.production_status,
|
||||
)
|
||||
|
||||
|
||||
sales_order_service = SalesOrderService()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""库存变动业务服务层
|
||||
|
||||
将原 stock_movement_routes 中的业务编排(入库/出库/调整的库存原子更新与流水写入、
|
||||
历史查询)下沉到此,路由层只做参数校验与响应组装。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
|
||||
INBOUND_TYPES = {
|
||||
"in", "purchase_in", "return_from_production", "outsource_return", "finish_in"
|
||||
}
|
||||
OUTBOUND_TYPES = {
|
||||
"out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out"
|
||||
}
|
||||
ADJUST_TYPES = {"adjust"}
|
||||
SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES
|
||||
|
||||
|
||||
def _build_movement_response(movement: StockMovement, product: Product) -> StockMovementResponse:
|
||||
return StockMovementResponse(
|
||||
id=movement.id,
|
||||
product_id=product.id,
|
||||
product_sku=product.sku,
|
||||
product_name=product.name,
|
||||
movement_type=movement.movement_type,
|
||||
quantity=movement.quantity,
|
||||
before_quantity=movement.before_quantity,
|
||||
after_quantity=movement.after_quantity,
|
||||
reference_no=movement.reference_no,
|
||||
remark=movement.remark,
|
||||
created_at=movement.created_at
|
||||
)
|
||||
|
||||
|
||||
class StockMovementService:
|
||||
"""库存变动业务服务"""
|
||||
|
||||
@staticmethod
|
||||
async def create_movement(
|
||||
db_session: AsyncSession,
|
||||
movement_data: StockMovementCreate,
|
||||
current_user: User,
|
||||
) -> StockMovementResponse:
|
||||
if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="无效的变动类型")
|
||||
if movement_data.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="数量必须大于0")
|
||||
|
||||
warehouse_result = await db_session.execute(
|
||||
select(Warehouse)
|
||||
.where(Warehouse.id == movement_data.warehouse_id)
|
||||
.where(Warehouse.is_active == True)
|
||||
)
|
||||
warehouse = warehouse_result.scalar_one_or_none()
|
||||
if not warehouse:
|
||||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||||
|
||||
product_result = await db_session.execute(
|
||||
select(Product)
|
||||
.where(Product.id == movement_data.product_id)
|
||||
.where(Product.is_active == True)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
sku_candidate = movement_data.product_sku or str(movement_data.product_id)
|
||||
product_by_sku_result = await db_session.execute(
|
||||
select(Product)
|
||||
.where(Product.sku == sku_candidate)
|
||||
.where(Product.is_active == True)
|
||||
)
|
||||
product = product_by_sku_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="库存仅管理物料,该条目不是物料")
|
||||
|
||||
resolved_product_id = product.id
|
||||
|
||||
if movement_data.movement_type in INBOUND_TYPES:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.values(quantity=Inventory.quantity + movement_data.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
inventory = Inventory(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
quantity=movement_data.quantity,
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
after_qty = movement_data.quantity
|
||||
else:
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty - movement_data.quantity
|
||||
|
||||
elif movement_data.movement_type in OUTBOUND_TYPES:
|
||||
upd_result = await db_session.execute(
|
||||
update(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.where(Inventory.quantity >= movement_data.quantity)
|
||||
.values(quantity=Inventory.quantity - movement_data.quantity)
|
||||
.returning(Inventory.quantity)
|
||||
)
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail="库存不足")
|
||||
after_qty = int(after_qty)
|
||||
before_qty = after_qty + movement_data.quantity
|
||||
|
||||
else:
|
||||
result = await db_session.execute(
|
||||
select(Inventory)
|
||||
.where(Inventory.product_id == resolved_product_id)
|
||||
.where(Inventory.warehouse_id == movement_data.warehouse_id)
|
||||
.with_for_update()
|
||||
)
|
||||
inventory = result.scalar_one_or_none()
|
||||
if not inventory:
|
||||
inventory = Inventory(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
quantity=0,
|
||||
)
|
||||
db_session.add(inventory)
|
||||
await db_session.flush()
|
||||
before_qty = 0
|
||||
else:
|
||||
before_qty = int(inventory.quantity)
|
||||
inventory.quantity = movement_data.quantity
|
||||
after_qty = movement_data.quantity
|
||||
|
||||
movement = StockMovement(
|
||||
product_id=resolved_product_id,
|
||||
warehouse_id=movement_data.warehouse_id,
|
||||
movement_type=movement_data.movement_type,
|
||||
quantity=movement_data.quantity,
|
||||
before_quantity=before_qty,
|
||||
after_quantity=after_qty,
|
||||
reference_no=generate_order_no("SM"),
|
||||
unit_price=movement_data.unit_price,
|
||||
total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None,
|
||||
remark=movement_data.remark,
|
||||
operator_id=current_user.id
|
||||
)
|
||||
db_session.add(movement)
|
||||
await db_session.commit()
|
||||
|
||||
return _build_movement_response(movement, product)
|
||||
|
||||
@staticmethod
|
||||
async def list_movements(
|
||||
db_session: AsyncSession,
|
||||
product_id: Optional[int],
|
||||
movement_type: Optional[str],
|
||||
skip: int,
|
||||
limit: int,
|
||||
) -> PaginatedResponse:
|
||||
base_query = (
|
||||
select(StockMovement, Product)
|
||||
.join(Product, StockMovement.product_id == Product.id)
|
||||
.order_by(StockMovement.created_at.desc())
|
||||
)
|
||||
|
||||
if product_id:
|
||||
base_query = base_query.where(StockMovement.product_id == product_id)
|
||||
if movement_type:
|
||||
base_query = base_query.where(StockMovement.movement_type == movement_type)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = await db_session.scalar(count_query) or 0
|
||||
|
||||
query = base_query.offset(skip).limit(limit)
|
||||
result = await db_session.execute(query)
|
||||
|
||||
movements = [_build_movement_response(movement, product) for movement, product in result.all()]
|
||||
return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
stock_movement_service = StockMovementService()
|
||||
@@ -29,6 +29,7 @@ from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.core.base_mold_generator import BaseMoldGenerator
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -54,48 +55,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
|
||||
|
||||
self.foam_material = foam_material
|
||||
|
||||
self.foam_materials = {
|
||||
"AlSi10Mg": {
|
||||
"density": 0.45,
|
||||
"expansion_ratio": 2.5,
|
||||
"shrinkage_rate": 0.015,
|
||||
"molding_temp": 380,
|
||||
"description": "常用铝硅泡沫"
|
||||
},
|
||||
"AlSi12": {
|
||||
"density": 0.50,
|
||||
"expansion_ratio": 2.2,
|
||||
"shrinkage_rate": 0.012,
|
||||
"molding_temp": 360,
|
||||
"description": "高强度铝泡沫"
|
||||
},
|
||||
"Pure Al Foam": {
|
||||
"density": 0.35,
|
||||
"expansion_ratio": 3.0,
|
||||
"shrinkage_rate": 0.020,
|
||||
"molding_temp": 400,
|
||||
"description": "纯铝泡沫"
|
||||
},
|
||||
"AlSi7Mg": {
|
||||
"density": 0.40,
|
||||
"expansion_ratio": 2.8,
|
||||
"shrinkage_rate": 0.018,
|
||||
"molding_temp": 390,
|
||||
"description": "轻质铝镁泡沫"
|
||||
}
|
||||
}
|
||||
|
||||
self.plastic_materials = {
|
||||
"ABS": {"density": 1.05, "shrinkage": 0.005},
|
||||
"PP": {"density": 0.90, "shrinkage": 0.016},
|
||||
"PC": {"density": 1.20, "shrinkage": 0.005},
|
||||
"PE": {"density": 0.95, "shrinkage": 0.025},
|
||||
"PS": {"density": 1.05, "shrinkage": 0.004},
|
||||
"PA": {"density": 1.14, "shrinkage": 0.015},
|
||||
"POM": {"density": 1.42, "shrinkage": 0.020},
|
||||
"PMMA": {"density": 1.18, "shrinkage": 0.004}
|
||||
}
|
||||
|
||||
self.parting_line_tolerance = 0.1
|
||||
self.max_draft_angle = 5.0
|
||||
self.min_draft_angle = 1.0
|
||||
@@ -107,21 +66,21 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
|
||||
|
||||
def set_foam_material(self, material: str):
|
||||
"""设置铝泡沫材料"""
|
||||
if material in self.foam_materials:
|
||||
props = self.foam_materials[material]
|
||||
if MaterialService.is_foam_material(material):
|
||||
props = MaterialService.get_material(material)
|
||||
self.foam_material = material
|
||||
self.material_density = props["density"]
|
||||
self.shrinkage_rate = props["shrinkage_rate"]
|
||||
self.shrinkage_rate = props["shrinkage"]
|
||||
logger.info(f"铝泡沫材料设置为 {material}, 密度: {props['density']} g/cm³")
|
||||
else:
|
||||
logger.warning(f"未知材料 {material}, 使用当前设置")
|
||||
|
||||
def set_material(self, material: str):
|
||||
"""设置材料(自动识别类型)"""
|
||||
if material in self.foam_materials:
|
||||
if MaterialService.is_foam_material(material):
|
||||
self.set_foam_material(material)
|
||||
elif material in self.plastic_materials:
|
||||
props = self.plastic_materials[material]
|
||||
elif MaterialService.has_material(material):
|
||||
props = MaterialService.get_material(material)
|
||||
self.material_density = props["density"]
|
||||
self.shrinkage_rate = props["shrinkage"]
|
||||
logger.info(f"塑料材料设置为 {material}, 密度: {props['density']} g/cm³")
|
||||
@@ -204,7 +163,7 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
|
||||
|
||||
parting_geometry = self._extract_parting_surface_geometry(parting_surface)
|
||||
|
||||
material_info = self.foam_materials.get(self.foam_material, {})
|
||||
material_info = MaterialService.get_material(self.foam_material)
|
||||
|
||||
detailed_json = {
|
||||
"metadata": {
|
||||
@@ -251,7 +210,7 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
|
||||
def generate_cavity_key_info(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""生成模具型腔的关键信息"""
|
||||
analysis = cavity_data["analysis"]
|
||||
material_info = self.foam_materials.get(self.foam_material, {})
|
||||
material_info = MaterialService.get_material(self.foam_material)
|
||||
|
||||
key_info = {
|
||||
"mold_parameters": {
|
||||
|
||||
@@ -9,6 +9,7 @@ from shared.models.schemas import (
|
||||
create_analysis_result
|
||||
)
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -25,12 +26,6 @@ class GeometryAnalyzer:
|
||||
"high_complexity": 50,
|
||||
}
|
||||
|
||||
self.product_materials = {
|
||||
"ABS": {"shrinkage": 0.005, "min_wall": 1.2},
|
||||
"PP": {"shrinkage": 0.016, "min_wall": 1.0},
|
||||
"PC": {"shrinkage": 0.007, "min_wall": 1.5},
|
||||
}
|
||||
|
||||
self.mold_materials = {
|
||||
"Aluminum": {"thermal_conductivity": 200, "hardness": "HB80", "cost": "low"},
|
||||
"P20_Steel": {"thermal_conductivity": 30, "hardness": "HRC30", "cost": "medium"},
|
||||
@@ -54,7 +49,6 @@ class GeometryAnalyzer:
|
||||
|
||||
features = self._detect_features(geometry_data, shape)
|
||||
|
||||
product_props = self.product_materials.get(product_material, {})
|
||||
mold_props = self.mold_materials.get(mold_material, {})
|
||||
|
||||
recommendations = self._generate_recommendations(
|
||||
@@ -744,8 +738,8 @@ class GeometryAnalyzer:
|
||||
avg_thickness = (volume / surface_area) * 0.6
|
||||
|
||||
if avg_thickness is not None:
|
||||
material_props = self.product_materials.get(material, self.product_materials["ABS"])
|
||||
min_wall = material_props["min_wall"]
|
||||
material_props = MaterialService.get_material(material)
|
||||
min_wall = material_props.get("min_wall", 1.2)
|
||||
|
||||
if avg_thickness < min_wall:
|
||||
return create_design_recommendation(
|
||||
|
||||
@@ -13,6 +13,7 @@ from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.core.base_mold_generator import BaseMoldGenerator
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -24,25 +25,14 @@ class MoldCavityGenerator(BaseMoldGenerator):
|
||||
material_density: float = 1.05):
|
||||
super().__init__(shrinkage_rate, draft_angle, material_density)
|
||||
|
||||
self.material_densities = {
|
||||
"ABS": 1.05,
|
||||
"PP": 0.90,
|
||||
"PC": 1.20,
|
||||
"PE": 0.95,
|
||||
"PS": 1.05,
|
||||
"PA": 1.14,
|
||||
"POM": 1.42,
|
||||
"PMMA": 1.18
|
||||
}
|
||||
|
||||
self.parting_line_tolerance = 0.1
|
||||
self.max_draft_angle = 5.0
|
||||
self.side_action_designer = SideActionDesigner()
|
||||
|
||||
def set_material(self, material: str):
|
||||
"""设置产品材料"""
|
||||
if material in self.material_densities:
|
||||
self.material_density = self.material_densities[material]
|
||||
if MaterialService.has_material(material):
|
||||
self.material_density = MaterialService.get_material(material)["density"]
|
||||
logger.info(f"材料设置为 {material}, 密度: {self.material_density} g/cm³")
|
||||
else:
|
||||
logger.warning(f"未知材料 {material}, 使用默认密度 {self.material_density} g/cm³")
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
# services/material_service.py
|
||||
"""材料属性管理服务"""
|
||||
"""材料属性管理服务 - 全局唯一材料属性源
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
所有材料属性(密度、收缩率、最小壁厚、泡沫特性等)集中在此,
|
||||
geometry_analyzer / mold_generator / aluminum_foam_mold 等模块一律通过 MaterialService 查询,
|
||||
禁止再各自维护材料字典,避免数据漂移。
|
||||
|
||||
历史问题:材料字典曾在 4 处重复定义且冲突(如 PE 收缩率此处 0.020 vs aluminum_foam_mold 0.025;
|
||||
PC/PA/PMMA 收缩率、POM 密度也冲突;PS 在其他字典有而此处缺失)。现已统一,以本文件为唯一源。
|
||||
泡沫材料原用 `shrinkage_rate` 键,现统一为 `shrinkage`(值一致)。
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"ABS": {"density": 1.05, "shrinkage": 0.005, "name": "ABS", "is_foam": False},
|
||||
"PP": {"density": 0.90, "shrinkage": 0.016, "name": "PP", "is_foam": False},
|
||||
"PE": {"density": 0.95, "shrinkage": 0.020, "name": "PE", "is_foam": False},
|
||||
"PC": {"density": 1.20, "shrinkage": 0.007, "name": "PC", "is_foam": False},
|
||||
"PA": {"density": 1.14, "shrinkage": 0.010, "name": "PA", "is_foam": False},
|
||||
"POM": {"density": 1.41, "shrinkage": 0.020, "name": "POM", "is_foam": False},
|
||||
"PMMA": {"density": 1.18, "shrinkage": 0.005, "name": "PMMA", "is_foam": False},
|
||||
"PBT": {"density": 1.31, "shrinkage": 0.015, "name": "PBT", "is_foam": False},
|
||||
"AlSi10Mg": {"density": 0.45, "shrinkage": 0.015, "name": "AlSi10Mg", "is_foam": True},
|
||||
"AlSi12": {"density": 0.50, "shrinkage": 0.012, "name": "AlSi12", "is_foam": True},
|
||||
"Pure Al Foam": {"density": 0.35, "shrinkage": 0.020, "name": "Pure Al Foam", "is_foam": True},
|
||||
"AlSi7Mg": {"density": 0.40, "shrinkage": 0.018, "name": "AlSi7Mg", "is_foam": True},
|
||||
# —— 注塑塑料 ——
|
||||
"ABS": {"name": "ABS", "density": 1.05, "shrinkage": 0.005, "is_foam": False, "min_wall": 1.2},
|
||||
"PP": {"name": "PP", "density": 0.90, "shrinkage": 0.016, "is_foam": False, "min_wall": 1.0},
|
||||
"PE": {"name": "PE", "density": 0.95, "shrinkage": 0.020, "is_foam": False, "min_wall": 1.0},
|
||||
"PC": {"name": "PC", "density": 1.20, "shrinkage": 0.007, "is_foam": False, "min_wall": 1.5},
|
||||
"PA": {"name": "PA", "density": 1.14, "shrinkage": 0.010, "is_foam": False, "min_wall": 1.0},
|
||||
"POM": {"name": "POM", "density": 1.41, "shrinkage": 0.020, "is_foam": False, "min_wall": 1.0},
|
||||
"PMMA": {"name": "PMMA", "density": 1.18, "shrinkage": 0.005, "is_foam": False, "min_wall": 1.5},
|
||||
"PBT": {"name": "PBT", "density": 1.31, "shrinkage": 0.015, "is_foam": False, "min_wall": 1.2},
|
||||
"PS": {"name": "PS", "density": 1.05, "shrinkage": 0.004, "is_foam": False, "min_wall": 1.0},
|
||||
# —— 铝泡沫 ——
|
||||
"AlSi10Mg": {"name": "AlSi10Mg", "density": 0.45, "shrinkage": 0.015, "is_foam": True,
|
||||
"expansion_ratio": 2.5, "molding_temp": 380, "description": "常用铝硅泡沫"},
|
||||
"AlSi12": {"name": "AlSi12", "density": 0.50, "shrinkage": 0.012, "is_foam": True,
|
||||
"expansion_ratio": 2.2, "molding_temp": 360, "description": "高强度铝泡沫"},
|
||||
"Pure Al Foam": {"name": "Pure Al Foam", "density": 0.35, "shrinkage": 0.020, "is_foam": True,
|
||||
"expansion_ratio": 3.0, "molding_temp": 400, "description": "纯铝泡沫"},
|
||||
"AlSi7Mg": {"name": "AlSi7Mg", "density": 0.40, "shrinkage": 0.018, "is_foam": True,
|
||||
"expansion_ratio": 2.8, "molding_temp": 390, "description": "轻质铝镁泡沫"},
|
||||
}
|
||||
|
||||
# 默认回退材料
|
||||
@@ -24,13 +40,18 @@ _DEFAULT_MATERIAL = MATERIAL_PROPERTIES["ABS"]
|
||||
|
||||
|
||||
class MaterialService:
|
||||
"""材料属性管理服务 — 集中管理材料字典,便于扩展和单测"""
|
||||
"""材料属性管理服务 - 集中管理材料字典,便于扩展和单测"""
|
||||
|
||||
@staticmethod
|
||||
def get_material(material_name: str) -> Dict[str, Any]:
|
||||
"""获取材料属性,不存在则回退到 ABS"""
|
||||
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL)
|
||||
|
||||
@staticmethod
|
||||
def has_material(material_name: str) -> bool:
|
||||
"""材料是否在字典中(不回退)"""
|
||||
return material_name in MATERIAL_PROPERTIES
|
||||
|
||||
@staticmethod
|
||||
def is_foam_material(material_name: str) -> bool:
|
||||
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL).get("is_foam", False)
|
||||
|
||||
Reference in New Issue
Block a user