| | |
| | | <template> |
| | | <div> |
| | | <el-dialog v-model="dialogVisible" |
| | | title="物料" |
| | | title="工序领料" |
| | | width="1200px" |
| | | @close="handleCloseMaterialDialog"> |
| | | <div class="material-toolbar"> |
| | | <span> |
| | | 按当前工序的 BOM 物料领用,也可添加 BOM 外的物料;填写本次领料数量并选择批号后领用,库存立即扣减。 |
| | | </span> |
| | | <el-button type="primary" |
| | | link |
| | | @click="openProductDialog">添加物料</el-button> |
| | | </div> |
| | | <el-table v-loading="materialTableLoading" |
| | | :data="materialTableData" |
| | | :data="pickRows" |
| | | border |
| | | row-key="id"> |
| | | row-key="rowKey"> |
| | | <el-table-column label="工序名称" |
| | | prop="processName" |
| | | prop="operationName" |
| | | min-width="120" /> |
| | | <el-table-column label="物料名称" |
| | | prop="productName" |
| | | min-width="140" /> |
| | | <el-table-column label="原料名称" |
| | | prop="materialName" |
| | | min-width="140" /> |
| | | <el-table-column label="原料型号" |
| | | prop="materialModel" |
| | | <el-table-column label="物料规格" |
| | | prop="model" |
| | | min-width="140" /> |
| | | <el-table-column label="计量单位" |
| | | prop="unit" |
| | | min-width="100" /> |
| | | <el-table-column label="线边仓数量" |
| | | prop="pickQty" |
| | | min-width="100" /> |
| | | <el-table-column label="补料数量" |
| | | prop="supplementQty" |
| | | min-width="100" /> |
| | | <el-table-column label="实际数量" |
| | | min-width="140"> |
| | | width="90" /> |
| | | <el-table-column label="来源" |
| | | width="90"> |
| | | <template #default="{ row }"> |
| | | <el-input-number v-model="row.actualQty" |
| | | <el-tag :type="row.bom ? 'success' : 'info'" |
| | | size="small">{{ row.bom ? "BOM" : "手工" }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="可用库存" |
| | | prop="stockQuantity" |
| | | min-width="100" /> |
| | | <el-table-column label="批号" |
| | | min-width="200"> |
| | | <template #default="{ row }"> |
| | | <el-select v-model="row.batchNo" |
| | | placeholder="请选择批号" |
| | | filterable |
| | | clearable |
| | | style="width: 100%;"> |
| | | <el-option v-for="batchNo in row.batchNoList || []" |
| | | :key="batchNo" |
| | | :label="batchNo" |
| | | :value="batchNo" /> |
| | | </el-select> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="本次领料数量" |
| | | min-width="150"> |
| | | <template #default="{ row }"> |
| | | <el-input-number v-model="row.pickQuantity" |
| | | :min="0" |
| | | :precision="3" |
| | | :step="1" |
| | |
| | | <el-table-column label="操作" |
| | | align="center" |
| | | fixed="right" |
| | | width="180"> |
| | | <template #default="{ row }"> |
| | | <el-button type="primary" |
| | | width="90"> |
| | | <template #default="{ row, $index }"> |
| | | <el-button v-if="!row.bom" |
| | | type="danger" |
| | | link |
| | | @click="openSupplementDialog(row)">补料</el-button> |
| | | <el-button type="info" |
| | | link |
| | | @click="openSupplementRecordDialog(row)">补料记录</el-button> |
| | | @click="removePickRow($index)">移除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <div class="material-section-title">本工序已领料记录</div> |
| | | <el-table v-loading="materialTableLoading" |
| | | :data="pickedRows" |
| | | border |
| | | row-key="id"> |
| | | <el-table-column label="物料名称" |
| | | prop="productName" |
| | | min-width="140" /> |
| | | <el-table-column label="物料规格" |
| | | prop="model" |
| | | min-width="140" /> |
| | | <el-table-column label="批号" |
| | | prop="batchNo" |
| | | min-width="150" /> |
| | | <el-table-column label="计量单位" |
| | | prop="unit" |
| | | width="90" /> |
| | | <el-table-column label="领料数量" |
| | | prop="pickQuantity" |
| | | min-width="110" /> |
| | | <el-table-column label="退料数量" |
| | | min-width="110"> |
| | | <template #default="{ row }">{{ row.returnQty || 0 }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="净领料数量" |
| | | prop="effectiveQuantity" |
| | | min-width="120" /> |
| | | <el-table-column label="领料时间" |
| | | min-width="120"> |
| | | <template #default="{ row }"> |
| | | {{ formatDate(row.createTime) }} |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="本次退料数量" |
| | | min-width="150"> |
| | | <template #default="{ row }"> |
| | | <el-input-number v-model="row.currentReturnQty" |
| | | :min="0" |
| | | :max="Number(row.effectiveQuantity || 0)" |
| | | :precision="3" |
| | | :step="1" |
| | | controls-position="right" |
| | | style="width: 100%;" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" |
| | | align="center" |
| | | fixed="right" |
| | | width="90"> |
| | | <template #default="{ row }"> |
| | | <el-button type="primary" |
| | | link |
| | | :loading="returningId === row.id" |
| | | :disabled="Number(row.effectiveQuantity || 0) <= 0" |
| | | @click="handleReturnMaterial(row)">退料</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | |
| | | <template #footer> |
| | | <span class="dialog-footer"> |
| | | <el-button type="primary" |
| | |
| | | </span> |
| | | </template> |
| | | </el-dialog> |
| | | <FormDialog v-model="supplementDialogVisible" |
| | | title="补料" |
| | | width="500px" |
| | | @confirm="handleSubmitSupplement"> |
| | | <el-form ref="supplementFormRef" |
| | | :model="supplementForm" |
| | | :rules="supplementRules" |
| | | label-width="100px"> |
| | | <el-form-item label="补料数量" |
| | | prop="supplementQty"> |
| | | <el-input-number v-model="supplementForm.supplementQty" |
| | | :min="0.001" |
| | | :precision="3" |
| | | :step="1" |
| | | style="width: 100%;" /> |
| | | </el-form-item> |
| | | <el-form-item label="补料原因" |
| | | prop="supplementReason"> |
| | | <el-input v-model="supplementForm.supplementReason" |
| | | type="textarea" |
| | | :rows="3" |
| | | maxlength="200" |
| | | show-word-limit |
| | | placeholder="请输入补料原因" /> |
| | | </el-form-item> |
| | | </el-form> |
| | | <template #footer> |
| | | <span class="dialog-footer"> |
| | | <el-button type="primary" |
| | | :loading="supplementSubmitting" |
| | | @click="handleSubmitSupplement">确定</el-button> |
| | | <el-button @click="supplementDialogVisible = false">取消</el-button> |
| | | </span> |
| | | </template> |
| | | </FormDialog> |
| | | <el-dialog v-model="supplementRecordDialogVisible" |
| | | title="补料记录" |
| | | width="900px"> |
| | | <el-table v-loading="supplementRecordLoading" |
| | | :data="supplementRecordTableData" |
| | | border |
| | | row-key="id"> |
| | | <el-table-column label="补料数量" |
| | | prop="supplementQty" |
| | | min-width="100" /> |
| | | <el-table-column label="补料原因" |
| | | prop="supplementReason" |
| | | min-width="200" /> |
| | | <el-table-column label="补料人" |
| | | prop="supplementUserName" |
| | | min-width="120" /> |
| | | <el-table-column label="补料日期" |
| | | prop="supplementTime" |
| | | min-width="160" /> |
| | | </el-table> |
| | | <template #footer> |
| | | <span class="dialog-footer"> |
| | | <el-button @click="supplementRecordDialogVisible = false">关闭</el-button> |
| | | </span> |
| | | </template> |
| | | </el-dialog> |
| | | <ProductSelectDialog v-if="productDialogVisible" |
| | | v-model:model-value="productDialogVisible" |
| | | :single="true" |
| | | @confirm="handleProductConfirm" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, nextTick, reactive, ref, watch } from "vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import FormDialog from "@/components/Dialog/FormDialog.vue"; |
| | | import { computed, defineAsyncComponent, ref, watch } from "vue"; |
| | | import { ElMessage, ElMessageBox } from "element-plus"; |
| | | import { |
| | | listWorkOrderMaterialLedger, |
| | | addWorkOrderMaterialSupplement, |
| | | listWorkOrderMaterialSupplementRecord, |
| | | pickWorkOrderMaterial, |
| | | returnWorkOrderMaterial, |
| | | } from "@/api/productionManagement/workOrder.js"; |
| | | |
| | | const ProductSelectDialog = defineAsyncComponent(() => |
| | | import("@/views/basicData/product/ProductSelectDialog.vue") |
| | | ); |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { |
| | |
| | | }); |
| | | |
| | | const materialTableLoading = ref(false); |
| | | const materialTableData = ref([]); |
| | | const currentMaterialRow = ref(null); |
| | | const currentMaterialOrderRow = ref(null); |
| | | // 本次可领用的物料行:BOM 建议 + 手工添加 |
| | | const pickRows = ref([]); |
| | | // 当前工序已产生的领料记录 |
| | | const pickedRows = ref([]); |
| | | const productDialogVisible = ref(false); |
| | | const currentWorkOrderRow = ref(null); |
| | | const pickSubmitting = ref(false); |
| | | const returningId = ref(null); |
| | | |
| | | const supplementDialogVisible = ref(false); |
| | | const supplementSubmitting = ref(false); |
| | | const supplementFormRef = ref(null); |
| | | const supplementForm = reactive({ |
| | | supplementQty: null, |
| | | supplementReason: "", |
| | | }); |
| | | const formatDate = value => (value ? String(value).slice(0, 10) : "-"); |
| | | |
| | | const supplementRecordDialogVisible = ref(false); |
| | | const supplementRecordLoading = ref(false); |
| | | const supplementRecordTableData = ref([]); |
| | | const createRowKey = () => `${Date.now()}_${Math.random()}`; |
| | | |
| | | const supplementRules = { |
| | | supplementQty: [ |
| | | { required: true, message: "请输入补料数量", trigger: "blur" }, |
| | | ], |
| | | supplementReason: [ |
| | | { required: true, message: "请输入补料原因", trigger: "blur" }, |
| | | ], |
| | | }; |
| | | const loadMaterialTable = async row => { |
| | | if (!row?.id) return; |
| | | currentMaterialOrderRow.value = row; |
| | | currentWorkOrderRow.value = row; |
| | | materialTableLoading.value = true; |
| | | materialTableData.value = []; |
| | | pickRows.value = []; |
| | | pickedRows.value = []; |
| | | try { |
| | | const res = await listWorkOrderMaterialLedger({ |
| | | workOrderId: row.id, |
| | | processId: row.processId, |
| | | productProcessRouteItemId: row.productProcessRouteItemId, |
| | | }); |
| | | materialTableData.value = res.data || []; |
| | | const res = await listWorkOrderMaterialLedger(row.id); |
| | | const records = Array.isArray(res.data) ? res.data : res.data?.records || []; |
| | | pickRows.value = records |
| | | .filter(item => item.picked === false || !item.id) |
| | | .map(item => ({ |
| | | rowKey: createRowKey(), |
| | | bom: item.bom !== false, |
| | | productModelId: item.productModelId, |
| | | productName: item.productName || "", |
| | | model: item.model || "", |
| | | unit: item.unit || "", |
| | | operationName: item.operationName || "", |
| | | stockQuantity: item.stockQuantity ?? 0, |
| | | batchNoList: item.batchNoList || [], |
| | | batchNo: "", |
| | | pickQuantity: 0, |
| | | })); |
| | | pickedRows.value = records |
| | | .filter(item => item.picked === true || item.id) |
| | | .map(item => ({ |
| | | ...item, |
| | | pickQuantity: item.pickQuantity ?? item.quantity ?? 0, |
| | | effectiveQuantity: item.effectiveQuantity ?? 0, |
| | | currentReturnQty: 0, |
| | | })); |
| | | } catch (e) { |
| | | console.error("获取物料台账失败", e); |
| | | ElMessage.error("获取物料台账失败"); |
| | | console.error("获取工序物料失败", e); |
| | | ElMessage.error("获取工序物料失败"); |
| | | } finally { |
| | | materialTableLoading.value = false; |
| | | } |
| | |
| | | ); |
| | | |
| | | const handleCloseMaterialDialog = () => { |
| | | materialTableData.value = []; |
| | | currentMaterialRow.value = null; |
| | | currentMaterialOrderRow.value = null; |
| | | pickRows.value = []; |
| | | pickedRows.value = []; |
| | | currentWorkOrderRow.value = null; |
| | | }; |
| | | |
| | | const openSupplementDialog = row => { |
| | | currentMaterialRow.value = row; |
| | | supplementForm.supplementQty = null; |
| | | supplementForm.supplementReason = ""; |
| | | supplementDialogVisible.value = true; |
| | | nextTick(() => { |
| | | supplementFormRef.value?.clearValidate(); |
| | | }); |
| | | const openProductDialog = () => { |
| | | productDialogVisible.value = true; |
| | | }; |
| | | |
| | | const handleSubmitSupplement = () => { |
| | | supplementFormRef.value?.validate(async valid => { |
| | | if (!valid || !currentMaterialRow.value?.id) { |
| | | ElMessage.warning("缺少物料明细ID"); |
| | | return; |
| | | } |
| | | supplementSubmitting.value = true; |
| | | try { |
| | | await addWorkOrderMaterialSupplement({ |
| | | materialLedgerId: currentMaterialRow.value.id, |
| | | supplementQty: Number(supplementForm.supplementQty), |
| | | supplementReason: supplementForm.supplementReason, |
| | | workOrderId: currentMaterialOrderRow.value?.id, |
| | | }); |
| | | supplementDialogVisible.value = false; |
| | | await loadMaterialTable(currentMaterialOrderRow.value); |
| | | ElMessage.success("补料成功"); |
| | | emit("refresh"); |
| | | } catch (e) { |
| | | console.error("补料失败", e); |
| | | ElMessage.error("补料失败"); |
| | | } finally { |
| | | supplementSubmitting.value = false; |
| | | } |
| | | }); |
| | | }; |
| | | const handleProductConfirm = products => { |
| | | const product = Array.isArray(products) ? products[0] : null; |
| | | if (!product) return; |
| | | |
| | | const openSupplementRecordDialog = async row => { |
| | | supplementRecordDialogVisible.value = true; |
| | | supplementRecordLoading.value = true; |
| | | supplementRecordTableData.value = []; |
| | | try { |
| | | const res = await listWorkOrderMaterialSupplementRecord({ |
| | | materialLedgerId: row.id, |
| | | }); |
| | | supplementRecordTableData.value = res.data || []; |
| | | } catch (e) { |
| | | console.error("获取补料记录失败", e); |
| | | ElMessage.error("获取补料记录失败"); |
| | | } finally { |
| | | supplementRecordLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | const validatePickRows = () => { |
| | | if (materialTableData.value.length === 0) { |
| | | return { valid: false, message: "暂无可领用物料" }; |
| | | } |
| | | const invalidRow = materialTableData.value.find( |
| | | item => |
| | | item.actualQty === null || |
| | | item.actualQty === undefined || |
| | | item.actualQty === "" |
| | | const productModelId = product.id || product.productModelId || product.modelId; |
| | | const exists = pickRows.value.some( |
| | | item => String(item.productModelId || "") === String(productModelId || "") |
| | | ); |
| | | if (invalidRow) { |
| | | return { valid: false, message: "请填写实际数量后再领用" }; |
| | | if (exists) { |
| | | ElMessage.warning("该物料已在领料列表中"); |
| | | return; |
| | | } |
| | | const exceedRow = materialTableData.value.find(item => { |
| | | const maxQty = Number(item.pickQty || 0) + Number(item.supplementQty || 0); |
| | | return Number(item.actualQty || 0) > maxQty; |
| | | |
| | | pickRows.value.push({ |
| | | rowKey: createRowKey(), |
| | | bom: false, |
| | | productModelId, |
| | | productName: product.productName || product.materialName || "", |
| | | model: product.model || product.materialModel || "", |
| | | unit: product.unit || "", |
| | | operationName: |
| | | currentWorkOrderRow.value?.processName || |
| | | currentWorkOrderRow.value?.operationName || |
| | | "", |
| | | stockQuantity: product.stockQuantity ?? 0, |
| | | batchNoList: product.batchNoList || [], |
| | | batchNo: "", |
| | | pickQuantity: 0, |
| | | }); |
| | | if (exceedRow) { |
| | | return { valid: false, message: "实际数量不能大于领用数量+补料数量" }; |
| | | }; |
| | | |
| | | const removePickRow = index => { |
| | | pickRows.value.splice(index, 1); |
| | | }; |
| | | |
| | | const handleReturnMaterial = async row => { |
| | | const returnQty = Number(row.currentReturnQty || 0); |
| | | if (returnQty <= 0) { |
| | | ElMessage.warning("请输入本次退料数量"); |
| | | return; |
| | | } |
| | | return { valid: true, message: "" }; |
| | | if (returnQty > Number(row.effectiveQuantity || 0)) { |
| | | ElMessage.warning("退料数量不能超过净领料数量"); |
| | | return; |
| | | } |
| | | |
| | | await ElMessageBox.confirm( |
| | | `确认退回 ${row.productName || "该物料"} ${returnQty}${row.unit || ""}?`, |
| | | "退料确认", |
| | | { type: "warning" } |
| | | ); |
| | | |
| | | returningId.value = row.id; |
| | | try { |
| | | await returnWorkOrderMaterial({ |
| | | productionOrderId: row.productionOrderId, |
| | | pickList: [ |
| | | { |
| | | id: row.id, |
| | | productionOrderId: row.productionOrderId, |
| | | returned: true, |
| | | returnQty, |
| | | }, |
| | | ], |
| | | }); |
| | | ElMessage.success("退料成功"); |
| | | await loadMaterialTable(currentWorkOrderRow.value); |
| | | emit("refresh"); |
| | | } catch (e) { |
| | | if (e !== "cancel" && e !== "close") { |
| | | console.error("退料失败", e); |
| | | } |
| | | } finally { |
| | | returningId.value = null; |
| | | } |
| | | }; |
| | | |
| | | const handleSubmitPick = async () => { |
| | | if (!currentMaterialOrderRow.value?.id) return; |
| | | const validateResult = validatePickRows(); |
| | | if (!validateResult.valid) { |
| | | ElMessage.warning(validateResult.message); |
| | | if (!currentWorkOrderRow.value?.id) return; |
| | | const submitRows = pickRows.value.filter( |
| | | item => Number(item.pickQuantity || 0) > 0 |
| | | ); |
| | | if (submitRows.length === 0) { |
| | | ElMessage.warning("请至少填写一个物料的本次领料数量"); |
| | | return; |
| | | } |
| | | const missingBatchRow = submitRows.find( |
| | | item => (item.batchNoList || []).length > 0 && !item.batchNo |
| | | ); |
| | | if (missingBatchRow) { |
| | | ElMessage.warning("请选择领料批号"); |
| | | return; |
| | | } |
| | | |
| | | pickSubmitting.value = true; |
| | | try { |
| | | await pickWorkOrderMaterial({ |
| | | workOrderId: currentMaterialOrderRow.value.id, |
| | | items: materialTableData.value.map(item => ({ |
| | | materialLedgerId: item.id, |
| | | actualQty: Number(item.actualQty || 0), |
| | | productionOperationTaskId: currentWorkOrderRow.value.id, |
| | | pickList: submitRows.map(item => ({ |
| | | productModelId: item.productModelId, |
| | | batchNo: item.batchNo || undefined, |
| | | pickQuantity: Number(item.pickQuantity || 0), |
| | | })), |
| | | }); |
| | | ElMessage.success("领用成功"); |
| | | await loadMaterialTable(currentMaterialOrderRow.value); |
| | | await loadMaterialTable(currentWorkOrderRow.value); |
| | | emit("refresh"); |
| | | } catch (e) { |
| | | console.error("领用失败", e); |
| | | ElMessage.error("领用失败"); |
| | | } finally { |
| | | pickSubmitting.value = false; |
| | | } |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .material-toolbar { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 12px; |
| | | color: #606266; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .material-section-title { |
| | | margin: 16px 0 8px; |
| | | color: #303133; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | } |
| | | </style> |