fix(scrap): 报废申请页搜索/选择解耦,审批人常驻必填

一、搜索丢失已选(核心 bug)
主表格 :data="stockList" 同时充当搜索结果与选择容器。Element Plus 的
setData 在 reserveSelection 为假时走 clearSelection(),进而 emit
selection-change([]),故每次重新搜索都会清空选中态、「已选 N 条」归零。
(源码路径:table/src/store/index.mjs:34-52 → store/watcher.mjs:145-152)

改为「购物车 + 选择弹窗」结构,对齐出库选单页:
  · 主表格数据源改为 cart(已选清单),删除复选列,改「移除」按钮;
  · 新增「选择库存物品」弹窗,搜索框移入其中;
  · 表格 row-key + :reserve-selection="true",跨搜索/翻页保留勾选;
  · 点行勾选、改数量自动勾选、数字框 @click.stop 防冒泡反转勾选;
  · 服务端搜索(350ms 防抖)+ 分页 20/50/100/200;
  · uniqueKey 取 source_table_id(三表主键各自独立,必须带表名前缀)。

二、审批人字段不显示
原为 v-if="approvalVisible",仅库管代建或命中需审批物料时才渲染。
因全库仅 1 个物料标记 is_approval_required,该条件几乎从不成立。
现改为常驻,并用 el-form rules 声明 approver_id / remark 双必填;
打开弹窗即调用 checkScrapApproval 预检并展示提示文案。

三、顺带修复
库位列此前恒为空:三张库存表 to_dict 只输出 warehouse_loc,而模板绑定
warehouse_location。已在 loadStockList 中归一化。

SFC 编译与 vue-tsc 通过,无新增类型错误。
This commit is contained in:
yueli
2026-09-10 11:32:56 +08:00
parent e152f16ebc
commit 1aa31334b1

View File

@ -3,44 +3,180 @@
<el-card shadow="always">
<template #header>
<div class="card-header">
<span class="title">报废申请</span>
<div class="header-left">
<span class="title">报废申请</span>
<span class="subtitle">(请添加需要报废的物品)</span>
</div>
<div>
<el-input
v-model="keyword"
placeholder="按名称/规格/SKU 搜索库存"
style="width: 260px; margin-right: 10px;"
clearable
@keyup.enter="loadStock"
@clear="loadStock"
/>
<el-button type="primary" @click="loadStock">查询</el-button>
<el-button type="danger" :disabled="cart.length === 0" @click="clearCart">清空列表</el-button>
<el-divider direction="vertical" />
<el-button type="primary" :icon="Plus" @click="openManualSelect">手动添加库存</el-button>
<el-button type="success" :icon="Select" :disabled="cart.length === 0" @click="openSubmitDialog">
提交报废申请
</el-button>
</div>
</div>
</template>
<el-table :data="stockList" border height="480" v-loading="loading" @selection-change="onSelect">
<el-table-column type="selection" width="50" />
<el-alert
v-if="cart.length === 0"
title="清单为空,请点击右上角【手动添加库存】选择要报废的物品"
type="info"
center
show-icon
:closable="false"
style="margin-bottom: 20px"
/>
<!-- 主表格 = 已选清单购物车不再承载搜索结果避免搜索重置选中态 -->
<el-table
v-else
:data="cart"
border
row-key="uniqueKey"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="typeLabel" label="类型" width="90" align="center" />
<el-table-column prop="name" label="物料名称" min-width="170" show-overflow-tooltip />
<el-table-column label="规格型号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.standard || row.spec_model || '-' }}</template>
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column label="库位" width="120" show-overflow-tooltip>
<template #default="{ row }">
<span style="color:#409EFF;">{{ row.location || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_number || row.serial_number || '-' }}</template>
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
</el-table-column>
<el-table-column label="可用库存" width="110" align="right">
<template #default="{ row }">{{ Number(row.available_quantity || 0) }}</template>
<template #default="{ row }">{{ row.available_qty }}</template>
</el-table-column>
<el-table-column label="本次报废" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-input-number
v-model="row.scrap_qty"
:min="1"
:max="row.available_qty"
:precision="0"
:controls="false"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button type="danger" link @click="removeCartRow(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:12px; text-align:right;">
<span style="margin-right:12px;color:#909399;">已选 {{ selected.length }} </span>
<el-button type="primary" :disabled="selected.length===0" @click="openSubmitDialog">提交报废申请</el-button>
<div v-if="cart.length > 0" class="cart-summary">
<span class="num">{{ cart.length }}</span> 种物品
合计报废 <span class="num">{{ totalScrapCount }}</span>
</div>
</el-card>
<!-- ============================================================
选择库存物品独立弹窗搜索与已选解耦
reserve-selection + row-key 保证跨搜索/翻页保留勾选
============================================================ -->
<el-dialog
v-model="manualDialogVisible"
title="选择库存物品"
width="85%"
top="5vh"
destroy-on-close
:close-on-click-modal="false"
>
<div class="filter-container">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 / 规格型号 / SKU 进行搜索"
style="width: 320px"
:prefix-icon="Search"
clearable
@input="filterStock"
/>
<span class="filter-tip">
提示点击表格行可勾选<span style="color:#F56C6C;font-weight:bold;">修改数量会自动勾选</span>
</span>
</div>
<el-table
ref="manualTableRef"
:data="stockList"
v-loading="stockLoading"
height="500"
border
row-key="uniqueKey"
@selection-change="handleStockSelection"
@row-click="handleRowClick"
style="cursor: pointer"
>
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="规格" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.standard || '-' }}</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" width="130" show-overflow-tooltip />
<el-table-column label="库位" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.warehouse_location || '-' }}</template>
</el-table-column>
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_number || row.serial_number || '-' }}</template>
</el-table-column>
<el-table-column prop="available_quantity" label="可用库存" width="110" align="right" />
<el-table-column label="本次报废" width="150" align="center" fixed="right">
<template #default="{ row }">
<!-- @click.stop防止点击数字框冒泡触发 @row-click 反转勾选 -->
<el-input-number
v-model="row.scrap_qty"
:min="1"
:max="Number(row.available_quantity || 0)"
:precision="0"
size="small"
style="width: 100%"
placeholder="0"
@click.stop
@change="(val: any) => handleManualQuantityChange(val, row)"
/>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 12px; justify-content: flex-end; display: flex;"
v-model:current-page="stockPage"
v-model:page-size="stockPageSize"
:total="stockTotal"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next"
background
@size-change="handleStockSizeChange"
@current-change="handleStockPageChange"
/>
<template #footer>
<span style="float:left; line-height:32px; color:#909399;">
已勾选 <b style="color:#409EFF;">{{ tempSelection.length }}</b>
<span v-if="alreadyInCartCount > 0" style="margin-left:6px;">
其中 {{ alreadyInCartCount }} 项已在清单中
</span>
</span>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
</template>
</el-dialog>
<!-- 提交报废申请 -->
<el-dialog v-model="dialogVisible" title="提交报废申请" width="760px" destroy-on-close :close-on-click-modal="false">
<el-table :data="cart" border size="small" max-height="300">
@ -62,15 +198,16 @@
</el-table-column>
</el-table>
<el-form label-width="100px" style="margin-top:14px;">
<el-form-item v-if="approvalVisible" label="指定审批人">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="margin-top:14px;">
<!-- 业务规则所有报废申请一律需审批审批人恒为必填项 -->
<el-form-item label="指定审批人" prop="approver_id">
<div v-if="approvalText" style="color:#E6A23C; font-size:12px; line-height:1.4; margin-bottom:4px;">{{ approvalText }}</div>
<el-select v-model="approverId" placeholder="请选择审批人" style="width:100%" filterable>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width:100%" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="报废原因" required>
<el-input v-model="remark" type="textarea" :rows="2" placeholder="请填写报废原因(必填)" maxlength="200" show-word-limit />
<el-form-item label="报废原因" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请填写报废原因(必填)" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
@ -83,46 +220,188 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getStockList } from '@/api/inbound/stock'
import { ref, reactive, computed, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox, ElTable, ElForm } from 'element-plus'
import { Plus, Search, Select } from '@element-plus/icons-vue'
import { getScrapStockList } from '@/api/scrap'
import { getApproversList } from '@/api/auth'
import { submitScrapRequest, checkScrapApproval } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loading = ref(false)
const keyword = ref('')
const stockList = ref<any[]>([])
const selected = ref<any[]>([])
// --- 已选清单(唯一数据源,主表格 / 选择弹窗 / 提交弹窗共享同一份对象) ---
const cart = ref<any[]>([])
// --- 选择库存物品弹窗 ---
const manualDialogVisible = ref(false)
const manualTableRef = ref<InstanceType<typeof ElTable>>()
const stockList = ref<any[]>([]) // 弹窗内搜索结果(服务端分页)
const stockTotal = ref(0)
const stockPage = ref(1)
const stockPageSize = ref(20)
const stockLoading = ref(false)
const searchKeyword = ref('')
const tempSelection = ref<any[]>([]) // 弹窗内暂存勾选
let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
// --- 提交对话框 ---
const dialogVisible = ref(false)
const submitting = ref(false)
const cart = ref<any[]>([])
const remark = ref('')
const approverId = ref<number | null>(null)
const formRef = ref<InstanceType<typeof ElForm>>()
const approvers = ref<any[]>([])
const approvalVisible = ref(false)
const approvalText = ref('')
// ★ 预检结果:提交前由 checkScrapApproval 写入,供文案提示使用
const isApprovalRequired = ref(false)
const isKeeper = () => (userStore.role || '').toUpperCase() === 'WAREHOUSE_MGR'
const form = reactive({
approver_id: null as number | null,
remark: '',
})
const loadStock = async () => {
loading.value = true
// ★ 业务规则:所有报废申请一律需审批 → 审批人与报废原因恒为必填
const rules = {
approver_id: [{ required: true, message: '请选择审批人', trigger: 'change' }],
remark: [{ required: true, message: '请填写报废原因', trigger: 'blur' }],
}
// ============================================================
// 库存行的唯一键:三张库存表主键各自独立,必须带表名前缀防跨表碰撞。
// source_table 由后端稳定注入,取值恰好就是提交报废申请所需的 source_table。
// ============================================================
const stockKey = (row: any): string => `${row.source_table}_${row.id}`
// 库存行 → 购物车项
const toCartItem = (row: any) => ({
uniqueKey: stockKey(row),
source_table: row.source_table,
stock_id: row.id,
base_id: row.base_id,
name: row.name || row.material_name || '',
spec_model: row.standard || row.spec_model || '',
sku: row.sku || '',
location: row.warehouse_location || row.warehouse_loc || '',
batch_number: row.batch_number || row.serial_number || '',
available_qty: Number(row.available_quantity || 0),
scrap_qty: Number(row.scrap_qty) || 1,
})
// --- 计算属性 ---
const totalScrapCount = computed(() =>
cart.value.reduce((sum, it) => sum + (Number(it.scrap_qty) || 0), 0)
)
const alreadyInCartCount = computed(() => {
const keys = new Set(cart.value.map(it => it.uniqueKey))
return tempSelection.value.filter(it => keys.has(stockKey(it))).length
})
// --- 加载库存(服务端搜索 + 分页)---
// 注意:不传 is_aggregated —— 报废要求 stock_id 精确到单条库存行,
// 聚合后 id 会退化为组内代表项,导致「申请扣 A 行、实际报废 B 行」。
const loadStockList = async () => {
stockLoading.value = true
try {
const res: any = await getStockList({ page: 1, pageSize: 200, keyword: keyword.value.trim() })
const items = res?.data?.items || res?.data?.list || res?.data || []
stockList.value = items
const res: any = await getScrapStockList({
page: stockPage.value,
pageSize: stockPageSize.value,
keyword: searchKeyword.value.trim(),
})
// ★ uniqueKey 必须物化到行对象上reserve-selection 依赖 row-key 真实存在
stockList.value = (res.data?.list || []).map((item: any) => ({
...item,
uniqueKey: stockKey(item),
// 后端 to_dict 只输出 warehouse_loc统一归一化否则库位列恒为空
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || '',
}))
stockTotal.value = res.data?.total || 0
} catch (e) {
ElMessage.error('加载库存失败')
} finally {
loading.value = false
stockLoading.value = false
}
}
loadStock()
const onSelect = (rows: any[]) => { selected.value = rows }
const openManualSelect = async () => {
manualDialogVisible.value = true
stockPage.value = 1
searchKeyword.value = ''
tempSelection.value = []
await loadStockList()
}
// 搜索防抖 → 服务端过滤
const filterStock = () => {
if (stockSearchTimer) clearTimeout(stockSearchTimer)
stockSearchTimer = setTimeout(() => {
stockPage.value = 1
loadStockList()
}, 350)
}
const handleStockPageChange = (page: number) => {
stockPage.value = page
loadStockList()
}
const handleStockSizeChange = (size: number) => {
stockPageSize.value = size
stockPage.value = 1
loadStockList()
}
const handleStockSelection = (val: any[]) => { tempSelection.value = val }
// 点击行任意位置切换勾选undefined = 取反)
const handleRowClick = (row: any) => {
manualTableRef.value?.toggleRowSelection(row, undefined)
}
// 弹窗内数量变化联动勾选
const handleManualQuantityChange = (val: number | undefined, row: any) => {
if (val && val > 0) manualTableRef.value?.toggleRowSelection(row, true)
else manualTableRef.value?.toggleRowSelection(row, false)
}
// 确认添加:按 uniqueKey 回查当前页行取权威 scrap_qtytempSelection 可能是旧对象快照)
const confirmManualAdd = () => {
if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
const existing = new Set(cart.value.map(it => it.uniqueKey))
const toAdd: any[] = []
for (const it of tempSelection.value) {
const key = stockKey(it)
if (existing.has(key)) continue
// 优先用当前 stockList 里的同名行,保证拿到的是最新数量
const fresh = stockList.value.find(s => stockKey(s) === key) || it
toAdd.push(toCartItem(fresh))
existing.add(key)
}
if (toAdd.length === 0) {
manualDialogVisible.value = false
return ElMessage.warning('选中的物品已全部在清单中')
}
cart.value.push(...toAdd)
manualDialogVisible.value = false
tempSelection.value = []
ElMessage.success(`成功添加 ${toAdd.length} 项物品`)
}
// --- 清单维护 ---
const removeCartRow = (row: any) => {
const idx = cart.value.findIndex(it => it.uniqueKey === row.uniqueKey)
if (idx > -1) cart.value.splice(idx, 1)
}
const clearCart = () => {
ElMessageBox.confirm('确定要清空已选清单吗?', '提示', { type: 'warning' })
.then(() => { cart.value = [] })
.catch(() => {})
}
onBeforeUnmount(() => {
if (stockSearchTimer) clearTimeout(stockSearchTimer)
})
// --- 提交 ---
const loadApprovers = async () => {
try {
const res: any = await getApproversList()
@ -130,85 +409,86 @@ const loadApprovers = async () => {
} catch (e) { approvers.value = [] }
}
const openSubmitDialog = async () => {
// 构造购物车(保留 source_table + stock_id 精准定位 + 快照字段)
cart.value = selected.value.map((r: any) => ({
source_table: r.source_table,
stock_id: r.id,
base_id: r.base_id,
name: r.name || r.material_name || '',
spec_model: r.standard || r.spec_model || '',
sku: r.sku || '',
location: r.warehouse_location || '',
batch_number: r.batch_number || r.serial_number || '',
available_qty: Number(r.available_quantity || 0),
scrap_qty: 1,
}))
const buildItems = () => cart.value.map(it => ({
source_table: it.source_table,
stock_id: it.stock_id,
scrap_qty: Number(it.scrap_qty || 0),
name: it.name,
spec_model: it.spec_model,
sku: it.sku,
location: it.location,
batch_number: it.batch_number,
}))
remark.value = ''
approverId.value = null
approvalText.value = ''
// 库管代建默认显示审批人
approvalVisible.value = isKeeper()
if (approvalVisible.value) approvalText.value = '库管代建报废申请需审批,请选择审批人'
loadApprovers()
// ★ 预检:调用 checkScrapApproval把结果写入 isApprovalRequired 并生成提示文案。
// 所有报废申请一律需审批,此处主要用于向用户展示「哪些物料触发了审批」。
const runApprovalPrecheck = async (items: any[]) => {
try {
const chk: any = await checkScrapApproval({ items })
isApprovalRequired.value = true // 业务上恒需审批
const flagged: any[] = chk?.data?.materials || []
approvalText.value = flagged.length > 0
? `以下物料需审批报废:${flagged.map((m: any) => `${m.name}${m.spec_model || '-'}`).join('')}`
: '所有报废申请均需审批,请选择审批人'
} catch (e) {
isApprovalRequired.value = true
approvalText.value = '所有报废申请均需审批,请选择审批人'
}
}
const openSubmitDialog = async () => {
if (cart.value.length === 0) return ElMessage.warning('请先添加要报废的物品')
form.approver_id = null
form.remark = ''
isApprovalRequired.value = true
approvalText.value = '所有报废申请均需审批,请选择审批人'
formRef.value?.clearValidate()
await loadApprovers()
// 打开弹窗即预检,让用户在填写前就看到需审批提示
await runApprovalPrecheck(buildItems())
dialogVisible.value = true
}
const submit = async () => {
if (!remark.value.trim()) {
return ElMessage.warning('请填写报废原因')
}
const items = cart.value.map(it => ({
source_table: it.source_table,
stock_id: it.stock_id,
scrap_qty: Number(it.scrap_qty || 0),
name: it.name,
spec_model: it.spec_model,
sku: it.sku,
location: it.location,
batch_number: it.batch_number,
}))
if (!formRef.value) return
// ★ 表单校验:审批人与报废原因均为必填
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
submitting.value = true
try {
// ★ 预检:库管代建一律需审批;否则命中需审批物料才需审批
const isK = isKeeper()
let needApproval = isK
let flagged: any[] = []
submitting.value = true
try {
const chk: any = await checkScrapApproval({ items })
needApproval = needApproval || !!chk?.data?.need_approval
flagged = chk?.data?.materials || []
} catch (e) { flagged = [] }
approvalVisible.value = needApproval
approvalText.value = isK && flagged.length === 0
? '库管代建报废申请需审批,请选择审批人'
: flagged.map((m: any) => `${m.name}${m.spec_model || '-'}`).join('')
const items = buildItems()
await runApprovalPrecheck(items)
if (needApproval && !approverId.value) {
return ElMessage.warning(`${approvalText.value || '该申请需审批'}。请先选择审批人`)
await submitScrapRequest({
items,
remark: form.remark.trim(),
approver_id: form.approver_id,
})
ElMessage.success('报废申请已提交,待审批人审批')
dialogVisible.value = false
cart.value = []
} catch (err: any) {
// 拦截器已按后端 data.msg 弹业务错误HTTP 错误不重复
if (!err?.response) ElMessage.error('网络异常,请重试')
} finally {
submitting.value = false
}
await submitScrapRequest({
items,
remark: remark.value.trim(),
approver_id: needApproval ? approverId.value : null,
})
ElMessage.success('报废申请已提交(含需审批物料时将进入审批,否则直接待库管执行)')
dialogVisible.value = false
selected.value = []
loadStock()
} catch (err: any) {
// 拦截器已按后端 data.msg 弹业务错误HTTP 错误不重复
if (!err?.response) ElMessage.error('网络异常,请重试')
} finally {
submitting.value = false
}
})
}
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: baseline; gap: 8px; }
.title { font-size: 18px; font-weight: bold; }
.subtitle { font-size: 12px; color: #909399; }
.filter-container { display: flex; align-items: center; gap: 15px; margin-bottom: 12px; }
.filter-tip { color: #909399; font-size: 12px; }
.cart-summary { margin-top: 15px; text-align: right; color: #606266; font-size: 14px; }
.cart-summary .num { color: #F56C6C; font-weight: bold; }
</style>