<template>
|
<div>
|
<el-dialog v-model="dialogVisible"
|
title="领料台账"
|
width="1450px"
|
@close="handleClose">
|
<div class="material-toolbar">
|
<el-button type="primary"
|
@click="handleAddMaterialRow">新增</el-button>
|
</div>
|
<el-table v-loading="materialTableLoading"
|
:data="materialTableData"
|
border
|
row-key="tempId">
|
<el-table-column label="工序名称"
|
min-width="100">
|
<template #default="{ row }">
|
<span v-if="row.bom === true">{{ row.operationName || "-" }}</span>
|
<el-select v-else
|
v-model="row.operationName"
|
placeholder="请选择工序"
|
clearable
|
filterable
|
style="width: 100%;"
|
@change="val => handleProcessNameChange(row, val)">
|
<el-option v-for="item in processOptions"
|
:key="item.technologyOperationId"
|
:label="item.name"
|
:value="item.name" />
|
</el-select>
|
</template>
|
</el-table-column>
|
<el-table-column label="原料名称"
|
min-width="120">
|
<template #default="{ row }">
|
<span v-if="row.bom === true">{{ row.materialName || "-" }}</span>
|
<el-button v-else
|
type="primary"
|
link
|
@click="openMaterialProductSelect(row)">
|
{{ row.materialName || "选择原料" }}
|
</el-button>
|
</template>
|
</el-table-column>
|
<el-table-column label="原料型号"
|
min-width="110">
|
<template #default="{ row }">
|
{{ row.materialModel || "-" }}
|
</template>
|
</el-table-column>
|
<!-- 批号多选 -->
|
<el-table-column min-width="180"
|
label="批号">
|
<template #default="{ row }">
|
<el-select v-model="row.batchNo"
|
multiple
|
collapse-tags
|
collapse-tags-indicator
|
placeholder="请选择批号"
|
style="width: 100%;">
|
<el-option v-for="item in row.batchNoList"
|
:key="item"
|
:label="item"
|
:value="item" />
|
</el-select>
|
</template>
|
</el-table-column>
|
<el-table-column label="需求数量"
|
min-width="95">
|
<template #default="{ row }">
|
<span v-if="row.bom === true">{{ stripTrailingZeros(row.demandedQuantity) ?? "-" }}</span>
|
<el-input-number v-else
|
v-model="row.demandedQuantity"
|
:min="0"
|
:step="1"
|
controls-position="right"
|
:formatter="value => stripTrailingZeros(value)"
|
:parser="value => parseFloat(value) || 0"
|
style="width: 100%;"
|
@change="val => handleRequiredQtyChange(row, val)" />
|
</template>
|
</el-table-column>
|
<el-table-column label="库存余量"
|
min-width="90">
|
<template #default="{ row }">
|
<span v-if="row.stockQty === null">-</span>
|
<span v-else>{{ stripTrailingZeros(row.stockQty) }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="已领数量"
|
min-width="90">
|
<template #default="{ row }">
|
{{ stripTrailingZeros(row.pickedQty) ?? 0 }}
|
</template>
|
</el-table-column>
|
<el-table-column label="剩余可领"
|
min-width="90">
|
<template #default="{ row }">
|
<span :class="{ 'remaining-empty': Number(row.remainingQty) <= 0 }">
|
{{ stripTrailingZeros(row.remainingQty) ?? 0 }}
|
</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="计量单位"
|
width="80">
|
<template #default="{ row }">
|
{{ row.unit || "-" }}
|
</template>
|
</el-table-column>
|
<el-table-column label="领用数量"
|
min-width="120">
|
<template #default="{ row }">
|
<el-input-number v-model="row.pickQty"
|
:min="0"
|
:step="1"
|
controls-position="right"
|
:formatter="value => stripTrailingZeros(value)"
|
:parser="value => parseFloat(value) || 0"
|
style="width: 100%;" />
|
</template>
|
</el-table-column>
|
<el-table-column label="操作"
|
width="90"
|
fixed="right">
|
<template #default="{ $index, row }">
|
<el-button v-if="row.bom !== true || Number(row.pickQty) > 0"
|
type="danger"
|
link
|
@click="handleDeleteMaterialRow($index)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
<template #footer>
|
<span class="dialog-footer">
|
<el-button type="primary"
|
:loading="materialSaving"
|
:disabled="isSaveDisabled"
|
@click="handleMaterialSave">保存</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
</span>
|
</template>
|
</el-dialog>
|
<ProductSelectDialog v-model="materialProductDialogVisible"
|
@confirm="handleMaterialProductConfirm"
|
single />
|
<!-- request-url="/stockInventory/rawMaterials" -->
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, ref, watch } from "vue";
|
import { ElMessage } from "element-plus";
|
import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
|
import { findProductProcessRouteItemList } from "@/api/productionManagement/productProcessRoute.js";
|
import { getStockInventoryByModelId } from "@/api/inventoryManagement/stockInventory.js";
|
import {
|
listMaterialPickingDetail,
|
listMaterialPickingBom,
|
saveMaterialPickingLedger,
|
} from "@/api/productionManagement/productionOrder.js";
|
|
const props = defineProps({
|
modelValue: { type: Boolean, default: false },
|
orderRow: { type: Object, default: null },
|
});
|
const emit = defineEmits(["update:modelValue", "saved"]);
|
|
const dialogVisible = computed({
|
get: () => props.modelValue,
|
set: val => emit("update:modelValue", val),
|
});
|
|
const materialProductDialogVisible = ref(false);
|
const materialTableLoading = ref(false);
|
const materialSaving = ref(false);
|
const materialTableData = ref([]);
|
|
const isSaveDisabled = computed(() => {
|
if (materialTableLoading.value) return true;
|
if (materialTableData.value.length === 0) return true;
|
// 只有存在本次领用数量大于0的行才允许保存
|
return !materialTableData.value.some(item => Number(item.pickQty) > 0);
|
});
|
|
const processOptions = ref([]);
|
const currentMaterialSelectRowIndex = ref(-1);
|
let materialTempId = 0;
|
|
const toNumber = val => {
|
const n = Number(val);
|
return Number.isFinite(n) ? n : 0;
|
};
|
|
// 保留6位小数,避免浮点误差
|
const roundQuantity = val => Math.round(toNumber(val) * 1e6) / 1e6;
|
|
// 需求数量分组键:与后端保持一致(工序ID优先,缺失时用工序名称)
|
const buildDemandKey = (technologyOperationId, operationName, productModelId) =>
|
`${technologyOperationId != null && technologyOperationId !== undefined ? "T" + technologyOperationId : "N" + (operationName || "")}#${productModelId ?? ""}`;
|
|
const stripTrailingZeros = val => {
|
if (val === null || val === undefined || val === "") return "0";
|
const str = String(val);
|
if (str.includes(".")) {
|
return parseFloat(str).toString();
|
}
|
return str;
|
};
|
|
const createMaterialRow = (row = {}) => {
|
const batchNoList = Array.isArray(row.batchNoList) ? row.batchNoList : [];
|
const pickedEntry = row.pickedEntry || null;
|
const pickedQty = pickedEntry ? roundQuantity(pickedEntry.pickedQty) : 0;
|
const returnedQty = pickedEntry ? roundQuantity(pickedEntry.returnedQty) : 0;
|
const demandedQuantity = roundQuantity(row.demandedQuantity ?? 0);
|
const remainingQty = Math.max(roundQuantity(demandedQuantity - pickedQty + returnedQty), 0);
|
|
return {
|
tempId: `temp_${++materialTempId}`,
|
id: null,
|
bom: row.bom === true,
|
technologyOperationId: row.technologyOperationId ?? null,
|
operationName: row.operationName || "",
|
materialModelId: row.materialModelId ?? null,
|
materialName: row.materialName || "",
|
materialModel: row.materialModel || "",
|
unit: row.unit || "",
|
demandedQuantity,
|
pickedQty,
|
returnedQty,
|
remainingQty,
|
pickQty: 0,
|
stockQty: null,
|
batchNo: [],
|
batchNoList,
|
};
|
};
|
|
const getProcessOptions = async () => {
|
if (!props.orderRow?.id) return;
|
const res = await findProductProcessRouteItemList({
|
orderId: props.orderRow.id,
|
});
|
const routeList = Array.isArray(res?.data)
|
? res.data
|
: res?.data?.records || [];
|
const processMap = new Map();
|
routeList.forEach(item => {
|
const processId = item.technologyOperationId;
|
const operationName = item.operationName;
|
if (!processId || !operationName) return;
|
const key = `${processId}_${operationName}`;
|
if (!processMap.has(key)) {
|
processMap.set(key, {
|
id: processId,
|
technologyOperationId: processId,
|
name: operationName,
|
});
|
}
|
});
|
processOptions.value = Array.from(processMap.values());
|
};
|
|
const loadMaterialData = async () => {
|
if (!props.orderRow?.id) return;
|
materialTableLoading.value = true;
|
materialTableData.value = [];
|
try {
|
await getProcessOptions();
|
// 同时获取BOM需求数据与已领料明细,用于计算“已领数量/剩余可领”
|
const [bomRes, detailRes] = await Promise.all([
|
listMaterialPickingBom(props.orderRow.id),
|
listMaterialPickingDetail(props.orderRow.id),
|
]);
|
const bomList = Array.isArray(bomRes?.data)
|
? bomRes.data
|
: bomRes?.data?.records || [];
|
const detailList = Array.isArray(detailRes?.data)
|
? detailRes.data
|
: detailRes?.data?.records || [];
|
|
// 按“工序+原料”汇总历史领料:已领数量、退料数量
|
const pickedMap = new Map();
|
detailList.forEach(item => {
|
if (!item) return;
|
const key = buildDemandKey(
|
item.technologyOperationId,
|
item.operationName,
|
item.productModelId
|
);
|
const entry = pickedMap.get(key) || {
|
pickedQty: 0,
|
returnedQty: 0,
|
manualDemand: 0,
|
technologyOperationId: item.technologyOperationId,
|
operationName: item.operationName || "",
|
materialModelId: item.productModelId,
|
materialName: item.productName || "",
|
materialModel: item.model || "",
|
unit: item.unit || "",
|
batchNoList: Array.isArray(item.batchNoList)
|
? item.batchNoList
|
: [],
|
};
|
entry.pickedQty = roundQuantity(entry.pickedQty + toNumber(item.pickQuantity));
|
entry.returnedQty = roundQuantity(entry.returnedQty + toNumber(item.returnQty));
|
// 手工新增行(非BOM)的需求数量以领料快照为准
|
if (item.bom !== true && item.demandedQuantity != null) {
|
entry.manualDemand = Math.max(
|
toNumber(entry.manualDemand),
|
toNumber(item.demandedQuantity)
|
);
|
}
|
pickedMap.set(key, entry);
|
});
|
|
const rows = [];
|
const bomKeySet = new Set();
|
// BOM行:需求数量取BOM,已领数量取历史领料汇总
|
bomList.forEach(item => {
|
const materialModelId = item.productModelId ?? item.materialModelId;
|
const key = buildDemandKey(
|
item.technologyOperationId,
|
item.operationName,
|
materialModelId
|
);
|
bomKeySet.add(key);
|
rows.push(
|
createMaterialRow({
|
bom: true,
|
technologyOperationId: item.technologyOperationId,
|
operationName: item.operationName || "",
|
materialModelId,
|
materialName: item.productName || item.materialName || "",
|
materialModel: item.model || item.materialModel || "",
|
unit: item.unit || "",
|
demandedQuantity: item.demandedQuantity ?? item.requiredQty ?? 0,
|
pickedEntry: pickedMap.get(key),
|
batchNoList: item.batchNoList || [],
|
})
|
);
|
});
|
// 不在BOM中的历史领料(手工新增行),也展示出来支持继续领料
|
pickedMap.forEach((entry, key) => {
|
if (bomKeySet.has(key)) return;
|
rows.push(
|
createMaterialRow({
|
bom: false,
|
technologyOperationId: entry.technologyOperationId,
|
operationName: entry.operationName,
|
materialModelId: entry.materialModelId,
|
materialName: entry.materialName,
|
materialModel: entry.materialModel,
|
unit: entry.unit,
|
demandedQuantity: entry.manualDemand,
|
pickedEntry: entry,
|
batchNoList: entry.batchNoList,
|
})
|
);
|
});
|
// 先查出库存余量再入表,保证首次打开即展示(rows 尚未转为响应式代理,
|
// 入表后再改原始对象不会触发视图更新)
|
await loadStockQtyForRows(rows);
|
materialTableData.value = rows;
|
} finally {
|
materialTableLoading.value = false;
|
}
|
};
|
|
watch(
|
() => dialogVisible.value,
|
visible => {
|
if (visible) {
|
loadMaterialData();
|
}
|
}
|
);
|
|
const handleClose = () => {
|
materialTableData.value = [];
|
currentMaterialSelectRowIndex.value = -1;
|
};
|
|
// 按型号汇总所有批次的原料可用库存余量
|
const loadStockQtyForRows = async rows => {
|
const modelIds = [
|
...new Set(rows.map(row => row.materialModelId).filter(Boolean)),
|
];
|
const stockMap = new Map();
|
await Promise.all(
|
modelIds.map(async modelId => {
|
try {
|
const res = await getStockInventoryByModelId(modelId);
|
const list = Array.isArray(res?.data)
|
? res.data
|
: res?.data?.records || res?.data?.rows || [];
|
const total = list.reduce(
|
(sum, item) => sum + toNumber(item?.qualitity ?? item?.quantity),
|
0
|
);
|
stockMap.set(modelId, roundQuantity(total));
|
} catch (e) {
|
stockMap.set(modelId, 0);
|
}
|
})
|
);
|
rows.forEach(row => {
|
if (!row.materialModelId) {
|
row.stockQty = null;
|
return;
|
}
|
row.stockQty = stockMap.has(row.materialModelId)
|
? stockMap.get(row.materialModelId)
|
: 0;
|
});
|
};
|
|
const handleAddMaterialRow = () => {
|
materialTableData.value.push(createMaterialRow());
|
};
|
|
const handleDeleteMaterialRow = index => {
|
materialTableData.value.splice(index, 1);
|
};
|
|
const handleProcessNameChange = (row, operationName) => {
|
const process = processOptions.value.find(
|
item => item.name === operationName
|
);
|
row.technologyOperationId = process?.technologyOperationId;
|
};
|
|
const handleRequiredQtyChange = (row, val) => {
|
const required = roundQuantity(val);
|
row.demandedQuantity = required;
|
row.remainingQty = Math.max(
|
roundQuantity(required - toNumber(row.pickedQty) + toNumber(row.returnedQty)),
|
0
|
);
|
};
|
|
const openMaterialProductSelect = row => {
|
currentMaterialSelectRowIndex.value = materialTableData.value.findIndex(
|
item => item.tempId === row.tempId
|
);
|
materialProductDialogVisible.value = true;
|
};
|
|
const handleMaterialProductConfirm = products => {
|
if (!products || products.length === 0) return;
|
const index = currentMaterialSelectRowIndex.value;
|
if (index < 0 || !materialTableData.value[index]) return;
|
const product = products[0];
|
const row = materialTableData.value[index];
|
row.materialModelId =
|
product.materialModelId || product.modelId || product.id;
|
row.materialName =
|
product.materialName || product.productName || product.name || "";
|
row.materialModel = product.materialModel || product.model || "";
|
row.unit = product.unit || product.measureUnit || "";
|
row.batchNoList = Array.isArray(product.batchNoList) ? product.batchNoList : [];
|
row.batchNo = [];
|
currentMaterialSelectRowIndex.value = -1;
|
materialProductDialogVisible.value = false;
|
loadStockQtyForRows([row]);
|
};
|
|
const validateMaterialRows = () => {
|
if (materialTableData.value.length === 0) {
|
return { valid: false, message: "请先新增领料数据" };
|
}
|
const pickRows = materialTableData.value.filter(
|
item => toNumber(item.pickQty) > 0
|
);
|
if (pickRows.length === 0) {
|
return { valid: false, message: "请至少填写一行大于0的领用数量" };
|
}
|
for (let i = 0; i < materialTableData.value.length; i++) {
|
const item = materialTableData.value[i];
|
const rowNo = i + 1;
|
if (toNumber(item.pickQty) <= 0) continue;
|
if (item.bom !== true && (!item.operationName || !item.materialName)) {
|
return { valid: false, message: `第${rowNo}行的工序名称和原料名称为必填项` };
|
}
|
if (!Array.isArray(item.batchNo) || item.batchNo.length === 0) {
|
return { valid: false, message: `第${rowNo}行请选择批号` };
|
}
|
}
|
return { valid: true, message: "" };
|
};
|
|
const handleMaterialSave = async () => {
|
if (!props.orderRow?.id) return;
|
const validateResult = validateMaterialRows();
|
if (!validateResult.valid) {
|
ElMessage.warning(validateResult.message);
|
return;
|
}
|
materialSaving.value = true;
|
try {
|
// 每次保存都是一次独立的领料:只提交本次领用数量大于0的行,
|
// 出库台账按本次领料数量记账,历史领料记录保持不变。
|
const pickRows = materialTableData.value.filter(
|
item => toNumber(item.pickQty) > 0
|
);
|
await saveMaterialPickingLedger({
|
productionOrderId: props.orderRow.id,
|
productionOrderPickDto: pickRows.map(item => ({
|
technologyOperationId: item.technologyOperationId,
|
operationName: item.operationName,
|
bom: item.bom === true,
|
productModelId: item.materialModelId,
|
demandedQuantity: item.demandedQuantity,
|
unit: item.unit,
|
pickQuantity: toNumber(item.pickQty),
|
batchNo: Array.isArray(item.batchNo)
|
? item.batchNo.join(",")
|
: item.batchNo,
|
})),
|
});
|
|
ElMessage({ message: "领料成功", type: "success" });
|
emit("saved");
|
dialogVisible.value = false;
|
} finally {
|
materialSaving.value = false;
|
}
|
};
|
</script>
|
|
<style scoped lang="scss">
|
.material-toolbar {
|
margin-bottom: 12px;
|
text-align: right;
|
}
|
|
.remaining-empty {
|
color: #f56c6c;
|
font-weight: 600;
|
}
|
</style>
|