x
This commit is contained in:
@@ -28,15 +28,25 @@ async def get_dashboard(
|
|||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
current_user: User = Depends(get_current_active_user)
|
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))
|
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))
|
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))
|
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(
|
total_value = await db_session.scalar(
|
||||||
select(func.sum(Inventory.quantity * Product.cost_price))
|
select(func.sum(Inventory.quantity * Product.cost_price))
|
||||||
.join(Product, Inventory.product_id == Product.id)
|
.join(Product, Inventory.product_id == Product.id)
|
||||||
|
.where(Product.item_type == "material")
|
||||||
) or 0
|
) or 0
|
||||||
|
|
||||||
pending_purchase = await db_session.scalar(
|
pending_purchase = await db_session.scalar(
|
||||||
@@ -49,6 +59,7 @@ async def get_dashboard(
|
|||||||
low_stock_products = await db_session.execute(
|
low_stock_products = await db_session.execute(
|
||||||
select(Product, Inventory)
|
select(Product, Inventory)
|
||||||
.join(Inventory, Product.id == Inventory.product_id)
|
.join(Inventory, Product.id == Inventory.product_id)
|
||||||
|
.where(Product.item_type == "material")
|
||||||
.where(Inventory.quantity <= Product.min_stock)
|
.where(Inventory.quantity <= Product.min_stock)
|
||||||
.limit(10)
|
.limit(10)
|
||||||
)
|
)
|
||||||
@@ -58,7 +69,8 @@ async def get_dashboard(
|
|||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"product_count": product_count,
|
"product_count": finished_product_count,
|
||||||
|
"material_count": material_count,
|
||||||
"supplier_count": supplier_count,
|
"supplier_count": supplier_count,
|
||||||
"customer_count": customer_count,
|
"customer_count": customer_count,
|
||||||
"warehouse_count": warehouse_count,
|
"warehouse_count": warehouse_count,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async def list_inventory(
|
|||||||
.join(Product, Inventory.product_id == Product.id)
|
.join(Product, Inventory.product_id == Product.id)
|
||||||
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
|
||||||
.where(Product.is_active == True)
|
.where(Product.is_active == True)
|
||||||
|
.where(Product.item_type == "material")
|
||||||
.where(Warehouse.is_active == True)
|
.where(Warehouse.is_active == True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,23 +11,69 @@
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_, func, delete
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from database.database import get_db_session
|
from database.database import get_db_session
|
||||||
from services.auth_service import get_current_active_user, get_current_admin_user
|
from services.auth_service import get_current_active_user, get_current_admin_user
|
||||||
from models.database import User, Product
|
from models.database import User, Product, ProductMaterial
|
||||||
from .schemas import ProductCreate, ProductResponse
|
from .schemas import (
|
||||||
|
ProductCreate,
|
||||||
|
ProductResponse,
|
||||||
|
ProductBOMUpdate,
|
||||||
|
ProductBOMResponse,
|
||||||
|
ProductMaterialItemResponse
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
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])
|
@router.get("", response_model=List[ProductResponse])
|
||||||
async def list_products(
|
async def list_products(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
|
item_type: Optional[str] = None,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
current_user: User = Depends(get_current_active_user)
|
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}%")))
|
query = query.where(or_(Product.name.ilike(f"%{search}%"), Product.sku.ilike(f"%{search}%")))
|
||||||
if category:
|
if category:
|
||||||
query = query.where(Product.category == 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())
|
query = query.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||||
result = await db_session.execute(query)
|
result = await db_session.execute(query)
|
||||||
products = result.scalars().all()
|
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)
|
@router.post("", response_model=ProductResponse, status_code=201)
|
||||||
@@ -50,15 +100,21 @@ async def create_product(
|
|||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
current_user: User = Depends(get_current_active_user)
|
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))
|
existing = await db_session.execute(select(Product).where(Product.sku == product_data.sku))
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="SKU已存在")
|
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)
|
db_session.add(product)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
await db_session.refresh(product)
|
await db_session.refresh(product)
|
||||||
return ProductResponse.from_orm(product)
|
return _build_product_response(product, 0)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{product_id}", response_model=ProductResponse)
|
@router.put("/{product_id}", response_model=ProductResponse)
|
||||||
@@ -72,13 +128,21 @@ async def update_product(
|
|||||||
product = result.scalar_one_or_none()
|
product = result.scalar_one_or_none()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="产品不存在")
|
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)
|
setattr(product, key, value)
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
await db_session.refresh(product)
|
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}")
|
@router.delete("/{product_id}")
|
||||||
@@ -95,3 +159,104 @@ async def delete_product(
|
|||||||
product.is_active = False
|
product.is_active = False
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
return {"message": "产品已删除"}
|
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
|
路由前缀: /api/purchase-orders
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@@ -79,6 +79,14 @@ async def create_purchase_order(
|
|||||||
|
|
||||||
total_amount = 0
|
total_amount = 0
|
||||||
for item_data in order_data.items:
|
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(
|
item = PurchaseOrderItem(
|
||||||
order_id=order.id,
|
order_id=order.id,
|
||||||
product_id=item_data.product_id,
|
product_id=item_data.product_id,
|
||||||
|
|||||||
@@ -8,20 +8,141 @@
|
|||||||
|
|
||||||
路由前缀: /api/sales-orders
|
路由前缀: /api/sales-orders
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
from database.database import get_db_session
|
from database.database import get_db_session
|
||||||
from services.auth_service import get_current_active_user
|
from services.auth_service import get_current_active_user
|
||||||
from models.database import User, Customer, SalesOrder, SalesOrderItem
|
from models.database import (
|
||||||
from .schemas import SalesOrderCreate, SalesOrderResponse
|
User,
|
||||||
|
Customer,
|
||||||
|
Product,
|
||||||
|
ProductMaterial,
|
||||||
|
Warehouse,
|
||||||
|
Inventory,
|
||||||
|
StockMovement,
|
||||||
|
SalesOrder,
|
||||||
|
SalesOrderItem
|
||||||
|
)
|
||||||
|
from .schemas import (
|
||||||
|
SalesOrderCreate,
|
||||||
|
SalesOrderResponse,
|
||||||
|
SalesOrderProductionPlanResponse,
|
||||||
|
ProductionMaterialPlanItemResponse,
|
||||||
|
SalesOrderIssueRequest,
|
||||||
|
SalesOrderIssueResponse
|
||||||
|
)
|
||||||
from .utils import generate_order_no
|
from .utils import generate_order_no
|
||||||
|
|
||||||
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
|
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])
|
@router.get("", response_model=List[SalesOrderResponse])
|
||||||
async def list_sales_orders(
|
async def list_sales_orders(
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
@@ -44,18 +165,7 @@ async def list_sales_orders(
|
|||||||
|
|
||||||
orders = []
|
orders = []
|
||||||
for order, customer in result.all():
|
for order, customer in result.all():
|
||||||
orders.append(SalesOrderResponse(
|
orders.append(_build_sales_order_response(order, customer.name))
|
||||||
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
|
|
||||||
))
|
|
||||||
|
|
||||||
return orders
|
return orders
|
||||||
|
|
||||||
@@ -79,6 +189,14 @@ async def create_sales_order(
|
|||||||
|
|
||||||
total_amount = 0
|
total_amount = 0
|
||||||
for item_data in order_data.items:
|
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(
|
item = SalesOrderItem(
|
||||||
order_id=order.id,
|
order_id=order.id,
|
||||||
product_id=item_data.product_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 = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
|
||||||
customer = customer.scalar_one()
|
customer = customer.scalar_one()
|
||||||
|
|
||||||
return SalesOrderResponse(
|
return _build_sales_order_response(order, customer.name)
|
||||||
id=order.id,
|
|
||||||
|
|
||||||
|
@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,
|
order_no=order.order_no,
|
||||||
customer_name=customer.name,
|
customer_name=customer.name,
|
||||||
order_date=order.order_date,
|
production_no=production_no,
|
||||||
delivery_date=order.delivery_date,
|
planned_material_cost=round(float(planned_material_cost), 4),
|
||||||
status=order.status,
|
items=plan_items,
|
||||||
total_amount=order.total_amount,
|
)
|
||||||
received_amount=order.received_amount,
|
|
||||||
remark=order.remark,
|
|
||||||
created_at=order.created_at
|
@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 .supplier_schemas import SupplierCreate, SupplierResponse
|
||||||
from .customer_schemas import CustomerCreate, CustomerResponse
|
from .customer_schemas import CustomerCreate, CustomerResponse
|
||||||
from .warehouse_schemas import WarehouseCreate, WarehouseResponse
|
from .warehouse_schemas import WarehouseCreate, WarehouseResponse
|
||||||
@@ -12,7 +19,11 @@ from .purchase_order_schemas import (
|
|||||||
from .sales_order_schemas import (
|
from .sales_order_schemas import (
|
||||||
SalesOrderCreate,
|
SalesOrderCreate,
|
||||||
SalesOrderResponse,
|
SalesOrderResponse,
|
||||||
SalesOrderItemCreate
|
SalesOrderItemCreate,
|
||||||
|
ProductionMaterialPlanItemResponse,
|
||||||
|
SalesOrderProductionPlanResponse,
|
||||||
|
SalesOrderIssueRequest,
|
||||||
|
SalesOrderIssueResponse
|
||||||
)
|
)
|
||||||
from .finance_schemas import (
|
from .finance_schemas import (
|
||||||
FinanceAllocationCreate,
|
FinanceAllocationCreate,
|
||||||
@@ -31,7 +42,8 @@ from .finance_schemas import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductCreate", "ProductResponse",
|
"ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate",
|
||||||
|
"ProductMaterialItemResponse", "ProductBOMResponse",
|
||||||
"SupplierCreate", "SupplierResponse",
|
"SupplierCreate", "SupplierResponse",
|
||||||
"CustomerCreate", "CustomerResponse",
|
"CustomerCreate", "CustomerResponse",
|
||||||
"WarehouseCreate", "WarehouseResponse",
|
"WarehouseCreate", "WarehouseResponse",
|
||||||
@@ -39,6 +51,8 @@ __all__ = [
|
|||||||
"StockMovementCreate", "StockMovementResponse",
|
"StockMovementCreate", "StockMovementResponse",
|
||||||
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
|
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
|
||||||
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate",
|
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate",
|
||||||
|
"ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse",
|
||||||
|
"SalesOrderIssueRequest", "SalesOrderIssueResponse",
|
||||||
"FinanceAllocationCreate", "FinanceTransactionCreate",
|
"FinanceAllocationCreate", "FinanceTransactionCreate",
|
||||||
"ReceiptCreate", "PaymentCreate",
|
"ReceiptCreate", "PaymentCreate",
|
||||||
"FinanceAllocationResponse", "FinanceTransactionResponse",
|
"FinanceAllocationResponse", "FinanceTransactionResponse",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ class ProductCreate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
unit: str = "件"
|
unit: str = "件"
|
||||||
|
item_type: str = "finished"
|
||||||
cost_price: float = 0
|
cost_price: float = 0
|
||||||
sale_price: float = 0
|
sale_price: float = 0
|
||||||
min_stock: int = 0
|
min_stock: int = 0
|
||||||
@@ -22,12 +23,41 @@ class ProductResponse(BaseModel):
|
|||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
category: Optional[str]
|
category: Optional[str]
|
||||||
unit: str
|
unit: str
|
||||||
|
item_type: str
|
||||||
cost_price: float
|
cost_price: float
|
||||||
sale_price: float
|
sale_price: float
|
||||||
min_stock: int
|
min_stock: int
|
||||||
max_stock: int
|
max_stock: int
|
||||||
|
material_cost: float = 0
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
order_date: datetime
|
||||||
delivery_date: Optional[datetime]
|
delivery_date: Optional[datetime]
|
||||||
status: str
|
status: str
|
||||||
|
production_status: str
|
||||||
|
production_no: Optional[str]
|
||||||
|
planned_material_cost: float
|
||||||
|
actual_material_cost: float
|
||||||
total_amount: float
|
total_amount: float
|
||||||
received_amount: float
|
received_amount: float
|
||||||
remark: Optional[str]
|
remark: Optional[str]
|
||||||
@@ -31,3 +35,41 @@ class SalesOrderResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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()
|
product = product_by_sku_result.scalar_one_or_none()
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=404, detail="产品不存在,请选择系统中的产品")
|
raise HTTPException(status_code=404, detail="产品不存在,请选择系统中的产品")
|
||||||
|
if product.item_type != "material":
|
||||||
|
raise HTTPException(status_code=400, detail="库存仅管理物料,该条目不是物料")
|
||||||
|
|
||||||
resolved_product_id = product.id
|
resolved_product_id = product.id
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -1,5 +1,5 @@
|
|||||||
# models/database.py
|
# 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.ext.declarative import declarative_base
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -480,6 +480,7 @@ class Product(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
category = Column(String(100), nullable=True)
|
category = Column(String(100), nullable=True)
|
||||||
unit = Column(String(20), default="件")
|
unit = Column(String(20), default="件")
|
||||||
|
item_type = Column(String(20), default="finished", index=True)
|
||||||
cost_price = Column(Float, default=0)
|
cost_price = Column(Float, default=0)
|
||||||
sale_price = Column(Float, default=0)
|
sale_price = Column(Float, default=0)
|
||||||
min_stock = Column(Integer, default=0)
|
min_stock = Column(Integer, default=0)
|
||||||
@@ -490,11 +491,51 @@ class Product(Base):
|
|||||||
|
|
||||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||||
stock_movements = relationship("StockMovement", back_populates="product")
|
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):
|
def __repr__(self):
|
||||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
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):
|
class Supplier(Base):
|
||||||
"""供应商表"""
|
"""供应商表"""
|
||||||
__tablename__ = "suppliers"
|
__tablename__ = "suppliers"
|
||||||
@@ -667,6 +708,10 @@ class SalesOrder(Base):
|
|||||||
order_date = Column(DateTime, default=func.now())
|
order_date = Column(DateTime, default=func.now())
|
||||||
delivery_date = Column(DateTime, nullable=True)
|
delivery_date = Column(DateTime, nullable=True)
|
||||||
status = Column(String(20), default="draft")
|
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)
|
total_amount = Column(Float, default=0)
|
||||||
received_amount = Column(Float, default=0)
|
received_amount = Column(Float, default=0)
|
||||||
remark = Column(Text, nullable=True)
|
remark = Column(Text, nullable=True)
|
||||||
|
|||||||
+270
-15
@@ -1486,6 +1486,10 @@ const InventoryView = {
|
|||||||
customerProductStatement: [],
|
customerProductStatement: [],
|
||||||
supplierProductStatement: [],
|
supplierProductStatement: [],
|
||||||
products: [],
|
products: [],
|
||||||
|
materials: [],
|
||||||
|
productionOrders: [],
|
||||||
|
productionPlan: null,
|
||||||
|
productionWarehouseId: null,
|
||||||
suppliers: [],
|
suppliers: [],
|
||||||
customers: [],
|
customers: [],
|
||||||
warehouses: [],
|
warehouses: [],
|
||||||
@@ -1495,6 +1499,7 @@ const InventoryView = {
|
|||||||
showModal: false,
|
showModal: false,
|
||||||
modalType: '',
|
modalType: '',
|
||||||
editingItem: null,
|
editingItem: null,
|
||||||
|
productBomItems: [],
|
||||||
form: {}
|
form: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1555,7 +1560,7 @@ const InventoryView = {
|
|||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
try {
|
try {
|
||||||
state.products = await apiRequest('/api/products');
|
state.products = await apiRequest('/api/products?limit=200');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e, '加载产品');
|
handleApiError(e, '加载产品');
|
||||||
} finally {
|
} 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 () => {
|
const loadWarehouses = async () => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -1575,8 +1591,8 @@ const InventoryView = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureStockBaseData = async () => {
|
const ensureStockBaseData = async () => {
|
||||||
if (!state.products.length) {
|
if (!state.materials.length) {
|
||||||
await loadProducts();
|
await loadMaterials();
|
||||||
}
|
}
|
||||||
if (!state.warehouses.length) {
|
if (!state.warehouses.length) {
|
||||||
await loadWarehouses();
|
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 () => {
|
const loadCustomers = async () => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -1699,10 +1734,41 @@ const InventoryView = {
|
|||||||
case 'customers': loadCustomers(); break;
|
case 'customers': loadCustomers(); break;
|
||||||
case 'inventory': loadInventory(); break;
|
case 'inventory': loadInventory(); break;
|
||||||
case 'movements': loadMovements(); break;
|
case 'movements': loadMovements(); break;
|
||||||
|
case 'production': loadProductionOrders(); break;
|
||||||
case 'finance': loadFinance(); 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) => {
|
const openModal = async (type, item = null) => {
|
||||||
state.modalType = type;
|
state.modalType = type;
|
||||||
state.editingItem = item;
|
state.editingItem = item;
|
||||||
@@ -1713,12 +1779,22 @@ const InventoryView = {
|
|||||||
if (type === 'stockIn' || type === 'stockOut') {
|
if (type === 'stockIn' || type === 'stockOut') {
|
||||||
await ensureStockBaseData();
|
await ensureStockBaseData();
|
||||||
state.form = {
|
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,
|
warehouse_id: state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
movement_type: type === 'stockIn' ? 'purchase_in' : 'issue_to_production'
|
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;
|
state.showModal = true;
|
||||||
};
|
};
|
||||||
@@ -1727,6 +1803,7 @@ const InventoryView = {
|
|||||||
state.showModal = false;
|
state.showModal = false;
|
||||||
state.modalType = '';
|
state.modalType = '';
|
||||||
state.editingItem = null;
|
state.editingItem = null;
|
||||||
|
state.productBomItems = [];
|
||||||
state.form = {};
|
state.form = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1747,6 +1824,7 @@ const InventoryView = {
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
loadProducts();
|
loadProducts();
|
||||||
|
loadMaterials();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e, '保存产品');
|
handleApiError(e, '保存产品');
|
||||||
}
|
}
|
||||||
@@ -1758,11 +1836,55 @@ const InventoryView = {
|
|||||||
await apiRequest(`/api/products/${id}`, { method: 'DELETE' });
|
await apiRequest(`/api/products/${id}`, { method: 'DELETE' });
|
||||||
addNotification('产品已删除', 'success');
|
addNotification('产品已删除', 'success');
|
||||||
loadProducts();
|
loadProducts();
|
||||||
|
loadMaterials();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(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 () => {
|
const saveSupplier = async () => {
|
||||||
try {
|
try {
|
||||||
if (state.editingItem) {
|
if (state.editingItem) {
|
||||||
@@ -1877,12 +1999,19 @@ const InventoryView = {
|
|||||||
closeModal,
|
closeModal,
|
||||||
saveProduct,
|
saveProduct,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
|
editProductBom,
|
||||||
|
addBomItem,
|
||||||
|
removeBomItem,
|
||||||
|
saveProductBom,
|
||||||
saveSupplier,
|
saveSupplier,
|
||||||
deleteSupplier,
|
deleteSupplier,
|
||||||
saveCustomer,
|
saveCustomer,
|
||||||
deleteCustomer,
|
deleteCustomer,
|
||||||
stockIn,
|
stockIn,
|
||||||
stockOut,
|
stockOut,
|
||||||
|
loadProductionOrders,
|
||||||
|
loadOrderProductionPlan,
|
||||||
|
issueOrderMaterials,
|
||||||
refreshFinanceByPeriod,
|
refreshFinanceByPeriod,
|
||||||
inboundMovementOptions,
|
inboundMovementOptions,
|
||||||
outboundMovementOptions,
|
outboundMovementOptions,
|
||||||
@@ -1904,6 +2033,7 @@ const InventoryView = {
|
|||||||
<button :class="['tab', { active: state.activeTab === 'suppliers' }]" @click="switchTab('suppliers')">供应商</button>
|
<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 === 'customers' }]" @click="switchTab('customers')">客户</button>
|
||||||
<button :class="['tab', { active: state.activeTab === 'movements' }]" @click="switchTab('movements')">变动记录</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>
|
<button :class="['tab', { active: state.activeTab === 'finance' }]" @click="switchTab('finance')">财务</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1918,14 +2048,14 @@ const InventoryView = {
|
|||||||
<div class="stat-icon">📦</div>
|
<div class="stat-icon">📦</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value">{{ state.dashboard?.product_count || 0 }}</div>
|
<div class="stat-value">{{ state.dashboard?.product_count || 0 }}</div>
|
||||||
<div class="stat-label">产品数量</div>
|
<div class="stat-label">成品数量</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon">📊</div>
|
<div class="stat-icon">📊</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value">{{ state.dashboard?.total_stock || 0 }}</div>
|
<div class="stat-value">{{ state.dashboard?.total_stock || 0 }}</div>
|
||||||
<div class="stat-label">库存总量</div>
|
<div class="stat-label">物料库存总量</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -1966,25 +2096,30 @@ const InventoryView = {
|
|||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>类型</th>
|
||||||
<th>SKU</th>
|
<th>SKU</th>
|
||||||
<th>名称</th>
|
<th>名称</th>
|
||||||
<th>分类</th>
|
<th>分类</th>
|
||||||
<th>单位</th>
|
<th>单位</th>
|
||||||
<th>成本价</th>
|
<th>成本价</th>
|
||||||
<th>销售价</th>
|
<th>销售价</th>
|
||||||
|
<th>基础物料成本</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="product in state.products" :key="product.id">
|
<tr v-for="product in state.products" :key="product.id">
|
||||||
|
<td>{{ product.item_type === 'material' ? '物料' : '成品' }}</td>
|
||||||
<td>{{ product.sku }}</td>
|
<td>{{ product.sku }}</td>
|
||||||
<td>{{ product.name }}</td>
|
<td>{{ product.name }}</td>
|
||||||
<td>{{ product.category || '-' }}</td>
|
<td>{{ product.category || '-' }}</td>
|
||||||
<td>{{ product.unit }}</td>
|
<td>{{ product.unit }}</td>
|
||||||
<td>{{ formatCurrency(product.cost_price) }}</td>
|
<td>{{ formatCurrency(product.cost_price) }}</td>
|
||||||
<td>{{ formatCurrency(product.sale_price) }}</td>
|
<td>{{ formatCurrency(product.sale_price) }}</td>
|
||||||
|
<td>{{ product.item_type === 'finished' ? formatCurrency(product.material_cost || 0) : '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-btns">
|
<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-secondary" @click="openModal('product', product)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2094,6 +2229,79 @@ const InventoryView = {
|
|||||||
</div>
|
</div>
|
||||||
</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 v-else-if="state.activeTab === 'finance'">
|
||||||
<div class="table-container" style="margin-bottom: 16px;">
|
<div class="table-container" style="margin-bottom: 16px;">
|
||||||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||||
@@ -2294,7 +2502,7 @@ const InventoryView = {
|
|||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>产品</th>
|
<th>物料</th>
|
||||||
<th>类型</th>
|
<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 v-if="state.showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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>
|
<button class="modal-close" @click="closeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- 产品表单 -->
|
<!-- 产品表单 -->
|
||||||
<form v-if="state.modalType === 'product'" @submit.prevent="saveProduct">
|
<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">
|
<div class="form-group">
|
||||||
<label class="form-label">SKU *</label>
|
<label class="form-label">SKU *</label>
|
||||||
<input v-model="state.form.sku" class="form-input" required placeholder="产品编码" />
|
<input v-model="state.form.sku" class="form-input" required placeholder="产品编码" />
|
||||||
@@ -2354,16 +2569,56 @@ const InventoryView = {
|
|||||||
<label class="form-label">销售价</label>
|
<label class="form-label">销售价</label>
|
||||||
<input v-model.number="state.form.sale_price" type="number" step="0.01" class="form-input" placeholder="0.00" />
|
<input v-model.number="state.form.sale_price" type="number" step="0.01" class="form-input" placeholder="0.00" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div v-if="state.form.item_type === 'material'" class="form-group">
|
||||||
<label class="form-label">最低库存</label>
|
<label class="form-label">最低库存</label>
|
||||||
<input v-model.number="state.form.min_stock" type="number" class="form-input" placeholder="0" />
|
<input v-model.number="state.form.min_stock" type="number" class="form-input" placeholder="0" />
|
||||||
</div>
|
</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">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
||||||
<button type="submit" class="btn btn-primary">保存</button>
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<form v-else-if="state.modalType === 'supplier'" @submit.prevent="saveSupplier">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -2423,10 +2678,10 @@ const InventoryView = {
|
|||||||
<!-- 入库表单 -->
|
<!-- 入库表单 -->
|
||||||
<form v-else-if="state.modalType === 'stockIn'" @submit.prevent="stockIn">
|
<form v-else-if="state.modalType === 'stockIn'" @submit.prevent="stockIn">
|
||||||
<div class="form-group">
|
<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>
|
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||||
<option v-if="!state.products.length" :value="null" disabled>暂无产品,请先新增产品</option>
|
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
|
||||||
<option v-for="product in state.products" :key="'stockin-product-' + product.id" :value="product.id">
|
<option v-for="product in state.materials" :key="'stockin-product-' + product.id" :value="product.id">
|
||||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -2467,10 +2722,10 @@ const InventoryView = {
|
|||||||
<!-- 出库表单 -->
|
<!-- 出库表单 -->
|
||||||
<form v-else-if="state.modalType === 'stockOut'" @submit.prevent="stockOut">
|
<form v-else-if="state.modalType === 'stockOut'" @submit.prevent="stockOut">
|
||||||
<div class="form-group">
|
<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>
|
<select v-model.number="state.form.product_id" class="form-input" required>
|
||||||
<option v-if="!state.products.length" :value="null" disabled>暂无产品,请先新增产品</option>
|
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
|
||||||
<option v-for="product in state.products" :key="'stockout-product-' + product.id" :value="product.id">
|
<option v-for="product in state.materials" :key="'stockout-product-' + product.id" :value="product.id">
|
||||||
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user