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)
|
||||
|
||||
+270
-15
@@ -1486,6 +1486,10 @@ const InventoryView = {
|
||||
customerProductStatement: [],
|
||||
supplierProductStatement: [],
|
||||
products: [],
|
||||
materials: [],
|
||||
productionOrders: [],
|
||||
productionPlan: null,
|
||||
productionWarehouseId: null,
|
||||
suppliers: [],
|
||||
customers: [],
|
||||
warehouses: [],
|
||||
@@ -1495,6 +1499,7 @@ const InventoryView = {
|
||||
showModal: false,
|
||||
modalType: '',
|
||||
editingItem: null,
|
||||
productBomItems: [],
|
||||
form: {}
|
||||
});
|
||||
|
||||
@@ -1555,7 +1560,7 @@ const InventoryView = {
|
||||
const loadProducts = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
state.products = await apiRequest('/api/products');
|
||||
state.products = await apiRequest('/api/products?limit=200');
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载产品');
|
||||
} finally {
|
||||
@@ -1563,6 +1568,17 @@ const InventoryView = {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaterials = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
state.materials = await apiRequest('/api/products?item_type=material&limit=300');
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载物料');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadWarehouses = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
@@ -1575,8 +1591,8 @@ const InventoryView = {
|
||||
};
|
||||
|
||||
const ensureStockBaseData = async () => {
|
||||
if (!state.products.length) {
|
||||
await loadProducts();
|
||||
if (!state.materials.length) {
|
||||
await loadMaterials();
|
||||
}
|
||||
if (!state.warehouses.length) {
|
||||
await loadWarehouses();
|
||||
@@ -1608,6 +1624,25 @@ const InventoryView = {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProductionOrders = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
const [orders, warehouses] = await Promise.all([
|
||||
apiRequest('/api/sales-orders?limit=100'),
|
||||
apiRequest('/api/warehouses')
|
||||
]);
|
||||
state.productionOrders = orders || [];
|
||||
state.warehouses = warehouses || [];
|
||||
if (!state.productionWarehouseId) {
|
||||
state.productionWarehouseId = state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null;
|
||||
}
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载按单生产数据');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCustomers = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
@@ -1699,10 +1734,41 @@ const InventoryView = {
|
||||
case 'customers': loadCustomers(); break;
|
||||
case 'inventory': loadInventory(); break;
|
||||
case 'movements': loadMovements(); break;
|
||||
case 'production': loadProductionOrders(); break;
|
||||
case 'finance': loadFinance(); break;
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrderProductionPlan = async (orderId) => {
|
||||
try {
|
||||
state.productionPlan = await apiRequest(`/api/sales-orders/${orderId}/production-plan`);
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载领料建议');
|
||||
}
|
||||
};
|
||||
|
||||
const issueOrderMaterials = async (order) => {
|
||||
if (!state.productionWarehouseId) {
|
||||
addNotification('请先选择领料仓库', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await apiRequest(`/api/sales-orders/${order.id}/issue-materials`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
warehouse_id: state.productionWarehouseId,
|
||||
production_no: order.production_no || undefined
|
||||
})
|
||||
});
|
||||
addNotification(`领料成功,成本偏差率 ${(result.cost_deviation_rate * 100).toFixed(2)}%`, 'success');
|
||||
await loadProductionOrders();
|
||||
await loadMovements();
|
||||
state.productionPlan = await apiRequest(`/api/sales-orders/${order.id}/production-plan`);
|
||||
} catch (e) {
|
||||
handleApiError(e, '执行领料');
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = async (type, item = null) => {
|
||||
state.modalType = type;
|
||||
state.editingItem = item;
|
||||
@@ -1713,12 +1779,22 @@ const InventoryView = {
|
||||
if (type === 'stockIn' || type === 'stockOut') {
|
||||
await ensureStockBaseData();
|
||||
state.form = {
|
||||
product_id: state.products[0]?.id || null,
|
||||
product_id: state.materials[0]?.id || null,
|
||||
warehouse_id: state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
||||
quantity: 1,
|
||||
movement_type: type === 'stockIn' ? 'purchase_in' : 'issue_to_production'
|
||||
};
|
||||
}
|
||||
if (type === 'product') {
|
||||
state.form = {
|
||||
item_type: 'finished',
|
||||
unit: '件',
|
||||
min_stock: 0,
|
||||
max_stock: 1000,
|
||||
cost_price: 0,
|
||||
sale_price: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
state.showModal = true;
|
||||
};
|
||||
@@ -1727,6 +1803,7 @@ const InventoryView = {
|
||||
state.showModal = false;
|
||||
state.modalType = '';
|
||||
state.editingItem = null;
|
||||
state.productBomItems = [];
|
||||
state.form = {};
|
||||
};
|
||||
|
||||
@@ -1747,6 +1824,7 @@ const InventoryView = {
|
||||
}
|
||||
closeModal();
|
||||
loadProducts();
|
||||
loadMaterials();
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存产品');
|
||||
}
|
||||
@@ -1758,11 +1836,55 @@ const InventoryView = {
|
||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' });
|
||||
addNotification('产品已删除', 'success');
|
||||
loadProducts();
|
||||
loadMaterials();
|
||||
} catch (e) {
|
||||
handleApiError(e, '删除产品');
|
||||
}
|
||||
};
|
||||
|
||||
const editProductBom = async (product) => {
|
||||
try {
|
||||
await loadMaterials();
|
||||
const bom = await apiRequest(`/api/products/${product.id}/materials`);
|
||||
state.modalType = 'productBom';
|
||||
state.editingItem = product;
|
||||
state.productBomItems = (bom.items || []).map(item => ({
|
||||
material_id: item.material_id,
|
||||
quantity: item.quantity,
|
||||
loss_rate: item.loss_rate
|
||||
}));
|
||||
state.showModal = true;
|
||||
} catch (e) {
|
||||
handleApiError(e, '加载产品BOM');
|
||||
}
|
||||
};
|
||||
|
||||
const addBomItem = () => {
|
||||
state.productBomItems.push({
|
||||
material_id: state.materials[0]?.id || null,
|
||||
quantity: 1,
|
||||
loss_rate: 0
|
||||
});
|
||||
};
|
||||
|
||||
const removeBomItem = (idx) => {
|
||||
state.productBomItems.splice(idx, 1);
|
||||
};
|
||||
|
||||
const saveProductBom = async () => {
|
||||
try {
|
||||
await apiRequest(`/api/products/${state.editingItem.id}/materials`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: state.productBomItems })
|
||||
});
|
||||
addNotification('产品BOM保存成功', 'success');
|
||||
closeModal();
|
||||
loadProducts();
|
||||
} catch (e) {
|
||||
handleApiError(e, '保存产品BOM');
|
||||
}
|
||||
};
|
||||
|
||||
const saveSupplier = async () => {
|
||||
try {
|
||||
if (state.editingItem) {
|
||||
@@ -1877,12 +1999,19 @@ const InventoryView = {
|
||||
closeModal,
|
||||
saveProduct,
|
||||
deleteProduct,
|
||||
editProductBom,
|
||||
addBomItem,
|
||||
removeBomItem,
|
||||
saveProductBom,
|
||||
saveSupplier,
|
||||
deleteSupplier,
|
||||
saveCustomer,
|
||||
deleteCustomer,
|
||||
stockIn,
|
||||
stockOut,
|
||||
loadProductionOrders,
|
||||
loadOrderProductionPlan,
|
||||
issueOrderMaterials,
|
||||
refreshFinanceByPeriod,
|
||||
inboundMovementOptions,
|
||||
outboundMovementOptions,
|
||||
@@ -1904,6 +2033,7 @@ const InventoryView = {
|
||||
<button :class="['tab', { active: state.activeTab === 'suppliers' }]" @click="switchTab('suppliers')">供应商</button>
|
||||
<button :class="['tab', { active: state.activeTab === 'customers' }]" @click="switchTab('customers')">客户</button>
|
||||
<button :class="['tab', { active: state.activeTab === 'movements' }]" @click="switchTab('movements')">变动记录</button>
|
||||
<button :class="['tab', { active: state.activeTab === 'production' }]" @click="switchTab('production')">按单生产</button>
|
||||
<button :class="['tab', { active: state.activeTab === 'finance' }]" @click="switchTab('finance')">财务</button>
|
||||
</div>
|
||||
|
||||
@@ -1918,14 +2048,14 @@ const InventoryView = {
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ state.dashboard?.product_count || 0 }}</div>
|
||||
<div class="stat-label">产品数量</div>
|
||||
<div class="stat-label">成品数量</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ state.dashboard?.total_stock || 0 }}</div>
|
||||
<div class="stat-label">库存总量</div>
|
||||
<div class="stat-label">物料库存总量</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -1966,25 +2096,30 @@ const InventoryView = {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>SKU</th>
|
||||
<th>名称</th>
|
||||
<th>分类</th>
|
||||
<th>单位</th>
|
||||
<th>成本价</th>
|
||||
<th>销售价</th>
|
||||
<th>基础物料成本</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="product in state.products" :key="product.id">
|
||||
<td>{{ product.item_type === 'material' ? '物料' : '成品' }}</td>
|
||||
<td>{{ product.sku }}</td>
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.category || '-' }}</td>
|
||||
<td>{{ product.unit }}</td>
|
||||
<td>{{ formatCurrency(product.cost_price) }}</td>
|
||||
<td>{{ formatCurrency(product.sale_price) }}</td>
|
||||
<td>{{ product.item_type === 'finished' ? formatCurrency(product.material_cost || 0) : '-' }}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button v-if="product.item_type === 'finished'" class="btn btn-sm btn-secondary" @click="editProductBom(product)">BOM</button>
|
||||
<button class="btn btn-sm btn-secondary" @click="openModal('product', product)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
||||
</div>
|
||||
@@ -2094,6 +2229,79 @@ const InventoryView = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.activeTab === 'production'">
|
||||
<div class="table-container" style="margin-bottom: 16px;">
|
||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<label>领料仓库</label>
|
||||
<select v-model.number="state.productionWarehouseId" class="form-input" style="width:260px;">
|
||||
<option v-for="warehouse in state.warehouses" :key="'production-warehouse-' + warehouse.id" :value="warehouse.id">
|
||||
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" @click="loadProductionOrders">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>销售单</th>
|
||||
<th>客户</th>
|
||||
<th>生产单号</th>
|
||||
<th>生产状态</th>
|
||||
<th>计划物料成本</th>
|
||||
<th>实际领料成本</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in state.productionOrders" :key="'production-order-' + order.id">
|
||||
<td>{{ order.order_no }}</td>
|
||||
<td>{{ order.customer_name }}</td>
|
||||
<td>{{ order.production_no || '-' }}</td>
|
||||
<td>{{ order.production_status || '-' }}</td>
|
||||
<td>{{ formatCurrency(order.planned_material_cost || 0) }}</td>
|
||||
<td>{{ formatCurrency(order.actual_material_cost || 0) }}</td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="btn btn-sm btn-secondary" @click="loadOrderProductionPlan(order.id)">领料建议</button>
|
||||
<button class="btn btn-sm btn-primary" @click="issueOrderMaterials(order)">执行领料</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="state.productionPlan" class="table-container" style="margin-top: 16px;">
|
||||
<h3 style="margin-bottom: 12px;">领料建议:{{ state.productionPlan.order_no }}({{ state.productionPlan.production_no }})</h3>
|
||||
<div style="margin-bottom: 12px; color: var(--text-secondary);">
|
||||
计划物料成本:{{ formatCurrency(state.productionPlan.planned_material_cost || 0) }}
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>物料</th>
|
||||
<th>需求</th>
|
||||
<th>可用</th>
|
||||
<th>缺口</th>
|
||||
<th>单位成本</th>
|
||||
<th>需求成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in state.productionPlan.items" :key="'plan-material-' + item.material_id">
|
||||
<td>{{ item.material_sku }} - {{ item.material_name }}</td>
|
||||
<td>{{ item.required_quantity }}</td>
|
||||
<td>{{ item.available_quantity }}</td>
|
||||
<td :class="{ 'text-warning': item.shortage_quantity > 0 }">{{ item.shortage_quantity }}</td>
|
||||
<td>{{ formatCurrency(item.unit_cost) }}</td>
|
||||
<td>{{ formatCurrency(item.required_cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state.activeTab === 'finance'">
|
||||
<div class="table-container" style="margin-bottom: 16px;">
|
||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
@@ -2294,7 +2502,7 @@ const InventoryView = {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品</th>
|
||||
<th>物料</th>
|
||||
<th>类型</th>
|
||||
<th>数量</th>
|
||||
<th>变动前</th>
|
||||
@@ -2324,12 +2532,19 @@ const InventoryView = {
|
||||
<div v-if="state.showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ state.editingItem ? '编辑' : '新增' }}{{ state.modalType === 'product' ? '产品' : state.modalType === 'supplier' ? '供应商' : state.modalType === 'customer' ? '客户' : state.modalType === 'stockIn' ? '入库' : '出库' }}</h3>
|
||||
<h3>{{ state.editingItem ? '编辑' : '新增' }}{{ state.modalType === 'product' ? '产品/物料' : state.modalType === 'productBom' ? '产品BOM' : state.modalType === 'supplier' ? '供应商' : state.modalType === 'customer' ? '客户' : state.modalType === 'stockIn' ? '入库' : '出库' }}</h3>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 产品表单 -->
|
||||
<form v-if="state.modalType === 'product'" @submit.prevent="saveProduct">
|
||||
<div class="form-group">
|
||||
<label class="form-label">类型 *</label>
|
||||
<select v-model="state.form.item_type" class="form-input" required>
|
||||
<option value="finished">成品(按单生产,不做库存)</option>
|
||||
<option value="material">物料(纳入库存)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SKU *</label>
|
||||
<input v-model="state.form.sku" class="form-input" required placeholder="产品编码" />
|
||||
@@ -2354,16 +2569,56 @@ const InventoryView = {
|
||||
<label class="form-label">销售价</label>
|
||||
<input v-model.number="state.form.sale_price" type="number" step="0.01" class="form-input" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div v-if="state.form.item_type === 'material'" class="form-group">
|
||||
<label class="form-label">最低库存</label>
|
||||
<input v-model.number="state.form.min_stock" type="number" class="form-input" placeholder="0" />
|
||||
</div>
|
||||
<div v-if="state.form.item_type === 'finished'" class="form-group">
|
||||
<label class="form-label">说明</label>
|
||||
<input disabled value="成品不做库存,成本由下方BOM定义物料构成后自动计算" class="form-input" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else-if="state.modalType === 'productBom'" @submit.prevent="saveProductBom">
|
||||
<div class="table-header" style="margin-bottom: 12px;">
|
||||
<button type="button" class="btn btn-secondary" @click="addBomItem">+ 添加物料</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>物料</th>
|
||||
<th>数量</th>
|
||||
<th>损耗率</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in state.productBomItems" :key="'bom-item-' + idx">
|
||||
<td>
|
||||
<select v-model.number="item.material_id" class="form-input" required>
|
||||
<option v-for="material in state.materials" :key="'bom-material-' + material.id" :value="material.id">
|
||||
{{ material.sku }} - {{ material.name }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input v-model.number="item.quantity" type="number" min="0.0001" step="0.0001" class="form-input" required /></td>
|
||||
<td><input v-model.number="item.loss_rate" type="number" min="0" step="0.0001" class="form-input" required /></td>
|
||||
<td><button type="button" class="btn btn-sm btn-danger" @click="removeBomItem(idx)">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存BOM</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 供应商表单 -->
|
||||
<form v-else-if="state.modalType === 'supplier'" @submit.prevent="saveSupplier">
|
||||
<div class="form-group">
|
||||
@@ -2423,10 +2678,10 @@ const InventoryView = {
|
||||
<!-- 入库表单 -->
|
||||
<form v-else-if="state.modalType === 'stockIn'" @submit.prevent="stockIn">
|
||||
<div class="form-group">
|
||||
<label class="form-label">产品 *</label>
|
||||
<label class="form-label">物料 *</label>
|
||||
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||
<option v-if="!state.products.length" :value="null" disabled>暂无产品,请先新增产品</option>
|
||||
<option v-for="product in state.products" :key="'stockin-product-' + product.id" :value="product.id">
|
||||
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
|
||||
<option v-for="product in state.materials" :key="'stockin-product-' + product.id" :value="product.id">
|
||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||
</option>
|
||||
</select>
|
||||
@@ -2467,10 +2722,10 @@ const InventoryView = {
|
||||
<!-- 出库表单 -->
|
||||
<form v-else-if="state.modalType === 'stockOut'" @submit.prevent="stockOut">
|
||||
<div class="form-group">
|
||||
<label class="form-label">产品 *</label>
|
||||
<label class="form-label">物料 *</label>
|
||||
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||
<option v-if="!state.products.length" :value="null" disabled>暂无产品,请先新增产品</option>
|
||||
<option v-for="product in state.products" :key="'stockout-product-' + product.id" :value="product.id">
|
||||
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
|
||||
<option v-for="product in state.materials" :key="'stockout-product-' + product.id" :value="product.id">
|
||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||
</option>
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user