This commit is contained in:
2026-07-20 10:16:27 +08:00
parent 8493f0df03
commit f3d2951dc3
24 changed files with 23623 additions and 1546 deletions
+180
View File
@@ -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()