This commit is contained in:
2026-03-29 16:24:43 +08:00
parent 3228b7e28d
commit c72c47b102
6 changed files with 609 additions and 22 deletions
+443 -13
View File
@@ -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 = {
<div v-else-if="state.activeTab === 'materials'">
<div class="table-header">
<button class="btn btn-primary" @click="openCreateProduct('material')">+ 新增物料</button>
<button class="btn btn-secondary" @click="openRestockModal">+ 物料补货</button>
</div>
<div class="table-container">
<table class="data-table">
@@ -2975,15 +3226,16 @@ const InventoryView = {
<tr v-for="order in state.purchaseOrders" :key="'purchase-order-' + order.id">
<td>{{ order.order_no }}</td>
<td>{{ order.supplier_name }}</td>
<td>{{ order.status }}</td>
<td>{{ getPurchaseOrderStatusLabel(order.status) }}</td>
<td>{{ order.expected_date ? formatDate(order.expected_date) : '-' }}</td>
<td>{{ formatCurrency(order.total_amount || 0) }}</td>
<td>{{ formatCurrency(order.paid_amount || 0) }}</td>
<td>
<div class="action-btns">
<button class="btn btn-sm btn-secondary" @click="openModal('purchaseOrder', order)">编辑</button>
<button class="btn btn-sm btn-danger" @click="deletePurchaseOrder(order.id)">删除</button>
<button class="btn btn-sm btn-primary" @click="openModal('purchaseReceive', order)">到货入库</button>
<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)" :disabled="isPurchaseOrderLocked(order.status)">删除</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>
</td>
</tr>
@@ -3424,6 +3676,7 @@ const InventoryView = {
<label class="form-label">备注</label>
<input v-model="state.form.remark" class="form-input" placeholder="订单备注" />
</div>
<!-- 模具信息 -->
<div class="form-group" style="margin-bottom: var(--space-4);">
<button type="button" class="btn btn-secondary" @click="addSalesOrderItem">+ 添加模具</button>
</div>
@@ -3486,6 +3739,40 @@ const InventoryView = {
</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">
<button type="button" class="btn btn-secondary" @click="closeModal">取消</button>
<button type="submit" class="btn btn-primary">保存订单</button>
@@ -3749,6 +4036,149 @@ const InventoryView = {
</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>
`
};