From d2bcea7810185a8e58d95ca733b2f6724133f46b Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Sun, 15 Mar 2026 22:50:38 +0800 Subject: [PATCH] x --- src/api/inventory/dashboard_routes.py | 18 +- src/api/inventory/inventory_routes.py | 1 + src/api/inventory/product_routes.py | 183 ++++++++++- src/api/inventory/purchase_order_routes.py | 10 +- src/api/inventory/sales_order_routes.py | 266 ++++++++++++++-- src/api/inventory/schemas/__init__.py | 20 +- src/api/inventory/schemas/product_schemas.py | 32 +- .../inventory/schemas/sales_order_schemas.py | 42 +++ src/api/inventory/stock_movement_routes.py | 2 + src/models/database.py | 47 ++- static/vue-app.js | 285 +++++++++++++++++- 11 files changed, 848 insertions(+), 58 deletions(-) diff --git a/src/api/inventory/dashboard_routes.py b/src/api/inventory/dashboard_routes.py index 2c5ed61..b5cd509 100644 --- a/src/api/inventory/dashboard_routes.py +++ b/src/api/inventory/dashboard_routes.py @@ -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, diff --git a/src/api/inventory/inventory_routes.py b/src/api/inventory/inventory_routes.py index 881cede..ae5f4cb 100644 --- a/src/api/inventory/inventory_routes.py +++ b/src/api/inventory/inventory_routes.py @@ -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) ) diff --git a/src/api/inventory/product_routes.py b/src/api/inventory/product_routes.py index 8e993cc..39cabae 100644 --- a/src/api/inventory/product_routes.py +++ b/src/api/inventory/product_routes.py @@ -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) diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index 7eb41f8..e82475b 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -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, diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py index e91bc58..474284a 100644 --- a/src/api/inventory/sales_order_routes.py +++ b/src/api/inventory/sales_order_routes.py @@ -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, ) diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py index 6c18655..cb6b0c8 100644 --- a/src/api/inventory/schemas/__init__.py +++ b/src/api/inventory/schemas/__init__.py @@ -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", diff --git a/src/api/inventory/schemas/product_schemas.py b/src/api/inventory/schemas/product_schemas.py index 496ec21..81cdd34 100644 --- a/src/api/inventory/schemas/product_schemas.py +++ b/src/api/inventory/schemas/product_schemas.py @@ -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] diff --git a/src/api/inventory/schemas/sales_order_schemas.py b/src/api/inventory/schemas/sales_order_schemas.py index 6527b6c..78a5def 100644 --- a/src/api/inventory/schemas/sales_order_schemas.py +++ b/src/api/inventory/schemas/sales_order_schemas.py @@ -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 diff --git a/src/api/inventory/stock_movement_routes.py b/src/api/inventory/stock_movement_routes.py index 7917d8d..28f36b9 100644 --- a/src/api/inventory/stock_movement_routes.py +++ b/src/api/inventory/stock_movement_routes.py @@ -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 diff --git a/src/models/database.py b/src/models/database.py index 9e95636..6e2007e 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -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"" +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"" + + 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) diff --git a/static/vue-app.js b/static/vue-app.js index 4c7623b..d3d6d9d 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -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 = { + @@ -1918,14 +2048,14 @@ const InventoryView = {
📦
{{ state.dashboard?.product_count || 0 }}
-
产品数量
+
成品数量
📊
{{ state.dashboard?.total_stock || 0 }}
-
库存总量
+
物料库存总量
@@ -1966,25 +2096,30 @@ const InventoryView = { + + + +
类型 SKU 名称 分类 单位 成本价 销售价基础物料成本 操作
{{ product.item_type === 'material' ? '物料' : '成品' }} {{ product.sku }} {{ product.name }} {{ product.category || '-' }} {{ product.unit }} {{ formatCurrency(product.cost_price) }} {{ formatCurrency(product.sale_price) }}{{ product.item_type === 'finished' ? formatCurrency(product.material_cost || 0) : '-' }}
+
@@ -2094,6 +2229,79 @@ const InventoryView = { +
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
销售单客户生产单号生产状态计划物料成本实际领料成本操作
{{ order.order_no }}{{ order.customer_name }}{{ order.production_no || '-' }}{{ order.production_status || '-' }}{{ formatCurrency(order.planned_material_cost || 0) }}{{ formatCurrency(order.actual_material_cost || 0) }} +
+ + +
+
+
+
+

领料建议:{{ state.productionPlan.order_no }}({{ state.productionPlan.production_no }})

+
+ 计划物料成本:{{ formatCurrency(state.productionPlan.planned_material_cost || 0) }} +
+ + + + + + + + + + + + + + + + + + + + + +
物料需求可用缺口单位成本需求成本
{{ item.material_sku }} - {{ item.material_name }}{{ item.required_quantity }}{{ item.available_quantity }}{{ item.shortage_quantity }}{{ formatCurrency(item.unit_cost) }}{{ formatCurrency(item.required_cost) }}
+
+
+
@@ -2294,7 +2502,7 @@ const InventoryView = { - + @@ -2324,12 +2532,19 @@ const InventoryView = {
产品物料 类型 数量 变动前
+ + + + + + + + + + + + + + + + +
物料数量损耗率操作
+ +
+
+ +
@@ -2423,10 +2678,10 @@ const InventoryView = {
- + @@ -2467,10 +2722,10 @@ const InventoryView = {
- +