diff --git a/src/api/inventory/inventory_routes.py b/src/api/inventory/inventory_routes.py index ae5f4cb..01d2259 100644 --- a/src/api/inventory/inventory_routes.py +++ b/src/api/inventory/inventory_routes.py @@ -7,7 +7,7 @@ 路由前缀: /api/inventory """ -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 @@ -15,7 +15,7 @@ from typing import Optional, List from database.database import get_db_session from services.auth_service import get_current_active_user from models.database import User, Product, Warehouse, Inventory -from .schemas import InventoryResponse +from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate router = APIRouter(prefix="/inventory", tags=["库存管理"]) @@ -64,3 +64,129 @@ async def list_inventory( )) return inventory_list + + +@router.post("", response_model=InventoryResponse, status_code=201) +async def create_inventory( + payload: InventoryCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + if payload.quantity < 0 or payload.locked_quantity < 0: + raise HTTPException(status_code=400, detail="库存数量不能为负数") + if payload.locked_quantity > payload.quantity: + raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量") + + product_result = await db_session.execute( + select(Product).where(Product.id == payload.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 != "material": + 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="仓库不存在") + + exists_result = await db_session.execute( + select(Inventory).where( + Inventory.product_id == payload.product_id, + Inventory.warehouse_id == payload.warehouse_id + ) + ) + if exists_result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录") + + inventory = Inventory( + product_id=payload.product_id, + warehouse_id=payload.warehouse_id, + quantity=payload.quantity, + locked_quantity=payload.locked_quantity, + batch_number=payload.batch_number, + location=payload.location + ) + db_session.add(inventory) + await db_session.commit() + await db_session.refresh(inventory) + + return InventoryResponse( + id=inventory.id, + product_id=product.id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=warehouse.id, + warehouse_name=warehouse.name, + quantity=inventory.quantity, + locked_quantity=inventory.locked_quantity, + available_quantity=inventory.available_quantity + ) + + +@router.put("/{inventory_id}", response_model=InventoryResponse) +async def update_inventory( + inventory_id: int, + payload: InventoryUpdate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute( + select(Inventory, Product, Warehouse) + .join(Product, Inventory.product_id == Product.id) + .join(Warehouse, Inventory.warehouse_id == Warehouse.id) + .where(Inventory.id == inventory_id) + .where(Product.item_type == "material") + ) + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="库存记录不存在") + inventory, product, warehouse = row + + if payload.quantity is not None: + if payload.quantity < 0: + raise HTTPException(status_code=400, detail="库存数量不能为负数") + inventory.quantity = payload.quantity + if payload.locked_quantity is not None: + if payload.locked_quantity < 0: + raise HTTPException(status_code=400, detail="锁定数量不能为负数") + inventory.locked_quantity = payload.locked_quantity + if inventory.locked_quantity > inventory.quantity: + raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量") + + if payload.batch_number is not None: + inventory.batch_number = payload.batch_number + if payload.location is not None: + inventory.location = payload.location + + await db_session.commit() + await db_session.refresh(inventory) + return InventoryResponse( + id=inventory.id, + product_id=product.id, + product_name=product.name, + product_sku=product.sku, + warehouse_id=warehouse.id, + warehouse_name=warehouse.name, + quantity=inventory.quantity, + locked_quantity=inventory.locked_quantity, + available_quantity=inventory.available_quantity + ) + + +@router.delete("/{inventory_id}") +async def delete_inventory( + inventory_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id)) + inventory = result.scalar_one_or_none() + if not inventory: + raise HTTPException(status_code=404, detail="库存记录不存在") + await db_session.delete(inventory) + await db_session.commit() + return {"message": "库存记录已删除"} diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index e82475b..242656f 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -15,13 +15,148 @@ from typing import Optional, List from database.database import get_db_session from services.auth_service import get_current_active_user -from models.database import User, Supplier, Product, PurchaseOrder, PurchaseOrderItem -from .schemas import PurchaseOrderCreate, PurchaseOrderResponse +from models.database import ( + User, + Supplier, + Product, + Warehouse, + Inventory, + StockMovement, + PurchaseOrder, + PurchaseOrderItem +) +from .schemas import ( + PurchaseOrderCreate, + PurchaseOrderResponse, + PurchaseOrderDetailResponse, + PurchaseOrderItemResponse, + PurchaseOrderReceiveRequest, +) from .utils import generate_order_no router = APIRouter(prefix="/purchase-orders", tags=["采购订单"]) +def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse: + return PurchaseOrderResponse( + id=order.id, + order_no=order.order_no, + supplier_name=supplier_name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at + ) + + +async def _get_order_with_supplier( + db_session: AsyncSession, + order_id: int +) -> tuple[PurchaseOrder, Supplier]: + result = await db_session.execute( + select(PurchaseOrder, Supplier) + .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) + .where(PurchaseOrder.id == order_id) + ) + row = result.first() + if not row: + raise HTTPException(status_code=404, detail="采购订单不存在") + return row[0], row[1] + + +async def _build_purchase_order_detail( + db_session: AsyncSession, + order: PurchaseOrder, + supplier_name: str +) -> PurchaseOrderDetailResponse: + item_result = await db_session.execute( + select(PurchaseOrderItem, Product) + .join(Product, PurchaseOrderItem.product_id == Product.id) + .where(PurchaseOrderItem.order_id == order.id) + .order_by(PurchaseOrderItem.id.asc()) + ) + item_rows = item_result.all() + return PurchaseOrderDetailResponse( + id=order.id, + order_no=order.order_no, + supplier_id=order.supplier_id, + supplier_name=supplier_name, + order_date=order.order_date, + expected_date=order.expected_date, + status=order.status, + total_amount=order.total_amount, + paid_amount=order.paid_amount, + remark=order.remark, + created_at=order.created_at, + items=[ + PurchaseOrderItemResponse( + id=item.id, + product_id=item.product_id, + product_sku=product.sku, + product_name=product.name, + quantity=item.quantity, + received_quantity=item.received_quantity, + unit_price=item.unit_price, + amount=item.amount, + remark=item.remark + ) for item, product in item_rows + ] + ) + + +async def _resolve_receive_warehouse( + db_session: AsyncSession, + warehouse_id: Optional[int] +) -> Warehouse: + if warehouse_id: + result = await db_session.execute( + select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True) + ) + warehouse = result.scalar_one_or_none() + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + return warehouse + + result = await db_session.execute( + select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc()) + ) + warehouse = result.scalars().first() + if not warehouse: + raise HTTPException(status_code=400, detail="未配置可用仓库") + return warehouse + + +async def _apply_order_items( + db_session: AsyncSession, + order: PurchaseOrder, + order_data: PurchaseOrderCreate +) -> float: + total_amount = 0.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, + quantity=item_data.quantity, + unit_price=item_data.unit_price, + amount=item_data.quantity * item_data.unit_price, + remark=item_data.remark + ) + db_session.add(item) + total_amount += item.amount + return total_amount + + @router.get("", response_model=List[PurchaseOrderResponse]) async def list_purchase_orders( status: Optional[str] = None, @@ -44,18 +179,7 @@ async def list_purchase_orders( orders = [] for order, supplier in result.all(): - orders.append(PurchaseOrderResponse( - id=order.id, - order_no=order.order_no, - supplier_name=supplier.name, - order_date=order.order_date, - expected_date=order.expected_date, - status=order.status, - total_amount=order.total_amount, - paid_amount=order.paid_amount, - remark=order.remark, - created_at=order.created_at - )) + orders.append(_build_purchase_order_response(order, supplier.name)) return orders @@ -77,43 +201,163 @@ async def create_purchase_order( db_session.add(order) await db_session.flush() - 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, - quantity=item_data.quantity, - unit_price=item_data.unit_price, - amount=item_data.quantity * item_data.unit_price, - remark=item_data.remark - ) - db_session.add(item) - total_amount += item.amount - - order.total_amount = total_amount + order.total_amount = await _apply_order_items(db_session, order, order_data) await db_session.commit() await db_session.refresh(order) supplier = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) supplier = supplier.scalar_one() - return PurchaseOrderResponse( - id=order.id, - order_no=order.order_no, - supplier_name=supplier.name, - order_date=order.order_date, - expected_date=order.expected_date, - status=order.status, - total_amount=order.total_amount, - paid_amount=order.paid_amount, - remark=order.remark, - created_at=order.created_at + return _build_purchase_order_response(order, supplier.name) + + +@router.get("/{order_id}", response_model=PurchaseOrderDetailResponse) +async def get_purchase_order_detail( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, supplier = await _get_order_with_supplier(db_session, order_id) + return await _build_purchase_order_detail(db_session, order, supplier.name) + + +@router.put("/{order_id}", response_model=PurchaseOrderResponse) +async def update_purchase_order( + order_id: int, + order_data: PurchaseOrderCreate, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_order_with_supplier(db_session, order_id) + if order.paid_amount and order.paid_amount > 0: + raise HTTPException(status_code=400, detail="已付款采购单不允许修改") + + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) ) + existing_items = item_result.scalars().all() + if any((item.received_quantity or 0) > 0 for item in existing_items): + raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改") + + for item in existing_items: + await db_session.delete(item) + + order.supplier_id = order_data.supplier_id + order.expected_date = order_data.expected_date + order.remark = order_data.remark + order.total_amount = await _apply_order_items(db_session, order, order_data) + order.status = "draft" + + await db_session.commit() + await db_session.refresh(order) + supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) + supplier = supplier_result.scalar_one_or_none() + return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商") + + +@router.delete("/{order_id}") +async def delete_purchase_order( + order_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, _ = await _get_order_with_supplier(db_session, order_id) + if order.paid_amount and order.paid_amount > 0: + raise HTTPException(status_code=400, detail="已付款采购单不允许删除") + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) + ) + if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()): + raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除") + await db_session.delete(order) + await db_session.commit() + return {"message": "采购订单已删除"} + + +@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse) +async def receive_purchase_order( + order_id: int, + payload: PurchaseOrderReceiveRequest, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_current_active_user) +): + order, supplier = await _get_order_with_supplier(db_session, order_id) + warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id) + + item_result = await db_session.execute( + select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id) + ) + item_map = {item.id: item for item in item_result.scalars().all()} + if not item_map: + raise HTTPException(status_code=400, detail="采购单无明细,无法入库") + + if not payload.items: + raise HTTPException(status_code=400, detail="请提供本次入库明细") + + for receive_item in payload.items: + item = item_map.get(receive_item.item_id) + if not item: + raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}") + if receive_item.receive_quantity <= 0: + raise HTTPException(status_code=400, detail="入库数量必须大于0") + remaining_qty = (item.quantity or 0) - (item.received_quantity or 0) + if receive_item.receive_quantity > remaining_qty: + raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}") + + for receive_item in payload.items: + item = item_map[receive_item.item_id] + product_result = await db_session.execute( + select(Product).where(Product.id == item.product_id) + ) + product = product_result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}") + + inv_result = await db_session.execute( + select(Inventory) + .where(Inventory.product_id == item.product_id) + .where(Inventory.warehouse_id == warehouse.id) + ) + inventory = inv_result.scalar_one_or_none() + if not inventory: + inventory = Inventory( + product_id=item.product_id, + warehouse_id=warehouse.id, + quantity=0, + locked_quantity=0 + ) + db_session.add(inventory) + await db_session.flush() + + before_qty = inventory.quantity + inventory.quantity += receive_item.receive_quantity + after_qty = inventory.quantity + item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity + + movement = StockMovement( + product_id=item.product_id, + warehouse_id=warehouse.id, + movement_type="purchase_in", + quantity=receive_item.receive_quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="purchase_order", + reference_id=order.id, + reference_no=order.order_no, + unit_price=item.unit_price, + total_amount=round(float(item.unit_price * receive_item.receive_quantity), 4), + remark=payload.remark or f"采购单{order.order_no}到货入库", + operator_id=current_user.id + ) + db_session.add(movement) + + all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values()) + any_received = any((item.received_quantity or 0) > 0 for item in item_map.values()) + if all_received: + order.status = "received" + elif any_received: + order.status = "partial_received" + + await db_session.commit() + await db_session.refresh(order) + return await _build_purchase_order_detail(db_session, order, supplier.name) diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py index 474284a..30816a6 100644 --- a/src/api/inventory/sales_order_routes.py +++ b/src/api/inventory/sales_order_routes.py @@ -10,7 +10,7 @@ """ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +from sqlalchemy import select, func, delete from typing import Optional, List from math import ceil @@ -30,6 +30,8 @@ from models.database import ( from .schemas import ( SalesOrderCreate, SalesOrderResponse, + SalesOrderDetailResponse, + SalesOrderItemResponse, SalesOrderProductionPlanResponse, ProductionMaterialPlanItemResponse, SalesOrderIssueRequest, @@ -59,6 +61,45 @@ def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesO ) +async def _build_sales_order_detail_response( + db_session: AsyncSession, + order: SalesOrder, + customer_name: str +) -> SalesOrderDetailResponse: + items_result = await db_session.execute( + select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc()) + ) + items = items_result.scalars().all() + return SalesOrderDetailResponse( + id=order.id, + order_no=order.order_no, + customer_id=order.customer_id, + 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, + items=[ + SalesOrderItemResponse( + id=item.id, + product_id=item.product_id, + quantity=item.quantity, + delivered_quantity=item.delivered_quantity, + unit_price=item.unit_price, + amount=item.amount, + remark=item.remark + ) for item in items + ] + ) + + async def _get_sales_order_with_customer( db_session: AsyncSession, order_id: int, @@ -143,6 +184,160 @@ async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> t return plan_items, planned_material_cost +async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse: + warehouse_result = await db_session.execute( + select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc()) + ) + warehouse = warehouse_result.scalars().first() + if not warehouse: + raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料") + return warehouse + + +async def _issue_materials_for_order_creation( + db_session: AsyncSession, + order: SalesOrder, + current_user: User +) -> tuple[int, float, float]: + warehouse = await _get_default_warehouse(db_session) + 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 = 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=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) + order.status = "pending" + + return movement_count, planned_material_cost, actual_material_cost + + +async def _rollback_issued_materials( + db_session: AsyncSession, + order: SalesOrder, + current_user: User +): + movement_result = await db_session.execute( + select(StockMovement) + .where(StockMovement.reference_type == "sales_order") + .where(StockMovement.reference_id == order.id) + .where(StockMovement.movement_type == "issue_to_production") + .order_by(StockMovement.id.asc()) + ) + movements = movement_result.scalars().all() + if not movements: + return + + for movement in movements: + inv_result = await db_session.execute( + select(Inventory) + .where(Inventory.product_id == movement.product_id) + .where(Inventory.warehouse_id == movement.warehouse_id) + ) + inventory = inv_result.scalar_one_or_none() + if not inventory: + inventory = Inventory( + product_id=movement.product_id, + warehouse_id=movement.warehouse_id, + quantity=0, + locked_quantity=0 + ) + db_session.add(inventory) + await db_session.flush() + + before_qty = inventory.quantity + inventory.quantity += movement.quantity + after_qty = inventory.quantity + + revert_movement = StockMovement( + product_id=movement.product_id, + warehouse_id=movement.warehouse_id, + movement_type="return_from_production", + quantity=movement.quantity, + before_quantity=before_qty, + after_quantity=after_qty, + reference_type="sales_order", + reference_id=order.id, + reference_no=order.production_no or order.order_no, + unit_price=movement.unit_price, + total_amount=movement.total_amount, + remark=f"销售单{order.order_no}变更/删除,自动回补物料", + operator_id=current_user.id + ) + db_session.add(revert_movement) + + +async def _apply_order_items( + db_session: AsyncSession, + order: SalesOrder, + order_data: SalesOrderCreate +) -> float: + total_amount = 0.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, + quantity=item_data.quantity, + unit_price=item_data.unit_price, + amount=item_data.quantity * item_data.unit_price, + remark=item_data.remark + ) + db_session.add(item) + total_amount += item.amount + return total_amount + + @router.get("", response_model=List[SalesOrderResponse]) async def list_sales_orders( status: Optional[str] = None, @@ -187,28 +382,8 @@ async def create_sales_order( db_session.add(order) await db_session.flush() - 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, - quantity=item_data.quantity, - unit_price=item_data.unit_price, - amount=item_data.quantity * item_data.unit_price, - remark=item_data.remark - ) - db_session.add(item) - total_amount += item.amount - - order.total_amount = total_amount + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) await db_session.commit() await db_session.refresh(order) @@ -218,6 +393,59 @@ async def create_sales_order( return _build_sales_order_response(order, customer.name) +@router.get("/{order_id}", response_model=SalesOrderDetailResponse) +async def get_sales_order_detail( + 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) + return await _build_sales_order_detail_response(db_session, order, customer.name) + + +@router.put("/{order_id}", response_model=SalesOrderResponse) +async def update_sales_order( + order_id: int, + order_data: SalesOrderCreate, + 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) + await _rollback_issued_materials(db_session, order, current_user) + await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id)) + + order.customer_id = order_data.customer_id + order.delivery_date = order_data.delivery_date + order.remark = order_data.remark + order.production_status = "not_started" + order.production_no = None + order.planned_material_cost = 0 + order.actual_material_cost = 0 + + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) + await db_session.commit() + await db_session.refresh(order) + + customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) + updated_customer = customer_result.scalar_one_or_none() + customer_name = updated_customer.name if updated_customer else "未知客户" + return _build_sales_order_response(order, customer_name) + + +@router.delete("/{order_id}") +async def delete_sales_order( + order_id: int, + 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) + await _rollback_issued_materials(db_session, order, current_user) + await db_session.delete(order) + await db_session.commit() + return {"message": "销售订单已删除"} + + @router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse) async def get_sales_order_production_plan( order_id: int, @@ -248,6 +476,8 @@ async def issue_sales_order_materials( order, _ = await _get_sales_order_with_customer(db_session, order_id) if order.production_status == "completed": raise HTTPException(status_code=400, detail="该销售单已完成生产") + if order.production_status == "material_issued": + raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料") warehouse_result = await db_session.execute( select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True) diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py index cb6b0c8..4e75b40 100644 --- a/src/api/inventory/schemas/__init__.py +++ b/src/api/inventory/schemas/__init__.py @@ -9,17 +9,23 @@ from .product_schemas import ( from .supplier_schemas import SupplierCreate, SupplierResponse from .customer_schemas import CustomerCreate, CustomerResponse from .warehouse_schemas import WarehouseCreate, WarehouseResponse -from .inventory_schemas import InventoryResponse +from .inventory_schemas import InventoryResponse, InventoryCreate, InventoryUpdate from .stock_movement_schemas import StockMovementCreate, StockMovementResponse from .purchase_order_schemas import ( PurchaseOrderCreate, PurchaseOrderResponse, - PurchaseOrderItemCreate + PurchaseOrderItemCreate, + PurchaseOrderItemResponse, + PurchaseOrderDetailResponse, + PurchaseOrderReceiveItem, + PurchaseOrderReceiveRequest ) from .sales_order_schemas import ( SalesOrderCreate, SalesOrderResponse, SalesOrderItemCreate, + SalesOrderItemResponse, + SalesOrderDetailResponse, ProductionMaterialPlanItemResponse, SalesOrderProductionPlanResponse, SalesOrderIssueRequest, @@ -47,10 +53,11 @@ __all__ = [ "SupplierCreate", "SupplierResponse", "CustomerCreate", "CustomerResponse", "WarehouseCreate", "WarehouseResponse", - "InventoryResponse", + "InventoryResponse", "InventoryCreate", "InventoryUpdate", "StockMovementCreate", "StockMovementResponse", "PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate", - "SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", + "PurchaseOrderItemResponse", "PurchaseOrderDetailResponse", "PurchaseOrderReceiveItem", "PurchaseOrderReceiveRequest", + "SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", "SalesOrderItemResponse", "SalesOrderDetailResponse", "ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse", "SalesOrderIssueRequest", "SalesOrderIssueResponse", "FinanceAllocationCreate", "FinanceTransactionCreate", diff --git a/src/api/inventory/schemas/inventory_schemas.py b/src/api/inventory/schemas/inventory_schemas.py index fde63ba..85078e5 100644 --- a/src/api/inventory/schemas/inventory_schemas.py +++ b/src/api/inventory/schemas/inventory_schemas.py @@ -1,4 +1,5 @@ from pydantic import BaseModel +from typing import Optional class InventoryResponse(BaseModel): @@ -14,3 +15,19 @@ class InventoryResponse(BaseModel): class Config: from_attributes = True + + +class InventoryCreate(BaseModel): + product_id: int + warehouse_id: int + quantity: int = 0 + locked_quantity: int = 0 + batch_number: Optional[str] = None + location: Optional[str] = None + + +class InventoryUpdate(BaseModel): + quantity: Optional[int] = None + locked_quantity: Optional[int] = None + batch_number: Optional[str] = None + location: Optional[str] = None diff --git a/src/api/inventory/schemas/purchase_order_schemas.py b/src/api/inventory/schemas/purchase_order_schemas.py index b19e5b7..ea093d4 100644 --- a/src/api/inventory/schemas/purchase_order_schemas.py +++ b/src/api/inventory/schemas/purchase_order_schemas.py @@ -31,3 +31,31 @@ class PurchaseOrderResponse(BaseModel): class Config: from_attributes = True + + +class PurchaseOrderItemResponse(BaseModel): + id: int + product_id: int + product_sku: str + product_name: str + quantity: int + received_quantity: int + unit_price: float + amount: float + remark: Optional[str] = None + + +class PurchaseOrderDetailResponse(PurchaseOrderResponse): + supplier_id: int + items: List[PurchaseOrderItemResponse] + + +class PurchaseOrderReceiveItem(BaseModel): + item_id: int + receive_quantity: int + + +class PurchaseOrderReceiveRequest(BaseModel): + warehouse_id: Optional[int] = None + items: List[PurchaseOrderReceiveItem] + remark: Optional[str] = None diff --git a/src/api/inventory/schemas/sales_order_schemas.py b/src/api/inventory/schemas/sales_order_schemas.py index 78a5def..432f2c7 100644 --- a/src/api/inventory/schemas/sales_order_schemas.py +++ b/src/api/inventory/schemas/sales_order_schemas.py @@ -37,6 +37,21 @@ class SalesOrderResponse(BaseModel): from_attributes = True +class SalesOrderItemResponse(BaseModel): + id: int + product_id: int + quantity: int + delivered_quantity: int + unit_price: float + amount: float + remark: Optional[str] = None + + +class SalesOrderDetailResponse(SalesOrderResponse): + customer_id: int + items: List[SalesOrderItemResponse] + + class ProductionMaterialPlanItemResponse(BaseModel): material_id: int material_sku: str diff --git a/static/vue-app.js b/static/vue-app.js index 65badec..1daa8c7 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1487,6 +1487,9 @@ const InventoryView = { supplierProductStatement: [], products: [], materials: [], + purchaseOrders: [], + purchaseWarehouseId: null, + purchaseReceiveItems: [], productionOrders: [], productionPlan: null, productionWarehouseId: null, @@ -1503,22 +1506,6 @@ const InventoryView = { form: {} }); - const inboundMovementOptions = [ - { value: 'purchase_in', label: '采购入库' }, - { value: 'return_from_production', label: '生产退料入库' }, - { value: 'outsource_return', label: '外协回库' }, - { value: 'finish_in', label: '完工入库' }, - { value: 'in', label: '其他入库' } - ]; - - const outboundMovementOptions = [ - { value: 'issue_to_production', label: '生产领料出库' }, - { value: 'outsource_send', label: '外协发料出库' }, - { value: 'shipment_out', label: '销售出库' }, - { value: 'scrap_out', label: '报废出库' }, - { value: 'out', label: '其他出库' } - ]; - const getMovementTypeLabel = (movementType) => { const movementLabelMap = { in: '其他入库', @@ -1643,6 +1630,25 @@ const InventoryView = { } }; + const loadPurchaseOrders = async () => { + state.loading = true; + try { + const [orders, warehouses] = await Promise.all([ + apiRequest('/api/purchase-orders?limit=100'), + apiRequest('/api/warehouses') + ]); + state.purchaseOrders = orders || []; + state.warehouses = warehouses || []; + if (!state.purchaseWarehouseId) { + state.purchaseWarehouseId = 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 { @@ -1734,6 +1740,7 @@ const InventoryView = { case 'customers': loadCustomers(); break; case 'inventory': loadInventory(); break; case 'movements': loadMovements(); break; + case 'purchases': loadPurchaseOrders(); break; case 'production': loadProductionOrders(); break; case 'finance': loadFinance(); break; } @@ -1773,16 +1780,65 @@ const InventoryView = { state.modalType = type; state.editingItem = item; if (item) { - state.form = { ...item }; + if (type === 'salesOrder') { + await loadCustomers(); + await loadProducts(); + const detail = await apiRequest(`/api/sales-orders/${item.id}`); + state.form = { + customer_id: detail.customer_id, + delivery_date: detail.delivery_date ? new Date(detail.delivery_date).toISOString().slice(0, 16) : '', + remark: detail.remark || '', + items: (detail.items || []).map(line => ({ + product_id: line.product_id, + quantity: line.quantity, + unit_price: line.unit_price, + remark: line.remark || '' + })) + }; + } else if (type === 'purchaseOrder') { + await loadSuppliers(); + await loadMaterials(); + const detail = await apiRequest(`/api/purchase-orders/${item.id}`); + state.form = { + supplier_id: detail.supplier_id, + expected_date: detail.expected_date ? new Date(detail.expected_date).toISOString().slice(0, 16) : '', + remark: detail.remark || '', + items: (detail.items || []).map(line => ({ + product_id: line.product_id, + quantity: line.quantity, + unit_price: line.unit_price, + remark: line.remark || '' + })) + }; + } else if (type === 'purchaseReceive') { + await loadWarehouses(); + const detail = await apiRequest(`/api/purchase-orders/${item.id}`); + state.purchaseReceiveItems = (detail.items || []) + .map(line => ({ + item_id: line.id, + material_label: `${line.product_sku || line.product_id} - ${line.product_name || ''}`.trim(), + remaining_quantity: Math.max((line.quantity || 0) - (line.received_quantity || 0), 0), + receive_quantity: Math.max((line.quantity || 0) - (line.received_quantity || 0), 0) + })) + .filter(line => line.remaining_quantity > 0); + state.form = { + warehouse_id: state.purchaseWarehouseId || state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null, + remark: '' + }; + } else { + state.form = { ...item }; + } } else { state.form = {}; - if (type === 'stockIn' || type === 'stockOut') { + if (type === 'inventoryItem') { await ensureStockBaseData(); state.form = { 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' + quantity: 0, + locked_quantity: 0, + batch_number: '', + location: '' }; } if (type === 'product') { @@ -1795,6 +1851,36 @@ const InventoryView = { sale_price: 0 }; } + if (type === 'salesOrder') { + await loadCustomers(); + await loadProducts(); + state.form = { + customer_id: state.customers[0]?.id || null, + delivery_date: '', + remark: '', + items: [{ + product_id: state.products.find(p => p.item_type === 'finished')?.id || null, + quantity: 1, + unit_price: 0, + remark: '' + }] + }; + } + if (type === 'purchaseOrder') { + await loadSuppliers(); + await loadMaterials(); + state.form = { + supplier_id: state.suppliers[0]?.id || null, + expected_date: '', + remark: '', + items: [{ + product_id: state.materials[0]?.id || null, + quantity: 1, + unit_price: 0, + remark: '' + }] + }; + } } state.showModal = true; }; @@ -1804,6 +1890,7 @@ const InventoryView = { state.modalType = ''; state.editingItem = null; state.productBomItems = []; + state.purchaseReceiveItems = []; state.form = {}; }; @@ -1951,33 +2038,193 @@ const InventoryView = { } }; - const stockIn = async () => { + const saveInventoryItem = async () => { try { - await apiRequest('/api/stock-movements', { - method: 'POST', - body: JSON.stringify({ ...state.form, movement_type: state.form.movement_type || 'purchase_in' }) - }); - addNotification('入库成功', 'success'); + if (state.editingItem) { + await apiRequest(`/api/inventory/${state.editingItem.id}`, { + method: 'PUT', + body: JSON.stringify({ + quantity: state.form.quantity, + locked_quantity: state.form.locked_quantity, + batch_number: state.form.batch_number, + location: state.form.location + }) + }); + addNotification('物料库存更新成功', 'success'); + } else { + await apiRequest('/api/inventory', { + method: 'POST', + body: JSON.stringify(state.form) + }); + addNotification('物料库存创建成功', 'success'); + } closeModal(); loadInventory(); - loadMovements(); } catch (e) { - handleApiError(e, '入库操作'); + handleApiError(e, '保存物料库存'); } }; - const stockOut = async () => { + const deleteInventoryItem = async (id) => { + if (!confirm('确定要删除这个物料库存记录吗?')) return; try { - await apiRequest('/api/stock-movements', { - method: 'POST', - body: JSON.stringify({ ...state.form, movement_type: state.form.movement_type || 'issue_to_production' }) - }); - addNotification('出库成功', 'success'); + await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' }); + addNotification('物料库存已删除', 'success'); + loadInventory(); + } catch (e) { + handleApiError(e, '删除物料库存'); + } + }; + + const addSalesOrderItem = () => { + state.form.items = state.form.items || []; + state.form.items.push({ + product_id: state.products.find(p => p.item_type === 'finished')?.id || null, + quantity: 1, + unit_price: 0, + remark: '' + }); + }; + + const removeSalesOrderItem = (index) => { + state.form.items.splice(index, 1); + }; + + const saveSalesOrder = async () => { + try { + if (!state.form.items || !state.form.items.length) { + addNotification('请至少添加一个成品明细', 'warning'); + return; + } + const payload = { + customer_id: state.form.customer_id, + delivery_date: state.form.delivery_date ? new Date(state.form.delivery_date).toISOString() : null, + remark: state.form.remark, + items: state.form.items + }; + if (state.editingItem) { + await apiRequest(`/api/sales-orders/${state.editingItem.id}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + addNotification('销售订单更新成功', 'success'); + } else { + await apiRequest('/api/sales-orders', { + method: 'POST', + body: JSON.stringify(payload) + }); + addNotification('销售订单创建成功并已自动扣减物料', 'success'); + } closeModal(); + loadProductionOrders(); loadInventory(); loadMovements(); } catch (e) { - handleApiError(e, '出库操作'); + handleApiError(e, '保存销售订单'); + } + }; + + const deleteSalesOrder = async (orderId) => { + if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return; + try { + await apiRequest(`/api/sales-orders/${orderId}`, { method: 'DELETE' }); + addNotification('销售订单已删除并回补物料', 'success'); + if (state.productionPlan?.sales_order_id === orderId) { + state.productionPlan = null; + } + loadProductionOrders(); + loadInventory(); + loadMovements(); + } catch (e) { + handleApiError(e, '删除销售订单'); + } + }; + + const addPurchaseOrderItem = () => { + state.form.items = state.form.items || []; + state.form.items.push({ + product_id: state.materials[0]?.id || null, + quantity: 1, + unit_price: 0, + remark: '' + }); + }; + + const removePurchaseOrderItem = (index) => { + state.form.items.splice(index, 1); + }; + + const savePurchaseOrder = async () => { + try { + if (!state.form.items || !state.form.items.length) { + addNotification('请至少添加一个物料明细', 'warning'); + return; + } + const payload = { + supplier_id: state.form.supplier_id, + expected_date: state.form.expected_date ? new Date(state.form.expected_date).toISOString() : null, + remark: state.form.remark, + items: state.form.items + }; + if (state.editingItem) { + await apiRequest(`/api/purchase-orders/${state.editingItem.id}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + addNotification('采购订单更新成功', 'success'); + } else { + await apiRequest('/api/purchase-orders', { + method: 'POST', + body: JSON.stringify(payload) + }); + addNotification('采购订单创建成功', 'success'); + } + closeModal(); + loadPurchaseOrders(); + } catch (e) { + handleApiError(e, '保存采购订单'); + } + }; + + const deletePurchaseOrder = async (orderId) => { + if (!confirm('确定删除这个采购订单吗?')) return; + try { + await apiRequest(`/api/purchase-orders/${orderId}`, { method: 'DELETE' }); + addNotification('采购订单已删除', 'success'); + loadPurchaseOrders(); + } catch (e) { + handleApiError(e, '删除采购订单'); + } + }; + + const receivePurchaseOrder = async () => { + try { + if (!state.editingItem?.id) return; + const items = (state.purchaseReceiveItems || []) + .filter(line => Number(line.receive_quantity) > 0) + .map(line => ({ + item_id: line.item_id, + receive_quantity: Number(line.receive_quantity) + })); + if (!items.length) { + addNotification('请填写本次入库数量', 'warning'); + return; + } + await apiRequest(`/api/purchase-orders/${state.editingItem.id}/receive`, { + method: 'POST', + body: JSON.stringify({ + warehouse_id: state.form.warehouse_id || state.purchaseWarehouseId, + items, + remark: state.form.remark || '' + }) + }); + addNotification('采购到货入库成功', 'success'); + closeModal(); + loadPurchaseOrders(); + loadInventory(); + loadMovements(); + } catch (e) { + handleApiError(e, '采购到货入库'); } }; @@ -2007,14 +2254,22 @@ const InventoryView = { deleteSupplier, saveCustomer, deleteCustomer, - stockIn, - stockOut, + saveInventoryItem, + deleteInventoryItem, + addSalesOrderItem, + removeSalesOrderItem, + saveSalesOrder, + deleteSalesOrder, + addPurchaseOrderItem, + removePurchaseOrderItem, + savePurchaseOrder, + deletePurchaseOrder, + receivePurchaseOrder, + loadPurchaseOrders, loadProductionOrders, loadOrderProductionPlan, issueOrderMaterials, refreshFinanceByPeriod, - inboundMovementOptions, - outboundMovementOptions, getMovementTypeLabel, getMovementBadgeClass }; @@ -2030,6 +2285,7 @@ const InventoryView = { + @@ -2132,18 +2388,18 @@ const InventoryView = {
| SKU | -产品 | +物料 | 仓库 | 数量 | 可用 | +操作 | {{ item.warehouse_name }} | {{ item.quantity }} | {{ item.available_quantity }} | +
+
+
+
+
+ |
+
+
+
|---|
| 采购单 | +供应商 | +状态 | +总金额 | +已付款 | +操作 | +
|---|---|---|---|---|---|
| {{ order.order_no }} | +{{ order.supplier_name }} | +{{ order.status }} | +{{ formatCurrency(order.total_amount || 0) }} | +{{ formatCurrency(order.paid_amount || 0) }} | +
+
+
+
+
+
+ |