feat(outbound): BOM 出库增加清单打印与缺货持久化提醒

- BOM 弹窗物料清单增加「本次结果」列(可出/缺货未出)
- 增加「打印 BOM 清单」按钮,可打印当前 BOM 的完整出库清单
- 缺货物料持久化到 localStorage,下次打开同一 BOM 时自动提示历史缺货记录
  防止重复出库或漏出
- 打印区域使用与 A4 出库单一致的 iframe 打印机制
This commit is contained in:
yueli
2026-08-31 09:26:53 +08:00
parent fe52da77e9
commit a4bd6f54d4

View File

@ -239,14 +239,21 @@
<span :style="{ color: row.shortage > 0 ? '#F56C6C' : '#67C23A', fontWeight: 'bold' }">{{ row.shortage > 0 ? row.shortage : '✓' }}</span>
</template>
</el-table-column>
<el-table-column label="本次结果" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.shortage > 0" type="danger" size="small">缺货未出</el-tag>
<el-tag v-else type="success" size="small">可出</el-tag>
</template>
</el-table-column>
</el-table>
</el-alert>
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )
注意系统将自动计算所需原料数量 ( 配方用量 × 套数 )缺货物料不会加入出库单请先补货
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button v-if="bomDetailList.length > 0" :icon="Printer" @click="printBomChecklist">打印 BOM 清单</el-button>
<el-button
v-if="userStore.hasPermission('outbound_selection:operation')"
type="primary"
@ -257,6 +264,37 @@
</template>
</el-dialog>
<!-- BOM 打印区域隐藏打印时克隆到 iframe -->
<div id="bom-print-area" class="print-bom-area" style="display:none;">
<h2 style="text-align:center; margin:0 0 8px;">BOM 出库清单</h2>
<p style="text-align:center; margin:0 0 16px; font-size:13px;">
BOM: <b>{{ selectedBomNo }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p>
<table class="print-table">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>SKU</th><th>需求量</th><th>可用库存</th><th>缺料</th><th>结果</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.need }}</td>
<td>{{ row.available }}</td>
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</td>
<td>{{ row.shortage > 0 ? '缺货未出' : '可出' }}</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;">
可出 <b style="color:#67C23A;">{{ bomAvailableCount }}</b>
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b>
</p>
<p style="color:#909399; font-size:12px;">缺货物料不会加入本次出库单请补货后重新按 BOM 出库</p>
</div>
<el-dialog
v-model="previewVisible"
title="出库单核对与打印"
@ -569,6 +607,11 @@ const bomDetailList = computed(() => {
const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.shortage > 0) && bomSets.value > maxBuildableSets.value)
// ★ BOM 打印统计
const bomPrintTime = ref('')
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// --- 辅助方法 ---
const getTypeTag = (type: string) => {
switch (type) {
@ -741,6 +784,23 @@ const openBomSelect = async () => {
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
// ★ 提示有历史缺货记录的 BOM防止下次重复出/漏出
const pendingBoms = Object.keys(bomShortageRecords.value).filter(no =>
flatItems.some(i => i.bom_no === no)
)
if (pendingBoms.length > 0) {
ElMessageBox.confirm(
`检测到以下 BOM 存在未完成的缺货记录(上次按 BOM 出库时缺货):\n\n` +
pendingBoms.map(no => {
const rec = bomShortageRecords.value[no]
return `${no}${rec.items.length} 种缺货,${rec.time}`
}).join('\n') +
`\n\n建议先补货后再出库避免漏出。是否查看`,
'存在未完成出库',
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
).catch(() => {})
}
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}
@ -818,12 +878,36 @@ const confirmBomAdd = async () => {
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
// ★ 记录本次缺货清单(持久化,防止下次重复出/漏出)
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
// 全部缺货也要记录
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
}
}
// ★ 缺货记录持久化:按 BOM 编号保存到 localStorage
const BOM_SHORTAGE_KEY = 'MOM_BOM_SHORTAGE_RECORDS'
const bomShortageRecords = ref<Record<string, any[]>>(JSON.parse(localStorage.getItem(BOM_SHORTAGE_KEY) || '{}'))
const saveBomShortage = (bomNo: string, shortageList: any[]) => {
const records = { ...bomShortageRecords.value }
if (shortageList.length > 0) {
records[bomNo] = {
time: new Date().toLocaleString('zh-CN'),
items: shortageList.map(i => ({ name: i.name, sku: i.sku, need: i.need, available: i.available, shortage: i.shortage }))
}
} else {
// 本次没有缺货 → 清除该 BOM 的历史缺货记录
delete records[bomNo]
}
bomShortageRecords.value = records
localStorage.setItem(BOM_SHORTAGE_KEY, JSON.stringify(records))
}
// --- 通用逻辑 ---
const handleSelectionChange = (val: any[]) => {
@ -963,9 +1047,54 @@ const confirmSubmitRequest = async () => {
}
}
// ★ BOM 清单打印(复用与 confirmPrint 相同的 iframe 打印机制)
const printBomChecklist = () => {
if (bomDetailList.value.length === 0) return ElMessage.warning('请先选择 BOM 并加载清单')
bomPrintTime.value = new Date().toLocaleString('zh-CN')
setTimeout(() => {
const printElement = document.getElementById('bom-print-area')
if (!printElement) return
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
document.body.appendChild(iframe)
const iframeDoc = iframe.contentWindow?.document
if (!iframeDoc) return
iframeDoc.open()
iframeDoc.write('<html><head><title>BOM 出库清单</title></head><body></body></html>')
iframeDoc.close()
const style = `
body { font-family: 'Microsoft YaHei', sans-serif; }
.print-bom-area { display: block !important; padding: 20px; }
.print-table { width: 100%; border-collapse: collapse; }
.print-table th, .print-table td { border: 1px solid #000; padding: 6px 8px; font-size: 13px; text-align: center; }
.print-table th { background: #f0f0f0; }
`;
const styleEl = iframeDoc.createElement('style')
styleEl.textContent = style
iframeDoc.head.appendChild(styleEl)
iframeDoc.body.appendChild(printElement.cloneNode(true))
setTimeout(() => {
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
setTimeout(() => document.body.removeChild(iframe), 1000)
}, 500)
}, 300)
}
const confirmPrint = async () => {
previewVisible.value = false;
try {
const payload = validSelectedItems.value.map(item => ({
name: item.name, standard: item.standard, quantity: item.export_quantity