Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-04-13 09:44:50 +08:00
12 changed files with 777 additions and 109 deletions
+1 -1
View File
@@ -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
+75
View File
@@ -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())
+3 -7
View File
@@ -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,
) )
) )
+63 -6
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 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"
+84 -1
View File
@@ -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
+2
View File
@@ -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,
+8 -5
View File
@@ -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
View File
@@ -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>
+41
View File
@@ -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
View File
@@ -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">&times;</button> <button class="modal-close" @click="closeModal">&times;</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">&times;</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">&times;</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>
` `
}; };