feat(outbound): 出库流程强化(缺料防漏 + 完全移除直接出库)

① BOM 清单 SKU 改为规格型号(getBomWithStock 返回 children 无 sku,只有 child_spec)
② 缺货清单自动写入出库申请备注(借鸡生蛋:历史欠料永久留存在申请单)
③ 打印单底部增加欠料待补签收区(补料人签字/日期)
④ 有缺料时阻断式二次确认,防手快漏看
⑤ 完全移除直接出库模式,所有出库必须按单出库
   - create.vue 删除 radio 切换、direct 相关逻辑
   - 提交前强制校验已选择审批申请单
This commit is contained in:
yueli
2026-08-31 09:37:14 +08:00
parent a4bd6f54d4
commit b6ec45a8b8
2 changed files with 97 additions and 47 deletions

View File

@ -223,7 +223,7 @@
</template>
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
<el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column prop="sku" label="SKU" width="100" />
<el-table-column prop="sku" label="规格型号" width="120" />
<el-table-column label="需求量" width="80">
<template #default="{ row }">
<span>{{ row.need }}</span>
@ -273,7 +273,7 @@
<table class="print-table">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>SKU</th><th>需求量</th><th>可用库存</th><th>缺料</th><th>结果</th>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需求量</th><th>可用库存</th><th>缺料</th><th>结果</th>
</tr>
</thead>
<tbody>
@ -293,6 +293,28 @@
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b>
</p>
<p style="color:#909399; font-size:12px;">缺货物料不会加入本次出库单请补货后重新按 BOM 出库</p>
<!-- 欠料待补签收区仅当有缺货时打印此联 -->
<div v-if="bomShortageCount > 0" style="margin-top: 20px; border-top: 2px dashed #000; padding-top: 10px;">
<p style="font-weight: bold; font-size: 14px;"> 欠料待补清单请补料后在此签字</p>
<table class="print-table" style="margin-top: 6px;">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需补数量</th><th>补领人签字</th><th>日期</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.shortage }}</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
<el-dialog
@ -592,7 +614,8 @@ const bomDetailList = computed(() => {
const shortage = Math.max(0, totalNeed - stock)
return {
name: bomItem.child_name || bomItem.name || '未知物料',
sku: bomItem.child_sku || bomItem.sku || '-',
// ★ 用规格型号替代 SKUgetBomWithStock 返回的 children 无 sku只有 child_spec
sku: bomItem.child_spec || bomItem.spec_model || '-',
need: totalNeed,
available: stock,
shortage
@ -834,6 +857,27 @@ const confirmBomAdd = async () => {
}
}
// ★ 阻断式二次确认:有缺料时强制确认,防止手快漏看
const shortageItems = bomDetailList.value.filter(i => i.shortage > 0)
if (shortageItems.length > 0) {
const shortageText = shortageItems.map(i => `${i.name}${i.sku})需 ${i.need} 个,缺 ${i.shortage}`).join('\n')
try {
await ElMessageBox.confirm(
`当前 BOM 有 ${shortageItems.length} 项物料库存不足:\n\n${shortageText}\n\n` +
`继续出库将【只扣减可用库存】,缺货物料不会加入本次出库单。\n是否确认【部分出库】`,
'⚠️ 存在缺料,请确认',
{
confirmButtonText: '确认部分出库',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}
)
} catch (e) {
return // 用户取消
}
}
const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
@ -891,7 +935,29 @@ const confirmBomAdd = async () => {
// ★ 缺货记录持久化:按 BOM 编号保存到 localStorage
const BOM_SHORTAGE_KEY = 'MOM_BOM_SHORTAGE_RECORDS'
const bomShortageRecords = ref<Record<string, any[]>>(JSON.parse(localStorage.getItem(BOM_SHORTAGE_KEY) || '{}'))
const bomShortageRecords = ref<Record<string, any>>(JSON.parse(localStorage.getItem(BOM_SHORTAGE_KEY) || '{}'))
// ★ 将缺货清单拼接进备注(借鸡生蛋:历史缺货记录永久留存在出库申请单备注)
const buildRemarkWithShortage = (baseRemark: string): string => {
const parts: string[] = []
if (baseRemark) parts.push(baseRemark)
const shortageBoms = Object.keys(bomShortageRecords.value)
if (shortageBoms.length > 0) {
const detail: string[] = []
for (const no of shortageBoms) {
const rec = bomShortageRecords.value[no]
if (!rec?.items?.length) continue
const items = rec.items.map((i: any) => `${i.name}(${i.shortage}个)`).join('、')
detail.push(`${no}: ${items}`)
}
if (detail.length > 0) {
parts.push(`[BOM欠料待补] ${detail.join('')}`)
}
}
return parts.join(' | ')
}
const saveBomShortage = (bomNo: string, shortageList: any[]) => {
const records = { ...bomShortageRecords.value }
@ -1030,7 +1096,7 @@ const confirmSubmitRequest = async () => {
warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0
})),
remark: trimmed,
remark: buildRemarkWithShortage(trimmed),
approver_id: requestApproverId.value
}

View File

@ -15,19 +15,14 @@
</div>
</template>
<!-- 出库模式切换 -->
<!-- 出库模式仅支持按单出库已移除直接出库 -->
<div class="mode-switch-bar">
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
<el-radio-button value="by-request">按单出库</el-radio-button>
<el-radio-button value="direct">直接出库</el-radio-button>
</el-radio-group>
<span class="mode-hint">
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
</span>
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
<span class="mode-hint">请先选择已审批通过的申请单再扫码出库</span>
</div>
<!-- 按单出库审批单选择 -->
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
<div class="approval-request-select">
<el-select
v-model="selectedRequestId"
placeholder="请选择已审批通过的出库申请单"
@ -54,7 +49,7 @@
</div>
<!-- 按单出库计划清单预览 -->
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
<div v-if="selectedRequest" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划出库清单</span>
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} </el-tag>
@ -79,7 +74,7 @@
<div class="scan-section">
<template v-if="outboundMode === 'by-request' && !selectedRequestId">
<template v-if="!selectedRequestId">
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">请先选择审批单</span>
@ -304,7 +299,7 @@ const barcodeRef = ref()
const formRef = ref()
// ★ 双轨制模式
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
const approvalRequests = ref<any[]>([])
const selectedRequest = ref<any>(null)
const requestsLoading = ref(false)
@ -355,18 +350,6 @@ const selectedRequestId = computed({
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
// ★ 模式切换
const handleModeChange = () => {
selectedRequest.value = null
selectedRequestId.value = null
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 加载已审批通过的申请单
const loadApprovalRequests = async () => {
requestsLoading.value = true
@ -489,7 +472,7 @@ const handleManualInput = async () => {
if (!code) return
// ★ 按单出库模式:必须先选择申请单
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
@ -503,14 +486,12 @@ const handleManualInput = async () => {
const item = cartItems.value[existIndex]
// ★ 按单模式:追加时仍需校验计划数量
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity)
@ -540,14 +521,12 @@ const handleManualInput = async () => {
}
// ★ 按单模式:扫码加入前校验是否在计划清单内
if (outboundMode.value === 'by-request') {
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
// 加入购物车
@ -610,6 +589,11 @@ const submitForm = async () => {
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
// ★ 按单出库:提交前必须已选择审批申请单
if (!selectedRequest.value) {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
@ -653,7 +637,7 @@ const submitForm = async () => {
// 3. 组装发给后端的包
const submitPayload: any = {
outbound_type: form.outbound_type,
request_id: outboundMode.value === 'by-request' && selectedRequest.value ? selectedRequest.value.id : null,
request_id: selectedRequest.value ? selectedRequest.value.id : null,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,