进销存系统重构

This commit is contained in:
2026-03-17 22:20:56 +08:00
parent abecbc827d
commit 95e7404902
8 changed files with 1258 additions and 178 deletions
+253 -23
View File
@@ -10,7 +10,7 @@
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, delete
from typing import Optional, List
from math import ceil
@@ -30,6 +30,8 @@ from models.database import (
from .schemas import (
SalesOrderCreate,
SalesOrderResponse,
SalesOrderDetailResponse,
SalesOrderItemResponse,
SalesOrderProductionPlanResponse,
ProductionMaterialPlanItemResponse,
SalesOrderIssueRequest,
@@ -59,6 +61,45 @@ def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesO
)
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,
status=order.status,
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=round(float(order.planned_material_cost or 0), 4),
actual_material_cost=round(float(order.actual_material_cost or 0), 4),
total_amount=order.total_amount,
received_amount=order.received_amount,
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=item.unit_price,
amount=item.amount,
remark=item.remark
) for item in items
]
)
async def _get_sales_order_with_customer(
db_session: AsyncSession,
order_id: int,
@@ -143,6 +184,160 @@ async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> t
return plan_items, planned_material_cost
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = warehouse_result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
return warehouse
async def _issue_materials_for_order_creation(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
) -> tuple[int, float, float]:
warehouse = await _get_default_warehouse(db_session)
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
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 = order.production_no or generate_order_no("WO")
actual_material_cost = 0.0
movement_count = 0
for item in plan_items:
inv_result = await db_session.execute(
select(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
)
inventory = inv_result.scalar_one_or_none()
if not inventory or inventory.quantity < item.required_quantity:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
before_qty = inventory.quantity
inventory.quantity -= item.required_quantity
after_qty = inventory.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=round(float(total_amount), 4),
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 = round(float(planned_material_cost), 4)
order.actual_material_cost = round(float(actual_material_cost), 4)
order.status = "pending"
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:
inv_result = await db_session.execute(
select(Inventory)
.where(Inventory.product_id == movement.product_id)
.where(Inventory.warehouse_id == movement.warehouse_id)
)
inventory = inv_result.scalar_one_or_none()
if not inventory:
inventory = Inventory(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
quantity=0,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = inventory.quantity
inventory.quantity += movement.quantity
after_qty = inventory.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
) -> float:
total_amount = 0.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 != "finished":
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
item = SalesOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_data.quantity * item_data.unit_price,
remark=item_data.remark
)
db_session.add(item)
total_amount += item.amount
return total_amount
@router.get("", response_model=List[SalesOrderResponse])
async def list_sales_orders(
status: Optional[str] = None,
@@ -187,28 +382,8 @@ async def create_sales_order(
db_session.add(order)
await db_session.flush()
total_amount = 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 != "finished":
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
item = SalesOrderItem(
order_id=order.id,
product_id=item_data.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
order.total_amount = total_amount
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()
await db_session.refresh(order)
@@ -218,6 +393,59 @@ async def create_sales_order(
return _build_sales_order_response(order, customer.name)
@router.get("/{order_id}", response_model=SalesOrderDetailResponse)
async def get_sales_order_detail(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
return await _build_sales_order_detail_response(db_session, order, customer.name)
@router.put("/{order_id}", response_model=SalesOrderResponse)
async def update_sales_order(
order_id: int,
order_data: SalesOrderCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, customer = await _get_sales_order_with_customer(db_session, order_id)
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.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()
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)
@router.delete("/{order_id}")
async def delete_sales_order(
order_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
order, _ = await _get_sales_order_with_customer(db_session, order_id)
await _rollback_issued_materials(db_session, order, current_user)
await db_session.delete(order)
await db_session.commit()
return {"message": "销售订单已删除"}
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
async def get_sales_order_production_plan(
order_id: int,
@@ -248,6 +476,8 @@ async def issue_sales_order_materials(
order, _ = await _get_sales_order_with_customer(db_session, order_id)
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)