进销存系统重构

This commit is contained in:
2026-03-17 22:20:56 +08:00
parent abecbc827d
commit 95e7404902
8 changed files with 1258 additions and 178 deletions
+128 -2
View File
@@ -7,7 +7,7 @@
路由前缀: /api/inventory 路由前缀: /api/inventory
""" """
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
@@ -15,7 +15,7 @@ from typing import Optional, List
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, Product, Warehouse, Inventory from models.database import User, Product, Warehouse, Inventory
from .schemas import InventoryResponse from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate
router = APIRouter(prefix="/inventory", tags=["库存管理"]) router = APIRouter(prefix="/inventory", tags=["库存管理"])
@@ -64,3 +64,129 @@ async def list_inventory(
)) ))
return inventory_list 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": "库存记录已删除"}
+291 -47
View File
@@ -15,13 +15,148 @@ from typing import Optional, List
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, Supplier, Product, PurchaseOrder, PurchaseOrderItem from models.database import (
from .schemas import PurchaseOrderCreate, PurchaseOrderResponse User,
Supplier,
Product,
Warehouse,
Inventory,
StockMovement,
PurchaseOrder,
PurchaseOrderItem
)
from .schemas import (
PurchaseOrderCreate,
PurchaseOrderResponse,
PurchaseOrderDetailResponse,
PurchaseOrderItemResponse,
PurchaseOrderReceiveRequest,
)
from .utils import generate_order_no from .utils import generate_order_no
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"]) 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]) @router.get("", response_model=List[PurchaseOrderResponse])
async def list_purchase_orders( async def list_purchase_orders(
status: Optional[str] = None, status: Optional[str] = None,
@@ -44,18 +179,7 @@ async def list_purchase_orders(
orders = [] orders = []
for order, supplier in result.all(): for order, supplier in result.all():
orders.append(PurchaseOrderResponse( orders.append(_build_purchase_order_response(order, supplier.name))
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 orders return orders
@@ -77,43 +201,163 @@ async def create_purchase_order(
db_session.add(order) db_session.add(order)
await db_session.flush() await db_session.flush()
total_amount = 0 order.total_amount = await _apply_order_items(db_session, order, order_data)
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
await db_session.commit() await db_session.commit()
await db_session.refresh(order) await db_session.refresh(order)
supplier = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) supplier = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier.scalar_one() supplier = supplier.scalar_one()
return PurchaseOrderResponse( return _build_purchase_order_response(order, supplier.name)
id=order.id,
order_no=order.order_no,
supplier_name=supplier.name, @router.get("/{order_id}", response_model=PurchaseOrderDetailResponse)
order_date=order.order_date, async def get_purchase_order_detail(
expected_date=order.expected_date, order_id: int,
status=order.status, db_session: AsyncSession = Depends(get_db_session),
total_amount=order.total_amount, current_user: User = Depends(get_current_active_user)
paid_amount=order.paid_amount, ):
remark=order.remark, order, supplier = await _get_order_with_supplier(db_session, order_id)
created_at=order.created_at 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)
+253 -23
View File
@@ -10,7 +10,7 @@
""" """
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, delete
from typing import Optional, List from typing import Optional, List
from math import ceil from math import ceil
@@ -30,6 +30,8 @@ from models.database import (
from .schemas import ( from .schemas import (
SalesOrderCreate, SalesOrderCreate,
SalesOrderResponse, SalesOrderResponse,
SalesOrderDetailResponse,
SalesOrderItemResponse,
SalesOrderProductionPlanResponse, SalesOrderProductionPlanResponse,
ProductionMaterialPlanItemResponse, ProductionMaterialPlanItemResponse,
SalesOrderIssueRequest, 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( async def _get_sales_order_with_customer(
db_session: AsyncSession, db_session: AsyncSession,
order_id: int, order_id: int,
@@ -143,6 +184,160 @@ async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> t
return plan_items, planned_material_cost 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]) @router.get("", response_model=List[SalesOrderResponse])
async def list_sales_orders( async def list_sales_orders(
status: Optional[str] = None, status: Optional[str] = None,
@@ -187,28 +382,8 @@ async def create_sales_order(
db_session.add(order) db_session.add(order)
await db_session.flush() await db_session.flush()
total_amount = 0 order.total_amount = await _apply_order_items(db_session, order, order_data)
for item_data in order_data.items: await _issue_materials_for_order_creation(db_session, order, current_user)
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
await db_session.commit() await db_session.commit()
await db_session.refresh(order) await db_session.refresh(order)
@@ -218,6 +393,59 @@ async def create_sales_order(
return _build_sales_order_response(order, customer.name) 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) @router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
async def get_sales_order_production_plan( async def get_sales_order_production_plan(
order_id: int, order_id: int,
@@ -248,6 +476,8 @@ async def issue_sales_order_materials(
order, _ = await _get_sales_order_with_customer(db_session, order_id) order, _ = await _get_sales_order_with_customer(db_session, order_id)
if order.production_status == "completed": if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产") raise HTTPException(status_code=400, detail="该销售单已完成生产")
if order.production_status == "material_issued":
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
warehouse_result = await db_session.execute( warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True) select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
+11 -4
View File
@@ -9,17 +9,23 @@ from .product_schemas import (
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
from .inventory_schemas import InventoryResponse from .inventory_schemas import InventoryResponse, InventoryCreate, InventoryUpdate
from .stock_movement_schemas import StockMovementCreate, StockMovementResponse from .stock_movement_schemas import StockMovementCreate, StockMovementResponse
from .purchase_order_schemas import ( from .purchase_order_schemas import (
PurchaseOrderCreate, PurchaseOrderCreate,
PurchaseOrderResponse, PurchaseOrderResponse,
PurchaseOrderItemCreate PurchaseOrderItemCreate,
PurchaseOrderItemResponse,
PurchaseOrderDetailResponse,
PurchaseOrderReceiveItem,
PurchaseOrderReceiveRequest
) )
from .sales_order_schemas import ( from .sales_order_schemas import (
SalesOrderCreate, SalesOrderCreate,
SalesOrderResponse, SalesOrderResponse,
SalesOrderItemCreate, SalesOrderItemCreate,
SalesOrderItemResponse,
SalesOrderDetailResponse,
ProductionMaterialPlanItemResponse, ProductionMaterialPlanItemResponse,
SalesOrderProductionPlanResponse, SalesOrderProductionPlanResponse,
SalesOrderIssueRequest, SalesOrderIssueRequest,
@@ -47,10 +53,11 @@ __all__ = [
"SupplierCreate", "SupplierResponse", "SupplierCreate", "SupplierResponse",
"CustomerCreate", "CustomerResponse", "CustomerCreate", "CustomerResponse",
"WarehouseCreate", "WarehouseResponse", "WarehouseCreate", "WarehouseResponse",
"InventoryResponse", "InventoryResponse", "InventoryCreate", "InventoryUpdate",
"StockMovementCreate", "StockMovementResponse", "StockMovementCreate", "StockMovementResponse",
"PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate", "PurchaseOrderCreate", "PurchaseOrderResponse", "PurchaseOrderItemCreate",
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", "PurchaseOrderItemResponse", "PurchaseOrderDetailResponse", "PurchaseOrderReceiveItem", "PurchaseOrderReceiveRequest",
"SalesOrderCreate", "SalesOrderResponse", "SalesOrderItemCreate", "SalesOrderItemResponse", "SalesOrderDetailResponse",
"ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse", "ProductionMaterialPlanItemResponse", "SalesOrderProductionPlanResponse",
"SalesOrderIssueRequest", "SalesOrderIssueResponse", "SalesOrderIssueRequest", "SalesOrderIssueResponse",
"FinanceAllocationCreate", "FinanceTransactionCreate", "FinanceAllocationCreate", "FinanceTransactionCreate",
@@ -1,4 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
class InventoryResponse(BaseModel): class InventoryResponse(BaseModel):
@@ -14,3 +15,19 @@ class InventoryResponse(BaseModel):
class Config: class Config:
from_attributes = True 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
@@ -31,3 +31,31 @@ class PurchaseOrderResponse(BaseModel):
class Config: class Config:
from_attributes = True 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
@@ -37,6 +37,21 @@ class SalesOrderResponse(BaseModel):
from_attributes = True 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): class ProductionMaterialPlanItemResponse(BaseModel):
material_id: int material_id: int
material_sku: str material_sku: str
+515 -102
View File
@@ -1487,6 +1487,9 @@ const InventoryView = {
supplierProductStatement: [], supplierProductStatement: [],
products: [], products: [],
materials: [], materials: [],
purchaseOrders: [],
purchaseWarehouseId: null,
purchaseReceiveItems: [],
productionOrders: [], productionOrders: [],
productionPlan: null, productionPlan: null,
productionWarehouseId: null, productionWarehouseId: null,
@@ -1503,22 +1506,6 @@ const InventoryView = {
form: {} 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 getMovementTypeLabel = (movementType) => {
const movementLabelMap = { const movementLabelMap = {
in: '其他入库', 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 () => { const loadCustomers = async () => {
state.loading = true; state.loading = true;
try { try {
@@ -1734,6 +1740,7 @@ 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 'purchases': loadPurchaseOrders(); break;
case 'production': loadProductionOrders(); break; case 'production': loadProductionOrders(); break;
case 'finance': loadFinance(); break; case 'finance': loadFinance(); break;
} }
@@ -1773,16 +1780,65 @@ const InventoryView = {
state.modalType = type; state.modalType = type;
state.editingItem = item; state.editingItem = item;
if (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 { } else {
state.form = {}; state.form = {};
if (type === 'stockIn' || type === 'stockOut') { if (type === 'inventoryItem') {
await ensureStockBaseData(); await ensureStockBaseData();
state.form = { state.form = {
product_id: state.materials[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: 0,
movement_type: type === 'stockIn' ? 'purchase_in' : 'issue_to_production' locked_quantity: 0,
batch_number: '',
location: ''
}; };
} }
if (type === 'product') { if (type === 'product') {
@@ -1795,6 +1851,36 @@ const InventoryView = {
sale_price: 0 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; state.showModal = true;
}; };
@@ -1804,6 +1890,7 @@ const InventoryView = {
state.modalType = ''; state.modalType = '';
state.editingItem = null; state.editingItem = null;
state.productBomItems = []; state.productBomItems = [];
state.purchaseReceiveItems = [];
state.form = {}; state.form = {};
}; };
@@ -1951,33 +2038,193 @@ const InventoryView = {
} }
}; };
const stockIn = async () => { const saveInventoryItem = async () => {
try { try {
await apiRequest('/api/stock-movements', { if (state.editingItem) {
method: 'POST', await apiRequest(`/api/inventory/${state.editingItem.id}`, {
body: JSON.stringify({ ...state.form, movement_type: state.form.movement_type || 'purchase_in' }) method: 'PUT',
}); body: JSON.stringify({
addNotification('入库成功', 'success'); 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(); closeModal();
loadInventory(); loadInventory();
loadMovements();
} catch (e) { } catch (e) {
handleApiError(e, '入库操作'); handleApiError(e, '保存物料库存');
} }
}; };
const stockOut = async () => { const deleteInventoryItem = async (id) => {
if (!confirm('确定要删除这个物料库存记录吗?')) return;
try { try {
await apiRequest('/api/stock-movements', { await apiRequest(`/api/inventory/${id}`, { method: 'DELETE' });
method: 'POST', addNotification('物料库存已删除', 'success');
body: JSON.stringify({ ...state.form, movement_type: state.form.movement_type || 'issue_to_production' }) loadInventory();
}); } catch (e) {
addNotification('出库成功', 'success'); 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(); closeModal();
loadProductionOrders();
loadInventory(); loadInventory();
loadMovements(); loadMovements();
} catch (e) { } 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, deleteSupplier,
saveCustomer, saveCustomer,
deleteCustomer, deleteCustomer,
stockIn, saveInventoryItem,
stockOut, deleteInventoryItem,
addSalesOrderItem,
removeSalesOrderItem,
saveSalesOrder,
deleteSalesOrder,
addPurchaseOrderItem,
removePurchaseOrderItem,
savePurchaseOrder,
deletePurchaseOrder,
receivePurchaseOrder,
loadPurchaseOrders,
loadProductionOrders, loadProductionOrders,
loadOrderProductionPlan, loadOrderProductionPlan,
issueOrderMaterials, issueOrderMaterials,
refreshFinanceByPeriod, refreshFinanceByPeriod,
inboundMovementOptions,
outboundMovementOptions,
getMovementTypeLabel, getMovementTypeLabel,
getMovementBadgeClass getMovementBadgeClass
}; };
@@ -2030,6 +2285,7 @@ const InventoryView = {
<button :class="['tab', { active: state.activeTab === 'dashboard' }]" @click="switchTab('dashboard')">仪表盘</button> <button :class="['tab', { active: state.activeTab === 'dashboard' }]" @click="switchTab('dashboard')">仪表盘</button>
<button :class="['tab', { active: state.activeTab === 'products' }]" @click="switchTab('products')">产品</button> <button :class="['tab', { active: state.activeTab === 'products' }]" @click="switchTab('products')">产品</button>
<button :class="['tab', { active: state.activeTab === 'inventory' }]" @click="switchTab('inventory')">库存</button> <button :class="['tab', { active: state.activeTab === 'inventory' }]" @click="switchTab('inventory')">库存</button>
<button :class="['tab', { active: state.activeTab === 'purchases' }]" @click="switchTab('purchases')">采购</button>
<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>
@@ -2132,18 +2388,18 @@ const InventoryView = {
<div v-else-if="state.activeTab === 'inventory'"> <div v-else-if="state.activeTab === 'inventory'">
<div class="table-header"> <div class="table-header">
<button class="btn btn-primary" @click="openModal('stockIn')">📥 采购/完工入库</button> <button class="btn btn-primary" @click="openModal('inventoryItem')">+ 新增物料库存</button>
<button class="btn btn-secondary" @click="openModal('stockOut')" style="margin-left: 8px;">📤 领料/销售出库</button>
</div> </div>
<div class="table-container"> <div class="table-container">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>SKU</th> <th>SKU</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>
@@ -2153,6 +2409,59 @@ const InventoryView = {
<td>{{ item.warehouse_name }}</td> <td>{{ item.warehouse_name }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ item.available_quantity }}</td> <td>{{ item.available_quantity }}</td>
<td>
<div class="action-btns">
<button class="btn btn-sm btn-secondary" @click="openModal('inventoryItem', item)">编辑</button>
<button class="btn btn-sm btn-danger" @click="deleteInventoryItem(item.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="state.activeTab === 'purchases'">
<div class="table-header" style="margin-bottom: 12px;">
<button class="btn btn-primary" @click="openModal('purchaseOrder')">+ 新增采购订单</button>
</div>
<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.purchaseWarehouseId" class="form-input" style="width:260px;">
<option v-for="warehouse in state.warehouses" :key="'purchase-warehouse-' + warehouse.id" :value="warehouse.id">
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
</option>
</select>
<button class="btn btn-secondary" @click="loadPurchaseOrders">刷新</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>
</tr>
</thead>
<tbody>
<tr v-for="order in state.purchaseOrders" :key="'purchase-order-' + order.id">
<td>{{ order.order_no }}</td>
<td>{{ order.supplier_name }}</td>
<td>{{ order.status }}</td>
<td>{{ formatCurrency(order.total_amount || 0) }}</td>
<td>{{ formatCurrency(order.paid_amount || 0) }}</td>
<td>
<div class="action-btns">
<button class="btn btn-sm btn-secondary" @click="openModal('purchaseOrder', order)">编辑</button>
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)">删除</button>
<button class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -2230,6 +2539,9 @@ const InventoryView = {
</div> </div>
<div v-else-if="state.activeTab === 'production'"> <div v-else-if="state.activeTab === 'production'">
<div class="table-header" style="margin-bottom: 12px;">
<button class="btn btn-primary" @click="openModal('salesOrder')">+ 新增销售订单</button>
</div>
<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;">
<label>领料仓库</label> <label>领料仓库</label>
@@ -2264,8 +2576,9 @@ const InventoryView = {
<td>{{ formatCurrency(order.actual_material_cost || 0) }}</td> <td>{{ formatCurrency(order.actual_material_cost || 0) }}</td>
<td> <td>
<div class="action-btns"> <div class="action-btns">
<button class="btn btn-sm btn-secondary" @click="openModal('salesOrder', order)">编辑</button>
<button class="btn btn-sm btn-danger" @click="deleteSalesOrder(order.id)">删除</button>
<button class="btn btn-sm btn-secondary" @click="loadOrderProductionPlan(order.id)">领料建议</button> <button class="btn btn-sm btn-secondary" @click="loadOrderProductionPlan(order.id)">领料建议</button>
<button class="btn btn-sm btn-primary" @click="issueOrderMaterials(order)">执行领料</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -2532,7 +2845,7 @@ 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 === 'productBom' ? '产品BOM' : state.modalType === 'supplier' ? '供应商' : state.modalType === 'customer' ? '客户' : state.modalType === 'stockIn' ? '入库' : '出库' }}</h3> <h3>{{ state.editingItem ? '编辑' : '新增' }}{{ state.modalType === 'product' ? '产品/物料' : state.modalType === 'productBom' ? '产品BOM' : state.modalType === 'inventoryItem' ? '物料库存' : state.modalType === 'salesOrder' ? '销售订单' : state.modalType === 'purchaseOrder' ? '采购订单' : state.modalType === 'purchaseReceive' ? '采购到货入库' : state.modalType === 'supplier' ? '供应商' : '客户' }}</h3>
<button class="modal-close" @click="closeModal">&times;</button> <button class="modal-close" @click="closeModal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -2583,6 +2896,153 @@ const InventoryView = {
</div> </div>
</form> </form>
<form v-else-if="state.modalType === 'salesOrder'" @submit.prevent="saveSalesOrder">
<div class="form-group">
<label class="form-label">客户 *</label>
<select v-model.number="state.form.customer_id" class="form-input" required>
<option v-for="customer in state.customers" :key="'order-customer-' + customer.id" :value="customer.id">
{{ customer.name }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">交付日期</label>
<input v-model="state.form.delivery_date" type="datetime-local" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="订单备注" />
</div>
<div class="table-header" style="margin-bottom: 8px;">
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加成品</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>成品</th>
<th>数量</th>
<th>单价</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(line, index) in state.form.items" :key="'sales-order-line-' + index">
<td>
<select v-model.number="line.product_id" class="form-input" required>
<option v-for="product in state.products.filter(p => p.item_type === 'finished')" :key="'sales-order-product-' + product.id" :value="product.id">
{{ product.sku }} - {{ product.name }}
</option>
</select>
</td>
<td><input v-model.number="line.quantity" type="number" min="1" class="form-input" required /></td>
<td><input v-model.number="line.unit_price" type="number" min="0" step="0.01" class="form-input" required /></td>
<td><input v-model="line.remark" class="form-input" placeholder="明细备注" /></td>
<td><button type="button" class="btn btn-sm btn-danger" @click="removeSalesOrderItem(index)">删除</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">保存订单</button>
</div>
</form>
<form v-else-if="state.modalType === 'purchaseOrder'" @submit.prevent="savePurchaseOrder">
<div class="form-group">
<label class="form-label">供应商 *</label>
<select v-model.number="state.form.supplier_id" class="form-input" required>
<option v-for="supplier in state.suppliers" :key="'purchase-supplier-' + supplier.id" :value="supplier.id">
{{ supplier.name }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">预计到货</label>
<input v-model="state.form.expected_date" type="datetime-local" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="采购单备注" />
</div>
<div class="table-header" style="margin-bottom: 8px;">
<button type="button" class="btn btn-secondary" @click="addPurchaseOrderItem">+ 添加物料</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>物料</th>
<th>数量</th>
<th>单价</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(line, index) in state.form.items" :key="'purchase-order-line-' + index">
<td>
<select v-model.number="line.product_id" class="form-input" required>
<option v-for="material in state.materials" :key="'purchase-order-material-' + material.id" :value="material.id">
{{ material.sku }} - {{ material.name }}
</option>
</select>
</td>
<td><input v-model.number="line.quantity" type="number" min="1" class="form-input" required /></td>
<td><input v-model.number="line.unit_price" type="number" min="0" step="0.01" class="form-input" required /></td>
<td><input v-model="line.remark" class="form-input" placeholder="明细备注" /></td>
<td><button type="button" class="btn btn-sm btn-danger" @click="removePurchaseOrderItem(index)">删除</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">保存采购单</button>
</div>
</form>
<form v-else-if="state.modalType === 'purchaseReceive'" @submit.prevent="receivePurchaseOrder">
<div class="form-group">
<label class="form-label">入库仓库 *</label>
<select v-model.number="state.form.warehouse_id" class="form-input" required>
<option v-for="warehouse in state.warehouses" :key="'purchase-receive-warehouse-' + warehouse.id" :value="warehouse.id">
{{ warehouse.name }}{{ warehouse.is_default ? ' [默认]' : '' }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="到货说明" />
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>物料</th>
<th>明细ID</th>
<th>剩余待入库</th>
<th>本次入库</th>
</tr>
</thead>
<tbody>
<tr v-for="line in state.purchaseReceiveItems" :key="'purchase-receive-line-' + line.item_id">
<td>{{ line.material_label }}</td>
<td>{{ line.item_id }}</td>
<td>{{ line.remaining_quantity }}</td>
<td><input v-model.number="line.receive_quantity" type="number" min="0" :max="line.remaining_quantity" class="form-input" required /></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">确认入库</button>
</div>
</form>
<form v-else-if="state.modalType === 'productBom'" @submit.prevent="saveProductBom"> <form v-else-if="state.modalType === 'productBom'" @submit.prevent="saveProductBom">
<div class="table-header" style="margin-bottom: 12px;"> <div class="table-header" style="margin-bottom: 12px;">
<button type="button" class="btn btn-secondary" @click="addBomItem">+ 添加物料</button> <button type="button" class="btn btn-secondary" @click="addBomItem">+ 添加物料</button>
@@ -2675,8 +3135,7 @@ const InventoryView = {
</div> </div>
</form> </form>
<!-- 入库表单 --> <form v-else-if="state.modalType === 'inventoryItem'" @submit.prevent="saveInventoryItem">
<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>
@@ -2695,71 +3154,25 @@ const InventoryView = {
</option> </option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label">业务类型 *</label>
<select v-model="state.form.movement_type" class="form-input" required>
<option v-for="item in inboundMovementOptions" :key="'stockin-type-' + item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">数量 *</label> <label class="form-label">数量 *</label>
<input v-model.number="state.form.quantity" type="number" class="form-input" required placeholder="入库数量" /> <input v-model.number="state.form.quantity" type="number" class="form-input" required placeholder="库存数量" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">单价</label> <label class="form-label">锁定数量</label>
<input v-model.number="state.form.unit_price" type="number" step="0.01" class="form-input" placeholder="采购单价" /> <input v-model.number="state.form.locked_quantity" type="number" class="form-input" placeholder="锁定库存" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">备注</label> <label class="form-label">批次号</label>
<input v-model="state.form.remark" class="form-input" placeholder="备注信息" /> <input v-model="state.form.batch_number" class="form-input" placeholder="批次号(可选)" />
</div>
<div class="form-group">
<label class="form-label">库位</label>
<input v-model="state.form.location" class="form-input" placeholder="库位(可选)" />
</div> </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" :disabled="!state.form.product_id || !state.form.warehouse_id">确认入库</button> <button type="submit" class="btn btn-primary" :disabled="!state.form.product_id || !state.form.warehouse_id">保存</button>
</div>
</form>
<!-- 出库表单 -->
<form v-else-if="state.modalType === 'stockOut'" @submit.prevent="stockOut">
<div class="form-group">
<label class="form-label">物料 *</label>
<select v-model.number="state.form.product_id" class="form-input" required>
<option v-if="!state.materials.length" :value="null" disabled>暂无物料,请先新增物料</option>
<option v-for="product in state.materials" :key="'stockout-product-' + product.id" :value="product.id">
{{ product.sku }} - {{ product.name }}(ID: {{ product.id }})
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">仓库 *</label>
<select v-model.number="state.form.warehouse_id" class="form-input" required>
<option v-if="!state.warehouses.length" :value="null" disabled>暂无仓库,系统将自动创建默认仓库</option>
<option v-for="warehouse in state.warehouses" :key="'stockout-warehouse-' + warehouse.id" :value="warehouse.id">
{{ warehouse.name }}{{ warehouse.code ? ' (' + warehouse.code + ')' : '' }}{{ warehouse.is_default ? ' [默认]' : '' }}(ID: {{ warehouse.id }})
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">业务类型 *</label>
<select v-model="state.form.movement_type" class="form-input" required>
<option v-for="item in outboundMovementOptions" :key="'stockout-type-' + item.value" :value="item.value">{{ item.label }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">数量 *</label>
<input v-model.number="state.form.quantity" type="number" class="form-input" required placeholder="出库数量" />
</div>
<div class="form-group">
<label class="form-label">单价</label>
<input v-model.number="state.form.unit_price" type="number" step="0.01" class="form-input" placeholder="销售单价" />
</div>
<div class="form-group">
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="备注信息" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
<button type="submit" class="btn btn-primary" :disabled="!state.form.product_id || !state.form.warehouse_id">确认出库</button>
</div> </div>
</form> </form>
</div> </div>