x
This commit is contained in:
@@ -28,15 +28,25 @@ async def get_dashboard(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_count = await db_session.scalar(select(func.count(Product.id)).where(Product.is_active == True))
|
||||
material_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "material")
|
||||
) or 0
|
||||
finished_product_count = await db_session.scalar(
|
||||
select(func.count(Product.id)).where(Product.is_active == True, Product.item_type == "finished")
|
||||
) or 0
|
||||
supplier_count = await db_session.scalar(select(func.count(Supplier.id)).where(Supplier.is_active == True))
|
||||
customer_count = await db_session.scalar(select(func.count(Customer.id)).where(Customer.is_active == True))
|
||||
warehouse_count = await db_session.scalar(select(func.count(Warehouse.id)).where(Warehouse.is_active == True))
|
||||
|
||||
total_stock = await db_session.scalar(select(func.sum(Inventory.quantity))) or 0
|
||||
total_stock = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
total_value = await db_session.scalar(
|
||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||
.join(Product, Inventory.product_id == Product.id)
|
||||
.where(Product.item_type == "material")
|
||||
) or 0
|
||||
|
||||
pending_purchase = await db_session.scalar(
|
||||
@@ -49,6 +59,7 @@ async def get_dashboard(
|
||||
low_stock_products = await db_session.execute(
|
||||
select(Product, Inventory)
|
||||
.join(Inventory, Product.id == Inventory.product_id)
|
||||
.where(Product.item_type == "material")
|
||||
.where(Inventory.quantity <= Product.min_stock)
|
||||
.limit(10)
|
||||
)
|
||||
@@ -58,7 +69,8 @@ async def get_dashboard(
|
||||
]
|
||||
|
||||
return {
|
||||
"product_count": product_count,
|
||||
"product_count": finished_product_count,
|
||||
"material_count": material_count,
|
||||
"supplier_count": supplier_count,
|
||||
"customer_count": customer_count,
|
||||
"warehouse_count": warehouse_count,
|
||||
|
||||
@@ -35,6 +35,7 @@ async def list_inventory(
|
||||
.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)
|
||||
)
|
||||
|
||||
|
||||
@@ -11,23 +11,69 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, or_, func, delete
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from database.database import get_db_session
|
||||
from services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from models.database import User, Product
|
||||
from .schemas import ProductCreate, ProductResponse
|
||||
from models.database import User, Product, ProductMaterial
|
||||
from .schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductBOMUpdate,
|
||||
ProductBOMResponse,
|
||||
ProductMaterialItemResponse
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: List[int]) -> Dict[int, float]:
|
||||
if not product_ids:
|
||||
return {}
|
||||
result = await db_session.execute(
|
||||
select(
|
||||
ProductMaterial.finished_product_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
Product.cost_price * ProductMaterial.quantity * (1 + ProductMaterial.loss_rate)
|
||||
),
|
||||
0
|
||||
)
|
||||
)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id.in_(product_ids))
|
||||
.group_by(ProductMaterial.finished_product_id)
|
||||
)
|
||||
return {row[0]: float(row[1] or 0) for row in result.all()}
|
||||
|
||||
|
||||
def _build_product_response(product: Product, material_cost: float = 0) -> ProductResponse:
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
sku=product.sku,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
item_type=product.item_type,
|
||||
cost_price=float(product.cost_price or 0),
|
||||
sale_price=float(product.sale_price or 0),
|
||||
min_stock=product.min_stock,
|
||||
max_stock=product.max_stock,
|
||||
material_cost=round(float(material_cost), 4),
|
||||
is_active=product.is_active,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ProductResponse])
|
||||
async def list_products(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
item_type: Optional[str] = None,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
@@ -37,11 +83,15 @@ async def list_products(
|
||||
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||
if category:
|
||||
query = query.where(Product.category == category)
|
||||
if item_type:
|
||||
query = query.where(Product.item_type == item_type)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
result = await db_session.execute(query)
|
||||
products = result.scalars().all()
|
||||
return [ProductResponse.from_orm(p) for p in products]
|
||||
finished_product_ids = [p.id for p in products if p.item_type == "finished"]
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, finished_product_ids)
|
||||
return [_build_product_response(p, material_cost_map.get(p.id, 0)) for p in products]
|
||||
|
||||
|
||||
@router.post("", response_model=ProductResponse, status_code=201)
|
||||
@@ -50,15 +100,21 @@ async def create_product(
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
||||
|
||||
product = Product(**product_data.dict())
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
product = Product(**product_dict)
|
||||
db_session.add(product)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return ProductResponse.from_orm(product)
|
||||
return _build_product_response(product, 0)
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductResponse)
|
||||
@@ -72,13 +128,21 @@ async def update_product(
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
if product_data.item_type not in ["material", "finished"]:
|
||||
raise HTTPException(status_code=400, detail="item_type 必须为 material 或 finished")
|
||||
|
||||
for key, value in product_data.dict().items():
|
||||
product_dict = product_data.dict()
|
||||
if product_data.item_type == "finished":
|
||||
product_dict["min_stock"] = 0
|
||||
product_dict["max_stock"] = 0
|
||||
|
||||
for key, value in product_dict.items():
|
||||
setattr(product, key, value)
|
||||
|
||||
await db_session.commit()
|
||||
await db_session.refresh(product)
|
||||
return ProductResponse.from_orm(product)
|
||||
material_cost_map = await _calculate_material_cost_map(db_session, [product.id])
|
||||
return _build_product_response(product, material_cost_map.get(product.id, 0))
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
@@ -95,3 +159,104 @@ async def delete_product(
|
||||
product.is_active = False
|
||||
await db_session.commit()
|
||||
return {"message": "产品已删除"}
|
||||
|
||||
|
||||
@router.get("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
async def get_product_bom(
|
||||
product_id: int,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == 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 != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
bom_result = await db_session.execute(
|
||||
select(ProductMaterial, Product)
|
||||
.join(Product, ProductMaterial.material_product_id == Product.id)
|
||||
.where(ProductMaterial.finished_product_id == product_id)
|
||||
.order_by(ProductMaterial.id.asc())
|
||||
)
|
||||
|
||||
items: List[ProductMaterialItemResponse] = []
|
||||
total_material_cost = 0.0
|
||||
for bom, material in bom_result.all():
|
||||
line_cost = float(material.cost_price or 0) * float(bom.quantity) * (1 + float(bom.loss_rate or 0))
|
||||
total_material_cost += line_cost
|
||||
items.append(
|
||||
ProductMaterialItemResponse(
|
||||
material_id=material.id,
|
||||
material_sku=material.sku,
|
||||
material_name=material.name,
|
||||
quantity=round(float(bom.quantity), 4),
|
||||
loss_rate=round(float(bom.loss_rate or 0), 4),
|
||||
unit_cost=round(float(material.cost_price or 0), 4),
|
||||
line_cost=round(float(line_cost), 4),
|
||||
)
|
||||
)
|
||||
|
||||
return ProductBOMResponse(
|
||||
product_id=product.id,
|
||||
product_name=product.name,
|
||||
total_material_cost=round(float(total_material_cost), 4),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{product_id}/materials", response_model=ProductBOMResponse)
|
||||
async def replace_product_bom(
|
||||
product_id: int,
|
||||
payload: ProductBOMUpdate,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
product_result = await db_session.execute(
|
||||
select(Product).where(Product.id == 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 != "finished":
|
||||
raise HTTPException(status_code=400, detail="仅成品支持配置物料BOM")
|
||||
|
||||
material_ids = [item.material_id for item in payload.items]
|
||||
if len(material_ids) != len(set(material_ids)):
|
||||
raise HTTPException(status_code=400, detail="BOM 物料不允许重复")
|
||||
|
||||
if material_ids:
|
||||
material_result = await db_session.execute(
|
||||
select(Product).where(Product.id.in_(material_ids), Product.is_active == True)
|
||||
)
|
||||
materials = material_result.scalars().all()
|
||||
material_map = {m.id: m for m in materials}
|
||||
if len(material_map) != len(material_ids):
|
||||
raise HTTPException(status_code=400, detail="存在无效物料")
|
||||
invalid_materials = [m.name for m in materials if m.item_type != "material"]
|
||||
if invalid_materials:
|
||||
raise HTTPException(status_code=400, detail=f"以下条目不是物料:{', '.join(invalid_materials)}")
|
||||
else:
|
||||
material_map = {}
|
||||
|
||||
await db_session.execute(delete(ProductMaterial).where(ProductMaterial.finished_product_id == product_id))
|
||||
|
||||
for item in payload.items:
|
||||
if item.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料数量必须大于0")
|
||||
if item.loss_rate < 0:
|
||||
raise HTTPException(status_code=400, detail="损耗率不能为负数")
|
||||
db_session.add(
|
||||
ProductMaterial(
|
||||
finished_product_id=product_id,
|
||||
material_product_id=item.material_id,
|
||||
quantity=item.quantity,
|
||||
loss_rate=item.loss_rate,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
return await get_product_bom(product_id, db_session, current_user)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
路由前缀: /api/purchase-orders
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
@@ -79,6 +79,14 @@ async def create_purchase_order(
|
||||
|
||||
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 != "material":
|
||||
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
|
||||
item = PurchaseOrderItem(
|
||||
order_id=order.id,
|
||||
product_id=item_data.product_id,
|
||||
|
||||
@@ -8,20 +8,141 @@
|
||||
|
||||
路由前缀: /api/sales-orders
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from typing import Optional, List
|
||||
from math import ceil
|
||||
|
||||
from database.database import get_db_session
|
||||
from services.auth_service import get_current_active_user
|
||||
from models.database import User, Customer, SalesOrder, SalesOrderItem
|
||||
from .schemas import SalesOrderCreate, SalesOrderResponse
|
||||
from models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem
|
||||
)
|
||||
from .schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
SalesOrderProductionPlanResponse,
|
||||
ProductionMaterialPlanItemResponse,
|
||||
SalesOrderIssueRequest,
|
||||
SalesOrderIssueResponse
|
||||
)
|
||||
from .utils import generate_order_no
|
||||
|
||||
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
async def _get_sales_order_with_customer(
|
||||
db_session: AsyncSession,
|
||||
order_id: int,
|
||||
) -> tuple[SalesOrder, Customer]:
|
||||
result = await db_session.execute(
|
||||
select(SalesOrder, Customer)
|
||||
.join(Customer, SalesOrder.customer_id == Customer.id)
|
||||
.where(SalesOrder.id == order_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="销售订单不存在")
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> tuple[List[ProductionMaterialPlanItemResponse], float]:
|
||||
item_result = await db_session.execute(
|
||||
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
|
||||
)
|
||||
order_items = item_result.scalars().all()
|
||||
if not order_items:
|
||||
return [], 0
|
||||
|
||||
required_qty_map = {}
|
||||
for order_item 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 == order_item.product_id)
|
||||
.where(Product.is_active == True)
|
||||
.where(Product.item_type == "material")
|
||||
)
|
||||
bom_items = bom_result.all()
|
||||
if not bom_items:
|
||||
continue
|
||||
for bom, material in bom_items:
|
||||
qty = float(order_item.quantity) * float(bom.quantity or 0) * (1 + float(bom.loss_rate or 0))
|
||||
entry = required_qty_map.setdefault(
|
||||
material.id,
|
||||
{
|
||||
"material": material,
|
||||
"required_qty": 0.0
|
||||
}
|
||||
)
|
||||
entry["required_qty"] += qty
|
||||
|
||||
if not required_qty_map:
|
||||
return [], 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]: int(row[1] or 0) for row in stock_result.all()}
|
||||
|
||||
plan_items = []
|
||||
planned_material_cost = 0.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, 0)
|
||||
shortage_qty = max(required_qty - available_qty, 0)
|
||||
unit_cost = float(material.cost_price or 0)
|
||||
required_cost = 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=required_qty,
|
||||
available_quantity=available_qty,
|
||||
shortage_quantity=shortage_qty,
|
||||
unit_cost=round(unit_cost, 4),
|
||||
required_cost=round(required_cost, 4),
|
||||
)
|
||||
)
|
||||
|
||||
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
|
||||
return plan_items, planned_material_cost
|
||||
|
||||
|
||||
@router.get("", response_model=List[SalesOrderResponse])
|
||||
async def list_sales_orders(
|
||||
status: Optional[str] = None,
|
||||
@@ -44,18 +165,7 @@ async def list_sales_orders(
|
||||
|
||||
orders = []
|
||||
for order, customer in result.all():
|
||||
orders.append(SalesOrderResponse(
|
||||
id=order.id,
|
||||
order_no=order.order_no,
|
||||
customer_name=customer.name,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
status=order.status,
|
||||
total_amount=order.total_amount,
|
||||
received_amount=order.received_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at
|
||||
))
|
||||
orders.append(_build_sales_order_response(order, customer.name))
|
||||
|
||||
return orders
|
||||
|
||||
@@ -79,6 +189,14 @@ async def create_sales_order(
|
||||
|
||||
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,
|
||||
@@ -97,15 +215,113 @@ async def create_sales_order(
|
||||
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||
customer = customer.scalar_one()
|
||||
|
||||
return SalesOrderResponse(
|
||||
id=order.id,
|
||||
return _build_sales_order_response(order, customer.name)
|
||||
|
||||
|
||||
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
|
||||
async def get_sales_order_production_plan(
|
||||
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)
|
||||
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,
|
||||
order_date=order.order_date,
|
||||
delivery_date=order.delivery_date,
|
||||
status=order.status,
|
||||
total_amount=order.total_amount,
|
||||
received_amount=order.received_amount,
|
||||
remark=order.remark,
|
||||
created_at=order.created_at
|
||||
production_no=production_no,
|
||||
planned_material_cost=round(float(planned_material_cost), 4),
|
||||
items=plan_items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse)
|
||||
async def issue_sales_order_materials(
|
||||
order_id: int,
|
||||
payload: SalesOrderIssueRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
order, _ = await _get_sales_order_with_customer(db_session, order_id)
|
||||
if order.production_status == "completed":
|
||||
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 = 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=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 = round(float(planned_material_cost), 4)
|
||||
order.actual_material_cost = round(float(actual_material_cost), 4)
|
||||
if order.status == "draft":
|
||||
order.status = "pending"
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
cost_deviation = round(float(actual_material_cost - planned_material_cost), 4)
|
||||
cost_deviation_rate = round((cost_deviation / planned_material_cost), 6) if planned_material_cost > 1e-9 else 0.0
|
||||
return SalesOrderIssueResponse(
|
||||
sales_order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
production_no=production_no,
|
||||
movement_count=movement_count,
|
||||
planned_material_cost=round(float(planned_material_cost), 4),
|
||||
actual_material_cost=round(float(actual_material_cost), 4),
|
||||
cost_deviation=cost_deviation,
|
||||
cost_deviation_rate=cost_deviation_rate,
|
||||
production_status=order.production_status,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
from .product_schemas import ProductCreate, ProductResponse
|
||||
from .product_schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductMaterialItemUpdate,
|
||||
ProductBOMUpdate,
|
||||
ProductMaterialItemResponse,
|
||||
ProductBOMResponse
|
||||
)
|
||||
from .supplier_schemas import SupplierCreate, SupplierResponse
|
||||
from .customer_schemas import CustomerCreate, CustomerResponse
|
||||
from .warehouse_schemas import WarehouseCreate, WarehouseResponse
|
||||
@@ -12,7 +19,11 @@ from .purchase_order_schemas import (
|
||||
from .sales_order_schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
SalesOrderItemCreate
|
||||
SalesOrderItemCreate,
|
||||
ProductionMaterialPlanItemResponse,
|
||||
SalesOrderProductionPlanResponse,
|
||||
SalesOrderIssueRequest,
|
||||
SalesOrderIssueResponse
|
||||
)
|
||||
from .finance_schemas import (
|
||||
FinanceAllocationCreate,
|
||||
@@ -31,7 +42,8 @@ from .finance_schemas import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProductCreate", "ProductResponse",
|
||||
"ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate",
|
||||
"ProductMaterialItemResponse", "ProductBOMResponse",
|
||||
"SupplierCreate", "SupplierResponse",
|
||||
"CustomerCreate", "CustomerResponse",
|
||||
"WarehouseCreate", "WarehouseResponse",
|
||||
@@ -39,6 +51,8 @@ __all__ = [
|
||||
"StockMovementCreate", "StockMovementResponse",
|
||||
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
|
||||
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate",
|
||||
"ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse",
|
||||
"SalesOrderIssueRequest", "SalesOrderIssueResponse",
|
||||
"FinanceAllocationCreate", "FinanceTransactionCreate",
|
||||
"ReceiptCreate", "PaymentCreate",
|
||||
"FinanceAllocationResponse", "FinanceTransactionResponse",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class ProductCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
unit: str = "件"
|
||||
item_type: str = "finished"
|
||||
cost_price: float = 0
|
||||
sale_price: float = 0
|
||||
min_stock: int = 0
|
||||
@@ -22,12 +23,41 @@ class ProductResponse(BaseModel):
|
||||
description: Optional[str]
|
||||
category: Optional[str]
|
||||
unit: str
|
||||
item_type: str
|
||||
cost_price: float
|
||||
sale_price: float
|
||||
min_stock: int
|
||||
max_stock: int
|
||||
material_cost: float = 0
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductMaterialItemUpdate(BaseModel):
|
||||
material_id: int
|
||||
quantity: float
|
||||
loss_rate: float = 0
|
||||
|
||||
|
||||
class ProductBOMUpdate(BaseModel):
|
||||
items: List[ProductMaterialItemUpdate]
|
||||
|
||||
|
||||
class ProductMaterialItemResponse(BaseModel):
|
||||
material_id: int
|
||||
material_sku: str
|
||||
material_name: str
|
||||
quantity: float
|
||||
loss_rate: float
|
||||
unit_cost: float
|
||||
line_cost: float
|
||||
|
||||
|
||||
class ProductBOMResponse(BaseModel):
|
||||
product_id: int
|
||||
product_name: str
|
||||
total_material_cost: float
|
||||
items: List[ProductMaterialItemResponse]
|
||||
|
||||
@@ -24,6 +24,10 @@ class SalesOrderResponse(BaseModel):
|
||||
order_date: datetime
|
||||
delivery_date: Optional[datetime]
|
||||
status: str
|
||||
production_status: str
|
||||
production_no: Optional[str]
|
||||
planned_material_cost: float
|
||||
actual_material_cost: float
|
||||
total_amount: float
|
||||
received_amount: float
|
||||
remark: Optional[str]
|
||||
@@ -31,3 +35,41 @@ class SalesOrderResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductionMaterialPlanItemResponse(BaseModel):
|
||||
material_id: int
|
||||
material_sku: str
|
||||
material_name: str
|
||||
required_quantity: int
|
||||
available_quantity: int
|
||||
shortage_quantity: int
|
||||
unit_cost: float
|
||||
required_cost: float
|
||||
|
||||
|
||||
class SalesOrderProductionPlanResponse(BaseModel):
|
||||
sales_order_id: int
|
||||
order_no: str
|
||||
customer_name: str
|
||||
production_no: str
|
||||
planned_material_cost: float
|
||||
items: List[ProductionMaterialPlanItemResponse]
|
||||
|
||||
|
||||
class SalesOrderIssueRequest(BaseModel):
|
||||
warehouse_id: int
|
||||
production_no: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class SalesOrderIssueResponse(BaseModel):
|
||||
sales_order_id: int
|
||||
order_no: str
|
||||
production_no: str
|
||||
movement_count: int
|
||||
planned_material_cost: float
|
||||
actual_material_cost: float
|
||||
cost_deviation: float
|
||||
cost_deviation_rate: float
|
||||
production_status: str
|
||||
|
||||
@@ -68,6 +68,8 @@ async def create_stock_movement(
|
||||
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
|
||||
|
||||
|
||||
+46
-1
@@ -1,5 +1,5 @@
|
||||
# models/database.py
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, LargeBinary, Boolean, Float, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
@@ -480,6 +480,7 @@ class Product(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Float, default=0)
|
||||
sale_price = Column(Float, default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
@@ -490,11 +491,51 @@ class Product(Base):
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Float, nullable=False)
|
||||
loss_rate = Column(Float, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
@@ -667,6 +708,10 @@ class SalesOrder(Base):
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Float, default=0)
|
||||
actual_material_cost = Column(Float, default=0)
|
||||
total_amount = Column(Float, default=0)
|
||||
received_amount = Column(Float, default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
Reference in New Issue
Block a user