This commit is contained in:
2026-03-15 22:50:38 +08:00
parent 4e9f66ccd6
commit d2bcea7810
11 changed files with 848 additions and 58 deletions
+15 -3
View File
@@ -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,
+1
View File
@@ -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)
)
+174 -9
View File
@@ -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)
+9 -1
View File
@@ -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,
+241 -25
View File
@@ -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,
)
+17 -3
View File
@@ -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",
+31 -1
View File
@@ -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
View File
@@ -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)