feat(outbound): 出库流程强化(缺料防漏 + 完全移除直接出库)
① BOM 清单 SKU 改为规格型号(getBomWithStock 返回 children 无 sku,只有 child_spec) ② 缺货清单自动写入出库申请备注(借鸡生蛋:历史欠料永久留存在申请单) ③ 打印单底部增加欠料待补签收区(补料人签字/日期) ④ 有缺料时阻断式二次确认,防手快漏看 ⑤ 完全移除直接出库模式,所有出库必须按单出库 - create.vue 删除 radio 切换、direct 相关逻辑 - 提交前强制校验已选择审批申请单
This commit is contained in:
@ -223,7 +223,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
|
<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="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">
|
<el-table-column label="需求量" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span>{{ row.need }}</span>
|
<span>{{ row.need }}</span>
|
||||||
@ -273,7 +273,7 @@
|
|||||||
<table class="print-table">
|
<table class="print-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -293,6 +293,28 @@
|
|||||||
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b> 种
|
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b> 种
|
||||||
</p>
|
</p>
|
||||||
<p style="color:#909399; font-size:12px;">缺货物料不会加入本次出库单,请补货后重新按 BOM 出库。</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>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@ -592,7 +614,8 @@ const bomDetailList = computed(() => {
|
|||||||
const shortage = Math.max(0, totalNeed - stock)
|
const shortage = Math.max(0, totalNeed - stock)
|
||||||
return {
|
return {
|
||||||
name: bomItem.child_name || bomItem.name || '未知物料',
|
name: bomItem.child_name || bomItem.name || '未知物料',
|
||||||
sku: bomItem.child_sku || bomItem.sku || '-',
|
// ★ 用规格型号替代 SKU(getBomWithStock 返回的 children 无 sku,只有 child_spec)
|
||||||
|
sku: bomItem.child_spec || bomItem.spec_model || '-',
|
||||||
need: totalNeed,
|
need: totalNeed,
|
||||||
available: stock,
|
available: stock,
|
||||||
shortage
|
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
|
const bomRows = currentBomDetail.value
|
||||||
|
|
||||||
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
|
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
|
||||||
@ -891,7 +935,29 @@ const confirmBomAdd = async () => {
|
|||||||
|
|
||||||
// ★ 缺货记录持久化:按 BOM 编号保存到 localStorage
|
// ★ 缺货记录持久化:按 BOM 编号保存到 localStorage
|
||||||
const BOM_SHORTAGE_KEY = 'MOM_BOM_SHORTAGE_RECORDS'
|
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 saveBomShortage = (bomNo: string, shortageList: any[]) => {
|
||||||
const records = { ...bomShortageRecords.value }
|
const records = { ...bomShortageRecords.value }
|
||||||
@ -1030,7 +1096,7 @@ const confirmSubmitRequest = async () => {
|
|||||||
warehouse_location: item.warehouse_location || '',
|
warehouse_location: item.warehouse_location || '',
|
||||||
quantity: item.export_quantity || 0
|
quantity: item.export_quantity || 0
|
||||||
})),
|
})),
|
||||||
remark: trimmed,
|
remark: buildRemarkWithShortage(trimmed),
|
||||||
approver_id: requestApproverId.value
|
approver_id: requestApproverId.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,19 +15,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ★ 出库模式切换 -->
|
<!-- ★ 出库模式:仅支持按单出库(已移除直接出库) -->
|
||||||
<div class="mode-switch-bar">
|
<div class="mode-switch-bar">
|
||||||
<el-radio-group v-model="outboundMode" size="default" @change="handleModeChange">
|
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
|
||||||
<el-radio-button value="by-request">按单出库</el-radio-button>
|
<span class="mode-hint">请先选择已审批通过的申请单,再扫码出库</span>
|
||||||
<el-radio-button value="direct">直接出库</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
<span class="mode-hint">
|
|
||||||
{{ outboundMode === 'by-request' ? '需先选择已审批通过的申请单' : '无需审批单,自由扫码出库' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ★ 按单出库:审批单选择 -->
|
<!-- ★ 按单出库:审批单选择 -->
|
||||||
<div v-if="outboundMode === 'by-request'" class="approval-request-select">
|
<div class="approval-request-select">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedRequestId"
|
v-model="selectedRequestId"
|
||||||
placeholder="请选择已审批通过的出库申请单"
|
placeholder="请选择已审批通过的出库申请单"
|
||||||
@ -54,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ★ 按单出库:计划清单预览 -->
|
<!-- ★ 按单出库:计划清单预览 -->
|
||||||
<div v-if="outboundMode === 'by-request' && selectedRequest" class="planned-items-section">
|
<div v-if="selectedRequest" class="planned-items-section">
|
||||||
<div class="planned-header">
|
<div class="planned-header">
|
||||||
<span class="planned-title">计划出库清单</span>
|
<span class="planned-title">计划出库清单</span>
|
||||||
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
||||||
@ -79,7 +74,7 @@
|
|||||||
|
|
||||||
<div class="scan-section">
|
<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;">
|
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||||
<span class="text">请先选择审批单</span>
|
<span class="text">请先选择审批单</span>
|
||||||
@ -304,7 +299,7 @@ const barcodeRef = ref()
|
|||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
// ★ 双轨制模式
|
// ★ 双轨制模式
|
||||||
const outboundMode = ref<'by-request' | 'direct'>('by-request') // 'by-request' | 'direct'
|
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
|
||||||
const approvalRequests = ref<any[]>([])
|
const approvalRequests = ref<any[]>([])
|
||||||
const selectedRequest = ref<any>(null)
|
const selectedRequest = ref<any>(null)
|
||||||
const requestsLoading = ref(false)
|
const requestsLoading = ref(false)
|
||||||
@ -355,18 +350,6 @@ const selectedRequestId = computed({
|
|||||||
|
|
||||||
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
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 () => {
|
const loadApprovalRequests = async () => {
|
||||||
requestsLoading.value = true
|
requestsLoading.value = true
|
||||||
@ -489,7 +472,7 @@ const handleManualInput = async () => {
|
|||||||
if (!code) return
|
if (!code) return
|
||||||
|
|
||||||
// ★ 按单出库模式:必须先选择申请单
|
// ★ 按单出库模式:必须先选择申请单
|
||||||
if (outboundMode.value === 'by-request' && !selectedRequest.value) {
|
if (!selectedRequest.value) {
|
||||||
ElMessage.warning('请先选择要出库的审批申请单')
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -503,7 +486,6 @@ const handleManualInput = async () => {
|
|||||||
const item = cartItems.value[existIndex]
|
const item = cartItems.value[existIndex]
|
||||||
|
|
||||||
// ★ 按单模式:追加时仍需校验计划数量
|
// ★ 按单模式:追加时仍需校验计划数量
|
||||||
if (outboundMode.value === 'by-request') {
|
|
||||||
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
||||||
if (err) {
|
if (err) {
|
||||||
ElMessage.error(err)
|
ElMessage.error(err)
|
||||||
@ -511,7 +493,6 @@ const handleManualInput = async () => {
|
|||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const maxQty = parseFloat(item.available_quantity)
|
const maxQty = parseFloat(item.available_quantity)
|
||||||
if (item.out_quantity < maxQty) {
|
if (item.out_quantity < maxQty) {
|
||||||
@ -540,7 +521,6 @@ const handleManualInput = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ★ 按单模式:扫码加入前校验是否在计划清单内
|
// ★ 按单模式:扫码加入前校验是否在计划清单内
|
||||||
if (outboundMode.value === 'by-request') {
|
|
||||||
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
const err = validateAgainstPlan(item.name, item.spec_model, 1)
|
||||||
if (err) {
|
if (err) {
|
||||||
ElMessage.error(err)
|
ElMessage.error(err)
|
||||||
@ -548,7 +528,6 @@ const handleManualInput = async () => {
|
|||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 加入购物车
|
// 加入购物车
|
||||||
cartItems.value.push({
|
cartItems.value.push({
|
||||||
@ -610,6 +589,11 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
||||||
|
// ★ 按单出库:提交前必须已选择审批申请单
|
||||||
|
if (!selectedRequest.value) {
|
||||||
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
@ -653,7 +637,7 @@ const submitForm = async () => {
|
|||||||
// 3. 组装发给后端的包
|
// 3. 组装发给后端的包
|
||||||
const submitPayload: any = {
|
const submitPayload: any = {
|
||||||
outbound_type: form.outbound_type,
|
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,
|
consumer_name: form.consumer_name,
|
||||||
operator_name: form.operator_name,
|
operator_name: form.operator_name,
|
||||||
remark: form.remark,
|
remark: form.remark,
|
||||||
|
|||||||
Reference in New Issue
Block a user