x
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user