diff --git a/.env b/.env index 0de4387..d9b38af 100644 --- a/.env +++ b/.env @@ -32,7 +32,7 @@ DB_USER=moldinsight DB_PASSWORD=Qqs1996* # RustFS 对象存储配置 (S3v4 API) -RUSTFS_ENDPOINT=http://szcjw:9000 +RUSTFS_ENDPOINT=http://szcjw:8010 RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB RUSTFS_TIMEOUT=30 diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index 41a36b3..accde36 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -205,7 +205,7 @@ async def create_purchase_order( expected_date=order_data.expected_date, remark=order_data.remark, operator_id=current_user.id, - status="draft" + status="pending" ) db_session.add(order) await db_session.flush() @@ -255,7 +255,7 @@ async def update_purchase_order( order.expected_date = order_data.expected_date order.remark = order_data.remark order.total_amount = await _apply_order_items(db_session, order, order_data) - order.status = "draft" + order.status = "pending" await db_session.commit() await db_session.refresh(order) @@ -283,6 +283,39 @@ async def delete_purchase_order( 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 + 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) async def receive_purchase_order( order_id: int, diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py index ea096c0..8ff5d45 100644 --- a/src/api/inventory/sales_order_routes.py +++ b/src/api/inventory/sales_order_routes.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete, update from typing import Optional, List from math import ceil +from pydantic import BaseModel, Field from database.database import get_db_session 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), 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": raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除") await _rollback_issued_materials(db_session, order, current_user) @@ -522,6 +523,88 @@ async def delete_sales_order( 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) async def get_sales_order_production_plan( order_id: int, diff --git a/src/models/database.py b/src/models/database.py index 5ef11f9..cf8539b 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -653,8 +653,8 @@ class Inventory(Base): id = Column(Integer, primary_key=True, index=True) product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True) warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True) - quantity = Column(Integer, default=0) - locked_quantity = Column(Integer, default=0) + quantity = Column(Float, default=0) + locked_quantity = Column(Float, default=0) batch_number = Column(String(50), nullable=True) location = Column(String(100), nullable=True) 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) warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False) movement_type = Column(String(20), nullable=False) - quantity = Column(Integer, nullable=False) - before_quantity = Column(Integer, default=0) - after_quantity = Column(Integer, default=0) + quantity = Column(Float, nullable=False) + before_quantity = Column(Float, default=0) + after_quantity = Column(Float, default=0) reference_type = Column(String(50), nullable=True) reference_id = Column(Integer, nullable=True) reference_no = Column(String(50), nullable=True) diff --git a/static/style.css b/static/style.css index 9ad45c1..ed25ac4 100644 --- a/static/style.css +++ b/static/style.css @@ -2764,6 +2764,47 @@ select.form-input { 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 { width: 100%; height: 500px; diff --git a/static/vue-app.js b/static/vue-app.js index 1a06371..46896b9 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1588,6 +1588,11 @@ const InventoryView = { modalType: '', editingItem: null, productBomItems: [], + materialConsumptionItems: [], + showMaterialConsumptionModal: false, + consumedMaterials: [], + restockItems: [], + showRestockModal: false, form: {} }); @@ -1620,17 +1625,25 @@ const InventoryView = { const toApiDateTime = (value) => { if (!value) return null; const text = String(value).trim(); + // 提取日期部分,忽略时间部分 + if (text.includes('T')) { + return text.split('T')[0]; + } + if (text.includes(' ')) { + return text.split(' ')[0]; + } // 如果是只有日期部分的格式 (yyyy-MM-dd) - if (text.length === 10 && !text.includes('T') && !text.includes(' ')) { + if (text.length === 10) { return text; // 直接返回日期格式,后端 Pydantic 会自动处理 } - // 如果是带时间的格式,转换为 ISO 8601 格式 - if (text.includes('T')) { - return text.length === 16 ? `${text}:00` : text; + // 如果是日期对象 + if (value instanceof Date) { + 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 格式 - const isoText = text.replace(' ', 'T'); - return isoText.length === 16 ? `${isoText}:00` : isoText; + return text; }; const toNativeValue = (value) => { @@ -1752,6 +1765,20 @@ const InventoryView = { 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 () => { state.loading = true; try { @@ -2108,6 +2135,14 @@ const InventoryView = { await loadCustomers(); await loadFinishedProducts(); 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 = { customer_id: detail.customer_id, delivery_date: toPickerValue(detail.delivery_date), @@ -2236,6 +2271,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 = () => { state.showModal = false; state.modalType = ''; @@ -2513,19 +2713,45 @@ const InventoryView = { remark: item.remark || '' })) }; + let orderId; if (state.editingItem) { const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, { method: 'PUT', body: JSON.stringify(payload) }); + orderId = state.editingItem.id; addNotification(result.production_status === 'bom_missing' ? '订单已保存,但成品未配置BOM,未扣减物料' : '销售订单更新成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success'); } else { const result = await apiRequest('/api/sales-orders', { method: 'POST', body: JSON.stringify(payload) }); + orderId = result.id; 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(); loadProductionOrders(); loadFinishedProducts(); @@ -2549,6 +2775,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) => { if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return; try { @@ -2721,8 +2960,9 @@ const InventoryView = { removeSalesOrderItem, setSalesOrderLineMode, saveSalesOrder, - deleteSalesOrder, updateSalesOrderStatus, + updatePurchaseOrderStatus, + deleteSalesOrder, addPurchaseOrderItem, removePurchaseOrderItem, savePurchaseOrder, @@ -2733,7 +2973,17 @@ const InventoryView = { refreshFinanceByPeriod, getMovementTypeLabel, - getMovementBadgeClass + getMovementBadgeClass, + openMaterialConsumptionModal, + addMaterialConsumptionItem, + removeMaterialConsumptionItem, + saveMaterialConsumption, + openRestockModal, + addRestockItem, + removeRestockItem, + saveRestock, + getPurchaseOrderStatusLabel, + isPurchaseOrderLocked }; }, template: ` @@ -2874,6 +3124,7 @@ const InventoryView = {
+
@@ -2975,15 +3226,16 @@ const InventoryView = { - + @@ -3424,6 +3676,7 @@ const InventoryView = { +
@@ -3486,6 +3739,40 @@ const InventoryView = { + + +
+ +
+
+ +
+
+
物料名称
+
数量
+
单价
+
金额
+
+
+
{{ item.material_name }}
+
{{ item.quantity }}
+
{{ formatCurrency(item.unit_price) }}
+
{{ formatCurrency(item.amount) }}
+
+
+
合计
+
+
+
{{ formatCurrency(state.editingItem.actual_material_cost) }}
+
+
+
+
📦
+
暂无物料消耗记录
+
点击"消耗物料"按钮添加物料消耗明细
+
+
+ + + + + + + ` };
{{ order.order_no }} {{ order.supplier_name }}{{ order.status }}{{ getPurchaseOrderStatusLabel(order.status) }} {{ order.expected_date ? formatDate(order.expected_date) : '-' }} {{ formatCurrency(order.total_amount || 0) }} {{ formatCurrency(order.paid_amount || 0) }}
- - - + + + +