Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -32,7 +32,7 @@ DB_USER=moldinsight
|
|||||||
DB_PASSWORD=Qqs1996*
|
DB_PASSWORD=Qqs1996*
|
||||||
|
|
||||||
# RustFS 对象存储配置 (S3v4 API)
|
# RustFS 对象存储配置 (S3v4 API)
|
||||||
RUSTFS_ENDPOINT=http://szcjw:9000
|
RUSTFS_ENDPOINT=http://szcjw:8010
|
||||||
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
|
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
|
||||||
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
|
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
|
||||||
RUSTFS_TIMEOUT=30
|
RUSTFS_TIMEOUT=30
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
数据库迁移脚本:为采购订单表添加状态变更时间字段
|
||||||
|
- received_date: 实际到货时间
|
||||||
|
- paid_date: 实际付款时间
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
sys.path.insert(0, str(project_root / "src"))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate():
|
||||||
|
"""执行数据库迁移"""
|
||||||
|
if not settings.DATABASE_URL:
|
||||||
|
print("错误:未配置数据库连接")
|
||||||
|
return
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# 检查字段是否已存在
|
||||||
|
check_sql = """
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'purchase_orders'
|
||||||
|
AND column_name = 'received_date'
|
||||||
|
"""
|
||||||
|
result = await conn.execute(text(check_sql))
|
||||||
|
if result.fetchone():
|
||||||
|
print("字段 received_date 已存在,跳过")
|
||||||
|
else:
|
||||||
|
# 添加实际到货时间字段
|
||||||
|
alter_sql = """
|
||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ADD COLUMN received_date TIMESTAMP WITHOUT TIME ZONE
|
||||||
|
"""
|
||||||
|
await conn.execute(text(alter_sql))
|
||||||
|
print("成功添加字段 received_date")
|
||||||
|
|
||||||
|
# 检查 paid_date 字段是否已存在
|
||||||
|
check_sql2 = """
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'purchase_orders'
|
||||||
|
AND column_name = 'paid_date'
|
||||||
|
"""
|
||||||
|
result2 = await conn.execute(text(check_sql2))
|
||||||
|
if result2.fetchone():
|
||||||
|
print("字段 paid_date 已存在,跳过")
|
||||||
|
else:
|
||||||
|
# 添加实际付款时间字段
|
||||||
|
alter_sql2 = """
|
||||||
|
ALTER TABLE purchase_orders
|
||||||
|
ADD COLUMN paid_date TIMESTAMP WITHOUT TIME ZONE
|
||||||
|
"""
|
||||||
|
await conn.execute(text(alter_sql2))
|
||||||
|
print("成功添加字段 paid_date")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
print("迁移完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(migrate())
|
||||||
@@ -36,7 +36,7 @@ async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: Li
|
|||||||
ProductMaterial.finished_product_id,
|
ProductMaterial.finished_product_id,
|
||||||
func.coalesce(
|
func.coalesce(
|
||||||
func.sum(
|
func.sum(
|
||||||
Product.cost_price * ProductMaterial.quantity * (1 + ProductMaterial.loss_rate)
|
Product.cost_price * ProductMaterial.quantity
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
@@ -186,7 +186,7 @@ async def get_product_bom(
|
|||||||
items: List[ProductMaterialItemResponse] = []
|
items: List[ProductMaterialItemResponse] = []
|
||||||
total_material_cost = 0.0
|
total_material_cost = 0.0
|
||||||
for bom, material in bom_result.all():
|
for bom, material in bom_result.all():
|
||||||
line_cost = float(material.cost_price or 0) * float(bom.quantity) * (1 + float(bom.loss_rate or 0))
|
line_cost = float(material.cost_price or 0) * float(bom.quantity)
|
||||||
total_material_cost += line_cost
|
total_material_cost += line_cost
|
||||||
items.append(
|
items.append(
|
||||||
ProductMaterialItemResponse(
|
ProductMaterialItemResponse(
|
||||||
@@ -194,7 +194,6 @@ async def get_product_bom(
|
|||||||
material_sku=material.sku,
|
material_sku=material.sku,
|
||||||
material_name=material.name,
|
material_name=material.name,
|
||||||
quantity=round(float(bom.quantity), 4),
|
quantity=round(float(bom.quantity), 4),
|
||||||
loss_rate=round(float(bom.loss_rate or 0), 4),
|
|
||||||
unit_cost=round(float(material.cost_price or 0), 4),
|
unit_cost=round(float(material.cost_price or 0), 4),
|
||||||
line_cost=round(float(line_cost), 4),
|
line_cost=round(float(line_cost), 4),
|
||||||
)
|
)
|
||||||
@@ -246,15 +245,12 @@ async def replace_product_bom(
|
|||||||
|
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
if item.quantity <= 0:
|
if item.quantity <= 0:
|
||||||
raise HTTPException(status_code=400, detail="物料数量必须大于0")
|
raise HTTPException(status_code=400, detail="物料数量必须大于 0")
|
||||||
if item.loss_rate < 0:
|
|
||||||
raise HTTPException(status_code=400, detail="损耗率不能为负数")
|
|
||||||
db_session.add(
|
db_session.add(
|
||||||
ProductMaterial(
|
ProductMaterial(
|
||||||
finished_product_id=product_id,
|
finished_product_id=product_id,
|
||||||
material_product_id=item.material_id,
|
material_product_id=item.material_id,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
loss_rate=item.loss_rate,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from sqlalchemy import select, func
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from database.database import get_db_session
|
from database.database import get_db_session
|
||||||
@@ -38,6 +38,10 @@ router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
|
|||||||
|
|
||||||
|
|
||||||
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
|
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
|
||||||
|
# 检查字段是否存在,避免数据库中没有这些字段时的错误
|
||||||
|
received_date = getattr(order, 'received_date', None)
|
||||||
|
paid_date = getattr(order, 'paid_date', None)
|
||||||
|
|
||||||
return PurchaseOrderResponse(
|
return PurchaseOrderResponse(
|
||||||
id=order.id,
|
id=order.id,
|
||||||
order_no=order.order_no,
|
order_no=order.order_no,
|
||||||
@@ -48,7 +52,9 @@ def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) ->
|
|||||||
total_amount=order.total_amount,
|
total_amount=order.total_amount,
|
||||||
paid_amount=order.paid_amount,
|
paid_amount=order.paid_amount,
|
||||||
remark=order.remark,
|
remark=order.remark,
|
||||||
created_at=order.created_at
|
created_at=order.created_at,
|
||||||
|
received_date=received_date,
|
||||||
|
paid_date=paid_date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +85,10 @@ async def _build_purchase_order_detail(
|
|||||||
.order_by(PurchaseOrderItem.id.asc())
|
.order_by(PurchaseOrderItem.id.asc())
|
||||||
)
|
)
|
||||||
item_rows = item_result.all()
|
item_rows = item_result.all()
|
||||||
|
# 检查字段是否存在,避免数据库中没有这些字段时的错误
|
||||||
|
received_date = getattr(order, 'received_date', None)
|
||||||
|
paid_date = getattr(order, 'paid_date', None)
|
||||||
|
|
||||||
return PurchaseOrderDetailResponse(
|
return PurchaseOrderDetailResponse(
|
||||||
id=order.id,
|
id=order.id,
|
||||||
order_no=order.order_no,
|
order_no=order.order_no,
|
||||||
@@ -91,6 +101,8 @@ async def _build_purchase_order_detail(
|
|||||||
paid_amount=order.paid_amount,
|
paid_amount=order.paid_amount,
|
||||||
remark=order.remark,
|
remark=order.remark,
|
||||||
created_at=order.created_at,
|
created_at=order.created_at,
|
||||||
|
received_date=received_date,
|
||||||
|
paid_date=paid_date,
|
||||||
items=[
|
items=[
|
||||||
PurchaseOrderItemResponse(
|
PurchaseOrderItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -205,7 +217,7 @@ async def create_purchase_order(
|
|||||||
expected_date=order_data.expected_date,
|
expected_date=order_data.expected_date,
|
||||||
remark=order_data.remark,
|
remark=order_data.remark,
|
||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
status="draft"
|
status="pending"
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
@@ -214,8 +226,10 @@ async def create_purchase_order(
|
|||||||
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_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
|
||||||
supplier = supplier.scalar_one()
|
supplier = supplier_result.scalar_one_or_none()
|
||||||
|
if not supplier:
|
||||||
|
raise HTTPException(status_code=404, detail="供应商不存在")
|
||||||
|
|
||||||
return _build_purchase_order_response(order, supplier.name)
|
return _build_purchase_order_response(order, supplier.name)
|
||||||
|
|
||||||
@@ -255,7 +269,7 @@ async def update_purchase_order(
|
|||||||
order.expected_date = order_data.expected_date
|
order.expected_date = order_data.expected_date
|
||||||
order.remark = order_data.remark
|
order.remark = order_data.remark
|
||||||
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
order.total_amount = await _apply_order_items(db_session, order, order_data)
|
||||||
order.status = "draft"
|
order.status = "pending"
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
await db_session.refresh(order)
|
await db_session.refresh(order)
|
||||||
@@ -283,6 +297,46 @@ async def delete_purchase_order(
|
|||||||
return {"message": "采购订单已删除"}
|
return {"message": "采购订单已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{order_id}/status")
|
||||||
|
async def update_purchase_order_status(
|
||||||
|
order_id: int,
|
||||||
|
status: dict,
|
||||||
|
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)
|
||||||
|
|
||||||
|
new_status = status.get("status")
|
||||||
|
if not new_status:
|
||||||
|
raise HTTPException(status_code=400, detail="状态不能为空")
|
||||||
|
|
||||||
|
valid_statuses = ["pending", "received", "paid"]
|
||||||
|
if new_status not in valid_statuses:
|
||||||
|
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
|
||||||
|
|
||||||
|
# 状态转换逻辑
|
||||||
|
if order.status == "paid":
|
||||||
|
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
|
||||||
|
|
||||||
|
if order.status == "received" and new_status != "paid":
|
||||||
|
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款")
|
||||||
|
|
||||||
|
# 更新状态和对应时间
|
||||||
|
order.status = new_status
|
||||||
|
# 检查字段是否存在,避免数据库中没有这些字段时的错误
|
||||||
|
if hasattr(order, 'received_date') and new_status == "received":
|
||||||
|
order.received_date = func.now()
|
||||||
|
elif hasattr(order, 'paid_date') and new_status == "paid":
|
||||||
|
order.paid_date = func.now()
|
||||||
|
|
||||||
|
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.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
|
@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
|
||||||
async def receive_purchase_order(
|
async def receive_purchase_order(
|
||||||
order_id: int,
|
order_id: int,
|
||||||
@@ -364,6 +418,9 @@ async def receive_purchase_order(
|
|||||||
any_received = any((item.received_quantity or 0) > 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:
|
if all_received:
|
||||||
order.status = "received"
|
order.status = "received"
|
||||||
|
# 检查字段是否存在,避免数据库中没有这些字段时的错误
|
||||||
|
if hasattr(order, 'received_date'):
|
||||||
|
order.received_date = func.now()
|
||||||
elif any_received:
|
elif any_received:
|
||||||
order.status = "partial_received"
|
order.status = "partial_received"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, func, delete, update
|
from sqlalchemy import select, func, delete, update
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
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
|
||||||
@@ -513,7 +514,7 @@ async def delete_sales_order(
|
|||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
current_user: User = Depends(get_current_active_user)
|
current_user: User = Depends(get_current_active_user)
|
||||||
):
|
):
|
||||||
order, _ = await _get_sales_order_with_customer(db_session, order_id)
|
order, customer = await _get_sales_order_with_customer(db_session, order_id)
|
||||||
if order.status == "delivered":
|
if order.status == "delivered":
|
||||||
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
|
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
|
||||||
await _rollback_issued_materials(db_session, order, current_user)
|
await _rollback_issued_materials(db_session, order, current_user)
|
||||||
@@ -522,6 +523,88 @@ async def delete_sales_order(
|
|||||||
return {"message": "销售订单已删除"}
|
return {"message": "销售订单已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialConsumptionItem(BaseModel):
|
||||||
|
material_id: int
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialConsumptionRequest(BaseModel):
|
||||||
|
items: List[MaterialConsumptionItem] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/consume-materials")
|
||||||
|
async def consume_materials(
|
||||||
|
order_id: int,
|
||||||
|
request: MaterialConsumptionRequest,
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 计算总物料成本
|
||||||
|
total_cost = 0
|
||||||
|
|
||||||
|
# 处理每个物料消耗项
|
||||||
|
for item in request.items:
|
||||||
|
# 获取物料信息
|
||||||
|
material = await db_session.get(Product, item.material_id)
|
||||||
|
if not material:
|
||||||
|
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
|
||||||
|
if material.item_type != "material":
|
||||||
|
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
|
||||||
|
|
||||||
|
# 计算成本
|
||||||
|
cost = material.cost_price * item.quantity
|
||||||
|
total_cost += cost
|
||||||
|
|
||||||
|
# 更新物料库存
|
||||||
|
inventory_result = await db_session.execute(
|
||||||
|
select(Inventory)
|
||||||
|
.where(Inventory.product_id == material.id)
|
||||||
|
.where(Inventory.warehouse_id == 1)
|
||||||
|
)
|
||||||
|
inventory = inventory_result.scalar()
|
||||||
|
if inventory:
|
||||||
|
before_qty = inventory.quantity
|
||||||
|
inventory.quantity -= item.quantity
|
||||||
|
after_qty = inventory.quantity
|
||||||
|
if after_qty < 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"物料 {material.name} 没有库存记录")
|
||||||
|
|
||||||
|
# 记录物料消耗
|
||||||
|
movement = StockMovement(
|
||||||
|
product_id=material.id,
|
||||||
|
warehouse_id=1, # 默认仓库
|
||||||
|
quantity=-item.quantity,
|
||||||
|
before_quantity=before_qty,
|
||||||
|
after_quantity=after_qty,
|
||||||
|
movement_type="consumption",
|
||||||
|
reference_type="sales_order",
|
||||||
|
reference_id=order.id,
|
||||||
|
unit_price=material.cost_price,
|
||||||
|
total_amount=cost,
|
||||||
|
operator_id=current_user.id,
|
||||||
|
remark=item.remark
|
||||||
|
)
|
||||||
|
db_session.add(movement)
|
||||||
|
|
||||||
|
# 更新订单的实际物料成本
|
||||||
|
order.actual_material_cost = total_cost
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(order)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "物料消耗记录保存成功",
|
||||||
|
"total_cost": total_cost,
|
||||||
|
"order": _build_sales_order_response(order, customer.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ProductResponse(BaseModel):
|
|||||||
class ProductMaterialItemUpdate(BaseModel):
|
class ProductMaterialItemUpdate(BaseModel):
|
||||||
material_id: int
|
material_id: int
|
||||||
quantity: float
|
quantity: float
|
||||||
loss_rate: float = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ProductBOMUpdate(BaseModel):
|
class ProductBOMUpdate(BaseModel):
|
||||||
@@ -51,7 +50,6 @@ class ProductMaterialItemResponse(BaseModel):
|
|||||||
material_sku: str
|
material_sku: str
|
||||||
material_name: str
|
material_name: str
|
||||||
quantity: float
|
quantity: float
|
||||||
loss_rate: float
|
|
||||||
unit_cost: float
|
unit_cost: float
|
||||||
line_cost: float
|
line_cost: float
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class PurchaseOrderResponse(BaseModel):
|
|||||||
paid_amount: float
|
paid_amount: float
|
||||||
remark: Optional[str]
|
remark: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
received_date: Optional[datetime]
|
||||||
|
paid_date: Optional[datetime]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ async def ensure_schema_updates():
|
|||||||
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
|
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
|
||||||
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
|
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
|
||||||
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
|
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
|
||||||
|
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
|
||||||
|
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
|
||||||
await conn.execute(text("""
|
await conn.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS product_materials (
|
CREATE TABLE IF NOT EXISTS product_materials (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -653,8 +653,8 @@ class Inventory(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||||
quantity = Column(Integer, default=0)
|
quantity = Column(Float, default=0)
|
||||||
locked_quantity = Column(Integer, default=0)
|
locked_quantity = Column(Float, default=0)
|
||||||
batch_number = Column(String(50), nullable=True)
|
batch_number = Column(String(50), nullable=True)
|
||||||
location = Column(String(100), nullable=True)
|
location = Column(String(100), nullable=True)
|
||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
@@ -678,9 +678,9 @@ class StockMovement(Base):
|
|||||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||||
movement_type = Column(String(20), nullable=False)
|
movement_type = Column(String(20), nullable=False)
|
||||||
quantity = Column(Integer, nullable=False)
|
quantity = Column(Float, nullable=False)
|
||||||
before_quantity = Column(Integer, default=0)
|
before_quantity = Column(Float, default=0)
|
||||||
after_quantity = Column(Integer, default=0)
|
after_quantity = Column(Float, default=0)
|
||||||
reference_type = Column(String(50), nullable=True)
|
reference_type = Column(String(50), nullable=True)
|
||||||
reference_id = Column(Integer, nullable=True)
|
reference_id = Column(Integer, nullable=True)
|
||||||
reference_no = Column(String(50), nullable=True)
|
reference_no = Column(String(50), nullable=True)
|
||||||
@@ -712,6 +712,9 @@ class PurchaseOrder(Base):
|
|||||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
# 状态变更时间
|
||||||
|
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||||
|
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||||
|
|
||||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
|||||||
+1
-1
@@ -77,6 +77,6 @@
|
|||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
|
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/air-datepicker@3.5.3/air-datepicker.js"></script>
|
||||||
<script src="/static/vue-app.js?v=20260324-01"></script>
|
<script src="/static/vue-app.js?v=20260409-01"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2813,6 +2813,47 @@ select.form-input {
|
|||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 物料消耗记录样式 */
|
||||||
|
.material-consumption-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-consumption-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-consumption-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-consumption-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-consumption-total {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-consumption-item {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-frame {
|
.viewer-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
|
|||||||
+497
-86
@@ -1594,6 +1594,11 @@ const InventoryView = {
|
|||||||
modalType: '',
|
modalType: '',
|
||||||
editingItem: null,
|
editingItem: null,
|
||||||
productBomItems: [],
|
productBomItems: [],
|
||||||
|
materialConsumptionItems: [],
|
||||||
|
showMaterialConsumptionModal: false,
|
||||||
|
consumedMaterials: [],
|
||||||
|
restockItems: [],
|
||||||
|
showRestockModal: false,
|
||||||
form: {}
|
form: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1626,17 +1631,25 @@ const InventoryView = {
|
|||||||
const toApiDateTime = (value) => {
|
const toApiDateTime = (value) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const text = String(value).trim();
|
const text = String(value).trim();
|
||||||
|
// 提取日期部分,忽略时间部分
|
||||||
|
if (text.includes('T')) {
|
||||||
|
return text.split('T')[0];
|
||||||
|
}
|
||||||
|
if (text.includes(' ')) {
|
||||||
|
return text.split(' ')[0];
|
||||||
|
}
|
||||||
// 如果是只有日期部分的格式 (yyyy-MM-dd)
|
// 如果是只有日期部分的格式 (yyyy-MM-dd)
|
||||||
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) {
|
if (text.length === 10) {
|
||||||
return text; // 直接返回日期格式,后端 Pydantic 会自动处理
|
return text; // 直接返回日期格式,后端 Pydantic 会自动处理
|
||||||
}
|
}
|
||||||
// 如果是带时间的格式,转换为 ISO 8601 格式
|
// 如果是日期对象
|
||||||
if (text.includes('T')) {
|
if (value instanceof Date) {
|
||||||
return text.length === 16 ? `${text}:00` : text;
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(value.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
// 将空格格式转换为 ISO 格式
|
return text;
|
||||||
const isoText = text.replace(' ', 'T');
|
|
||||||
return isoText.length === 16 ? `${isoText}:00` : isoText;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toNativeValue = (value) => {
|
const toNativeValue = (value) => {
|
||||||
@@ -1758,6 +1771,20 @@ const InventoryView = {
|
|||||||
return 'badge-warning';
|
return 'badge-warning';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPurchaseOrderStatusLabel = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
draft: '已下单',
|
||||||
|
pending: '已下单',
|
||||||
|
received: '已收货',
|
||||||
|
paid: '已付款'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPurchaseOrderLocked = (status) => {
|
||||||
|
return ['received', 'paid'].includes(status);
|
||||||
|
};
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -2114,6 +2141,14 @@ const InventoryView = {
|
|||||||
await loadCustomers();
|
await loadCustomers();
|
||||||
await loadFinishedProducts();
|
await loadFinishedProducts();
|
||||||
const detail = await apiRequest(`/api/sales-orders/${item.id}`);
|
const detail = await apiRequest(`/api/sales-orders/${item.id}`);
|
||||||
|
// 加载已消耗的物料
|
||||||
|
const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${item.id}&movement_type=consumption`);
|
||||||
|
state.consumedMaterials = (movements || []).map(movement => ({
|
||||||
|
material_name: movement.product_name || movement.product_sku || '未知物料',
|
||||||
|
quantity: Math.abs(movement.quantity),
|
||||||
|
unit_price: movement.unit_price || 0,
|
||||||
|
amount: movement.total_amount || 0
|
||||||
|
}));
|
||||||
state.form = {
|
state.form = {
|
||||||
customer_id: detail.customer_id,
|
customer_id: detail.customer_id,
|
||||||
delivery_date: toPickerValue(detail.delivery_date),
|
delivery_date: toPickerValue(detail.delivery_date),
|
||||||
@@ -2159,6 +2194,16 @@ const InventoryView = {
|
|||||||
warehouse_id: state.purchaseWarehouseId || state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
warehouse_id: state.purchaseWarehouseId || state.warehouses.find(w => w.is_default)?.id || state.warehouses[0]?.id || null,
|
||||||
remark: ''
|
remark: ''
|
||||||
};
|
};
|
||||||
|
} else if (type === 'product') {
|
||||||
|
state.form = { ...item };
|
||||||
|
if (item.item_type === 'finished') {
|
||||||
|
await loadMaterials();
|
||||||
|
const bom = await apiRequest(`/api/products/${item.id}/materials`);
|
||||||
|
state.productBomItems = (bom.items || []).map(bomItem => ({
|
||||||
|
material_id: bomItem.material_id,
|
||||||
|
quantity: bomItem.quantity
|
||||||
|
}));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.form = { ...item };
|
state.form = { ...item };
|
||||||
}
|
}
|
||||||
@@ -2242,6 +2287,171 @@ const InventoryView = {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMaterialConsumptionModal = async () => {
|
||||||
|
// 确保是在销售订单编辑或新增页面
|
||||||
|
if (state.modalType !== 'salesOrder') {
|
||||||
|
addNotification('请先打开销售订单编辑页面', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 加载物料和仓库数据
|
||||||
|
await loadMaterials();
|
||||||
|
await loadWarehouses();
|
||||||
|
// 初始化物料消耗列表
|
||||||
|
state.materialConsumptionItems = [];
|
||||||
|
// 打开物料消耗模态框
|
||||||
|
state.showMaterialConsumptionModal = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMaterialConsumptionItem = () => {
|
||||||
|
state.materialConsumptionItems.push({
|
||||||
|
material_id: state.materials[0]?.id || null,
|
||||||
|
quantity: 1,
|
||||||
|
remark: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMaterialConsumptionItem = (index) => {
|
||||||
|
state.materialConsumptionItems.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMaterialConsumption = async () => {
|
||||||
|
// 确保是在销售订单页面
|
||||||
|
if (state.modalType !== 'salesOrder') {
|
||||||
|
addNotification('请先打开销售订单编辑页面', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果是新增订单,先保存订单再添加物料消耗
|
||||||
|
if (!state.editingItem) {
|
||||||
|
// 关闭物料消耗模态框
|
||||||
|
state.showMaterialConsumptionModal = false;
|
||||||
|
await saveSalesOrder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证物料消耗项
|
||||||
|
for (const [i, item] of state.materialConsumptionItems.entries()) {
|
||||||
|
const idx = i + 1;
|
||||||
|
if (!item.material_id) {
|
||||||
|
addNotification(`第 ${idx} 行:请选择物料`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
|
||||||
|
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 API 保存物料消耗
|
||||||
|
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}/consume-materials`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: state.materialConsumptionItems.map(item => ({
|
||||||
|
material_id: item.material_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
remark: item.remark
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification('物料消耗记录保存成功', 'success');
|
||||||
|
// 关闭模态框
|
||||||
|
state.showMaterialConsumptionModal = false;
|
||||||
|
// 刷新订单详情
|
||||||
|
const detail = await apiRequest(`/api/sales-orders/${state.editingItem.id}`);
|
||||||
|
state.form = {
|
||||||
|
...state.form,
|
||||||
|
actual_material_cost: detail.actual_material_cost
|
||||||
|
};
|
||||||
|
// 刷新已消耗的物料
|
||||||
|
const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${state.editingItem.id}&movement_type=consumption`);
|
||||||
|
state.consumedMaterials = (movements || []).map(movement => ({
|
||||||
|
material_name: movement.product_name || movement.product_sku || '未知物料',
|
||||||
|
quantity: Math.abs(movement.quantity),
|
||||||
|
unit_price: movement.unit_price || 0,
|
||||||
|
amount: movement.total_amount || 0
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, '保存物料消耗');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRestockModal = async () => {
|
||||||
|
// 加载物料和供应商数据
|
||||||
|
await loadMaterials();
|
||||||
|
await loadSuppliers();
|
||||||
|
// 初始化补货列表
|
||||||
|
state.restockItems = [];
|
||||||
|
// 打开补货模态框
|
||||||
|
state.showRestockModal = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRestockItem = () => {
|
||||||
|
state.restockItems.push({
|
||||||
|
material_id: state.materials[0]?.id || null,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
remark: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRestockItem = (index) => {
|
||||||
|
state.restockItems.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRestock = async () => {
|
||||||
|
// 验证补货项
|
||||||
|
if (!state.restockItems || state.restockItems.length === 0) {
|
||||||
|
addNotification('请至少添加一个补货物料', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, item] of state.restockItems.entries()) {
|
||||||
|
const idx = i + 1;
|
||||||
|
if (!item.material_id) {
|
||||||
|
addNotification(`第 ${idx} 行:请选择物料`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(item.quantity) || item.quantity <= 0) {
|
||||||
|
addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(item.unit_price) || item.unit_price < 0) {
|
||||||
|
addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建采购订单
|
||||||
|
const payload = {
|
||||||
|
supplier_id: state.suppliers[0]?.id || null,
|
||||||
|
expected_date: new Date().toISOString().split('T')[0],
|
||||||
|
remark: '物料补货',
|
||||||
|
items: state.restockItems.map(item => ({
|
||||||
|
product_id: item.material_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
remark: item.remark
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiRequest('/api/purchase-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification('采购订单创建成功', 'success');
|
||||||
|
// 关闭补货模态框
|
||||||
|
state.showRestockModal = false;
|
||||||
|
// 清空补货列表
|
||||||
|
state.restockItems = [];
|
||||||
|
// 刷新采购订单列表
|
||||||
|
loadPurchaseOrders();
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, '保存补货订单');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
state.showModal = false;
|
state.showModal = false;
|
||||||
state.modalType = '';
|
state.modalType = '';
|
||||||
@@ -2259,6 +2469,12 @@ const InventoryView = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(state.form)
|
body: JSON.stringify(state.form)
|
||||||
});
|
});
|
||||||
|
if (state.form.item_type === 'finished' && state.productBomItems.length > 0) {
|
||||||
|
await apiRequest(`/api/products/${state.editingItem.id}/materials`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ items: state.productBomItems })
|
||||||
|
});
|
||||||
|
}
|
||||||
addNotification('产品更新成功', 'success');
|
addNotification('产品更新成功', 'success');
|
||||||
} else {
|
} else {
|
||||||
await apiRequest('/api/products', {
|
await apiRequest('/api/products', {
|
||||||
@@ -2287,28 +2503,10 @@ const InventoryView = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editProductBom = async (product) => {
|
|
||||||
try {
|
|
||||||
await loadMaterials();
|
|
||||||
const bom = await apiRequest(`/api/products/${product.id}/materials`);
|
|
||||||
state.modalType = 'productBom';
|
|
||||||
state.editingItem = product;
|
|
||||||
state.productBomItems = (bom.items || []).map(item => ({
|
|
||||||
material_id: item.material_id,
|
|
||||||
quantity: item.quantity,
|
|
||||||
loss_rate: item.loss_rate
|
|
||||||
}));
|
|
||||||
state.showModal = true;
|
|
||||||
} catch (e) {
|
|
||||||
handleApiError(e, '加载产品BOM');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addBomItem = () => {
|
const addBomItem = () => {
|
||||||
state.productBomItems.push({
|
state.productBomItems.push({
|
||||||
material_id: state.materials[0]?.id || null,
|
material_id: state.materials[0]?.id || null,
|
||||||
quantity: 1,
|
quantity: 1
|
||||||
loss_rate: 0
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2316,20 +2514,6 @@ const InventoryView = {
|
|||||||
state.productBomItems.splice(idx, 1);
|
state.productBomItems.splice(idx, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProductBom = async () => {
|
|
||||||
try {
|
|
||||||
await apiRequest(`/api/products/${state.editingItem.id}/materials`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ items: state.productBomItems })
|
|
||||||
});
|
|
||||||
addNotification('产品BOM保存成功', 'success');
|
|
||||||
closeModal();
|
|
||||||
loadProducts();
|
|
||||||
} catch (e) {
|
|
||||||
handleApiError(e, '保存产品BOM');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSupplier = async () => {
|
const saveSupplier = async () => {
|
||||||
try {
|
try {
|
||||||
if (state.editingItem) {
|
if (state.editingItem) {
|
||||||
@@ -2519,19 +2703,45 @@ const InventoryView = {
|
|||||||
remark: item.remark || ''
|
remark: item.remark || ''
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
let orderId;
|
||||||
if (state.editingItem) {
|
if (state.editingItem) {
|
||||||
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, {
|
const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
orderId = state.editingItem.id;
|
||||||
addNotification(result.production_status === 'bom_missing' ? '订单已保存,但成品未配置BOM,未扣减物料' : '销售订单更新成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
|
addNotification(result.production_status === 'bom_missing' ? '订单已保存,但成品未配置BOM,未扣减物料' : '销售订单更新成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
|
||||||
} else {
|
} else {
|
||||||
const result = await apiRequest('/api/sales-orders', {
|
const result = await apiRequest('/api/sales-orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
orderId = result.id;
|
||||||
addNotification(result.production_status === 'bom_missing' ? '订单已创建,但成品未配置BOM,未扣减物料' : '销售订单创建成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
|
addNotification(result.production_status === 'bom_missing' ? '订单已创建,但成品未配置BOM,未扣减物料' : '销售订单创建成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果有物料消耗记录,保存物料消耗
|
||||||
|
if (state.materialConsumptionItems && state.materialConsumptionItems.length > 0) {
|
||||||
|
try {
|
||||||
|
// 调用 API 保存物料消耗
|
||||||
|
const result = await apiRequest(`/api/sales-orders/${orderId}/consume-materials`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: state.materialConsumptionItems.map(item => ({
|
||||||
|
material_id: item.material_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
remark: item.remark
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
addNotification('物料消耗记录保存成功', 'success');
|
||||||
|
// 清空物料消耗记录
|
||||||
|
state.materialConsumptionItems = [];
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, '保存物料消耗');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
loadProductionOrders();
|
loadProductionOrders();
|
||||||
loadFinishedProducts();
|
loadFinishedProducts();
|
||||||
@@ -2555,6 +2765,19 @@ const InventoryView = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePurchaseOrderStatus = async (order, targetStatus) => {
|
||||||
|
try {
|
||||||
|
await apiRequest(`/api/purchase-orders/${order.id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: targetStatus })
|
||||||
|
});
|
||||||
|
addNotification('采购订单状态已更新', 'success');
|
||||||
|
loadPurchaseOrders();
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, '更新采购订单状态');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteSalesOrder = async (orderId) => {
|
const deleteSalesOrder = async (orderId) => {
|
||||||
if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return;
|
if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return;
|
||||||
try {
|
try {
|
||||||
@@ -2713,10 +2936,8 @@ const InventoryView = {
|
|||||||
openDateTimePicker,
|
openDateTimePicker,
|
||||||
saveProduct,
|
saveProduct,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
editProductBom,
|
|
||||||
addBomItem,
|
addBomItem,
|
||||||
removeBomItem,
|
removeBomItem,
|
||||||
saveProductBom,
|
|
||||||
saveSupplier,
|
saveSupplier,
|
||||||
deleteSupplier,
|
deleteSupplier,
|
||||||
saveCustomer,
|
saveCustomer,
|
||||||
@@ -2727,8 +2948,9 @@ const InventoryView = {
|
|||||||
removeSalesOrderItem,
|
removeSalesOrderItem,
|
||||||
setSalesOrderLineMode,
|
setSalesOrderLineMode,
|
||||||
saveSalesOrder,
|
saveSalesOrder,
|
||||||
deleteSalesOrder,
|
|
||||||
updateSalesOrderStatus,
|
updateSalesOrderStatus,
|
||||||
|
updatePurchaseOrderStatus,
|
||||||
|
deleteSalesOrder,
|
||||||
addPurchaseOrderItem,
|
addPurchaseOrderItem,
|
||||||
removePurchaseOrderItem,
|
removePurchaseOrderItem,
|
||||||
savePurchaseOrder,
|
savePurchaseOrder,
|
||||||
@@ -2739,7 +2961,17 @@ const InventoryView = {
|
|||||||
|
|
||||||
refreshFinanceByPeriod,
|
refreshFinanceByPeriod,
|
||||||
getMovementTypeLabel,
|
getMovementTypeLabel,
|
||||||
getMovementBadgeClass
|
getMovementBadgeClass,
|
||||||
|
openMaterialConsumptionModal,
|
||||||
|
addMaterialConsumptionItem,
|
||||||
|
removeMaterialConsumptionItem,
|
||||||
|
saveMaterialConsumption,
|
||||||
|
openRestockModal,
|
||||||
|
addRestockItem,
|
||||||
|
removeRestockItem,
|
||||||
|
saveRestock,
|
||||||
|
getPurchaseOrderStatusLabel,
|
||||||
|
isPurchaseOrderLocked
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
@@ -2866,7 +3098,6 @@ const InventoryView = {
|
|||||||
<td>{{ formatCurrency(product.material_cost || 0) }}</td>
|
<td>{{ formatCurrency(product.material_cost || 0) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<button class="btn btn-sm btn-secondary" @click="editProductBom(product)">BOM</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" @click="openModal('product', product)">编辑</button>
|
<button class="btn btn-sm btn-secondary" @click="openModal('product', product)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
<button class="btn btn-sm btn-danger" @click="deleteProduct(product.id)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2880,6 +3111,7 @@ const InventoryView = {
|
|||||||
<div v-else-if="state.activeTab === 'materials'">
|
<div v-else-if="state.activeTab === 'materials'">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<button class="btn btn-primary" @click="openCreateProduct('material')">+ 新增物料</button>
|
<button class="btn btn-primary" @click="openCreateProduct('material')">+ 新增物料</button>
|
||||||
|
<button class="btn btn-secondary" @click="openRestockModal">+ 物料补货</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -2971,7 +3203,10 @@ const InventoryView = {
|
|||||||
<th>采购单</th>
|
<th>采购单</th>
|
||||||
<th>供应商</th>
|
<th>供应商</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>订单创建</th>
|
||||||
<th>预计到货</th>
|
<th>预计到货</th>
|
||||||
|
<th>实际到货</th>
|
||||||
|
<th>实际付款</th>
|
||||||
<th>总金额</th>
|
<th>总金额</th>
|
||||||
<th>已付款</th>
|
<th>已付款</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@@ -2981,15 +3216,19 @@ const InventoryView = {
|
|||||||
<tr v-for="order in state.purchaseOrders" :key="'purchase-order-' + order.id">
|
<tr v-for="order in state.purchaseOrders" :key="'purchase-order-' + order.id">
|
||||||
<td>{{ order.order_no }}</td>
|
<td>{{ order.order_no }}</td>
|
||||||
<td>{{ order.supplier_name }}</td>
|
<td>{{ order.supplier_name }}</td>
|
||||||
<td>{{ order.status }}</td>
|
<td>{{ getPurchaseOrderStatusLabel(order.status) }}</td>
|
||||||
|
<td>{{ order.created_at ? formatDateTime(order.created_at) : '-' }}</td>
|
||||||
<td>{{ order.expected_date ? formatDate(order.expected_date) : '-' }}</td>
|
<td>{{ order.expected_date ? formatDate(order.expected_date) : '-' }}</td>
|
||||||
|
<td>{{ order.received_date ? formatDateTime(order.received_date) : '-' }}</td>
|
||||||
|
<td>{{ order.paid_date ? formatDateTime(order.paid_date) : '-' }}</td>
|
||||||
<td>{{ formatCurrency(order.total_amount || 0) }}</td>
|
<td>{{ formatCurrency(order.total_amount || 0) }}</td>
|
||||||
<td>{{ formatCurrency(order.paid_amount || 0) }}</td>
|
<td>{{ formatCurrency(order.paid_amount || 0) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<button class="btn btn-sm btn-secondary" @click="openModal('purchaseOrder', order)">编辑</button>
|
<button class="btn btn-sm btn-secondary" @click="openModal('purchaseOrder', order)" :disabled="isPurchaseOrderLocked(order.status)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)">删除</button>
|
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)" :disabled="isPurchaseOrderLocked(order.status)">删除</button>
|
||||||
<button class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
|
<button v-if="order.status === 'pending'" class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
|
||||||
|
<button v-else-if="order.status === 'received'" class="btn btn-sm btn-success" @click="updatePurchaseOrderStatus(order, 'paid')">标记为已付款</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -3360,7 +3599,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 === 'inventoryItem' ? '物料库存' : state.modalType === 'salesOrder' ? '销售订单' : state.modalType === 'purchaseOrder' ? '采购订单' : state.modalType === 'purchaseReceive' ? '采购到货入库' : state.modalType === 'supplier' ? '供应商' : '客户' }}</h3>
|
<h3>{{ (state.editingItem ? '编辑' : '新增') + (state.modalType === 'product' ? (state.form.item_type === 'finished' ? '成品' : '物料') : state.modalType === 'inventoryItem' ? '物料库存' : state.modalType === 'salesOrder' ? '销售订单' : state.modalType === 'purchaseOrder' ? '采购订单' : state.modalType === 'purchaseReceive' ? '采购到货入库' : state.modalType === 'supplier' ? '供应商' : '客户') }}</h3>
|
||||||
<button class="modal-close" @click="closeModal">×</button>
|
<button class="modal-close" @click="closeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -3403,6 +3642,36 @@ const InventoryView = {
|
|||||||
<label class="form-label">说明</label>
|
<label class="form-label">说明</label>
|
||||||
<input disabled value="成品不做库存,成本由下方BOM定义物料构成后自动计算" class="form-input" />
|
<input disabled value="成品不做库存,成本由下方BOM定义物料构成后自动计算" class="form-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="state.form.item_type === 'finished' && state.editingItem" class="bom-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">BOM物料配置</label>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="addBomItem" style="margin-bottom: 8px;">+ 添加物料</button>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>物料</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, idx) in state.productBomItems" :key="'edit-bom-item-' + idx">
|
||||||
|
<td>
|
||||||
|
<select v-model.number="item.material_id" class="form-input" required>
|
||||||
|
<option v-for="material in state.materials" :key="'edit-bom-material-' + material.id" :value="material.id">
|
||||||
|
{{ material.sku }} - {{ material.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input v-model.number="item.quantity" type="number" min="0.0001" step="0.0001" class="form-input" required /></td>
|
||||||
|
<td><button type="button" class="btn btn-sm btn-danger" @click="removeBomItem(idx)">删除</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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">保存</button>
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
@@ -3430,6 +3699,7 @@ const InventoryView = {
|
|||||||
<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.remark" class="form-input" placeholder="订单备注" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 模具信息 -->
|
||||||
<div class="form-group" style="margin-bottom: var(--space-4);">
|
<div class="form-group" style="margin-bottom: var(--space-4);">
|
||||||
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加模具</button>
|
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加模具</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3492,6 +3762,40 @@ const InventoryView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 物料信息 -->
|
||||||
|
<div class="form-group" style="margin-bottom: var(--space-4);">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="openMaterialConsumptionModal">+ 消耗物料</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: var(--space-4);">
|
||||||
|
<label class="form-label">已消耗物料</label>
|
||||||
|
<div v-if="state.editingItem && state.editingItem.actual_material_cost > 0" class="material-consumption-list">
|
||||||
|
<div class="material-consumption-header">
|
||||||
|
<div class="material-consumption-item">物料名称</div>
|
||||||
|
<div class="material-consumption-item">数量</div>
|
||||||
|
<div class="material-consumption-item">单价</div>
|
||||||
|
<div class="material-consumption-item">金额</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(item, index) in state.consumedMaterials" :key="'consumed-material-' + index" class="material-consumption-row">
|
||||||
|
<div class="material-consumption-item">{{ item.material_name }}</div>
|
||||||
|
<div class="material-consumption-item">{{ item.quantity }}</div>
|
||||||
|
<div class="material-consumption-item">{{ formatCurrency(item.unit_price) }}</div>
|
||||||
|
<div class="material-consumption-item">{{ formatCurrency(item.amount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="material-consumption-total">
|
||||||
|
<div class="material-consumption-item">合计</div>
|
||||||
|
<div class="material-consumption-item"></div>
|
||||||
|
<div class="material-consumption-item"></div>
|
||||||
|
<div class="material-consumption-item">{{ formatCurrency(state.editingItem.actual_material_cost) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<div class="empty-icon">📦</div>
|
||||||
|
<div class="empty-title">暂无物料消耗记录</div>
|
||||||
|
<div class="empty-desc">点击"消耗物料"按钮添加物料消耗明细</div>
|
||||||
|
</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">保存订单</button>
|
<button type="submit" class="btn btn-primary">保存订单</button>
|
||||||
@@ -3620,42 +3924,6 @@ const InventoryView = {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form v-else-if="state.modalType === 'productBom'" @submit.prevent="saveProductBom">
|
|
||||||
<div class="table-header" style="margin-bottom: 12px;">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="addBomItem">+ 添加物料</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>物料</th>
|
|
||||||
<th>数量</th>
|
|
||||||
<th>损耗率</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(item, idx) in state.productBomItems" :key="'bom-item-' + idx">
|
|
||||||
<td>
|
|
||||||
<select v-model.number="item.material_id" class="form-input" required>
|
|
||||||
<option v-for="material in state.materials" :key="'bom-material-' + material.id" :value="material.id">
|
|
||||||
{{ material.sku }} - {{ material.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input v-model.number="item.quantity" type="number" min="0.0001" step="0.0001" class="form-input" required /></td>
|
|
||||||
<td><input v-model.number="item.loss_rate" type="number" min="0" step="0.0001" class="form-input" required /></td>
|
|
||||||
<td><button type="button" class="btn btn-sm btn-danger" @click="removeBomItem(idx)">删除</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
|
|
||||||
<button type="submit" class="btn btn-primary">保存BOM</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- 供应商表单 -->
|
<!-- 供应商表单 -->
|
||||||
<form v-else-if="state.modalType === 'supplier'" @submit.prevent="saveSupplier">
|
<form v-else-if="state.modalType === 'supplier'" @submit.prevent="saveSupplier">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -3755,6 +4023,149 @@ const InventoryView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 物料消耗模态框 -->
|
||||||
|
<div v-if="state.showMaterialConsumptionModal" class="modal-overlay" @click.self="state.showMaterialConsumptionModal = false">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>添加物料消耗</h3>
|
||||||
|
<button class="modal-close" @click="state.showMaterialConsumptionModal = false">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group" style="margin-bottom: var(--space-4);">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="addMaterialConsumptionItem">+ 添加物料</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="state.materialConsumptionItems.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">📦</div>
|
||||||
|
<div class="empty-title">暂无物料</div>
|
||||||
|
<div class="empty-desc">请点击"添加物料"按钮添加物料消耗明细</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="purchase-order-items">
|
||||||
|
<div v-for="(line, index) in state.materialConsumptionItems" :key="'material-consumption-line-' + index" class="purchase-order-item">
|
||||||
|
<div class="purchase-order-item-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">物料 *</label>
|
||||||
|
<select v-model.number="line.material_id" class="form-input" required>
|
||||||
|
<option value="">请选择物料</option>
|
||||||
|
<option v-for="material in state.materials" :key="'consumption-material-' + material.id" :value="material.id">
|
||||||
|
{{ material.sku }} - {{ material.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-row">
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">数量 *</label>
|
||||||
|
<input v-model.number="line.quantity" type="number" min="1" class="form-input" placeholder="请输入数量" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">单价</label>
|
||||||
|
<div class="form-input" style="padding: 8px 12px; background: var(--bg-secondary);">
|
||||||
|
¥{{ (state.materials.find(m => m.id === line.material_id)?.cost_price || 0).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">总价</label>
|
||||||
|
<div class="form-input" style="padding: 8px 12px; background: var(--bg-secondary);">
|
||||||
|
¥{{ ((state.materials.find(m => m.id === line.material_id)?.cost_price || 0) * (line.quantity || 0)).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">备注</label>
|
||||||
|
<input v-model="line.remark" class="form-input" placeholder="请输入备注" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" @click="removeMaterialConsumptionItem(index)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="state.showMaterialConsumptionModal = false">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveMaterialConsumption">保存物料消耗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 物料补货模态框 -->
|
||||||
|
<div v-if="state.showRestockModal" class="modal-overlay" @click.self="state.showRestockModal = false">
|
||||||
|
<div class="modal-content" style="max-width: 900px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>物料补货</h3>
|
||||||
|
<button class="modal-close" @click="state.showRestockModal = false">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||||
|
<h4>补货物料明细</h4>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="addRestockItem">+ 添加物料</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="state.restockItems.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">📦</div>
|
||||||
|
<div class="empty-desc">请点击"添加物料"按钮添加补货物料明细</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="purchase-order-items">
|
||||||
|
<div v-for="(line, index) in state.restockItems" :key="'restock-line-' + index" class="purchase-order-item">
|
||||||
|
<div class="purchase-order-item-row">
|
||||||
|
<div class="purchase-order-item-column" style="flex: 2;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">物料 <span class="required">*</span></label>
|
||||||
|
<select v-model.number="line.material_id" class="form-input" required>
|
||||||
|
<option v-for="material in state.materials" :key="'restock-material-' + material.id" :value="material.id">
|
||||||
|
{{ material.sku }} - {{ material.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">数量 <span class="required">*</span></label>
|
||||||
|
<input v-model.number="line.quantity" type="number" class="form-input" min="1" step="1" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">单价</label>
|
||||||
|
<input v-model.number="line.unit_price" type="number" class="form-input" min="0" step="0.01" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">总价</label>
|
||||||
|
<div class="form-input" style="padding: 8px 12px; background: var(--bg-secondary);">
|
||||||
|
¥{{ ((line.unit_price || 0) * (line.quantity || 0)).toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-column">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">备注</label>
|
||||||
|
<input v-model="line.remark" class="form-input" placeholder="请输入备注" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-order-item-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" @click="removeRestockItem(index)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="state.showRestockModal = false">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveRestock">创建采购订单</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user