Files
bj_power/bj_power_wms/frontend/src/pages/Outbound.vue
T
zhoujianjin 7a925c78c4 feat(outbound)为出库单查询和导出功能添加入库归属单号筛选条件
- 更新前端页面表单,新增归属单号搜索框并绑定对应参数
- 调整后端查询代码中归属单号过滤条件的位置,优化可读性
2026-09-20 19:02:33 +08:00

725 lines
38 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
defineOptions({ name: 'Outbound' })
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { getRealName } from '../utils/auth'
import { fmtTime } from '../utils/format'
import { exportXlsxFetch } from '../utils/export'
import { can } from '../utils/perm'
import { confirmAction } from '../utils/confirm'
import PageHelp from '../components/PageHelp.vue'
import MaterialSelect from '../components/MaterialSelect.vue'
import { helpOutbound } from '../help'
const activeTab = ref('prep') // prep=备料出库 / general=通用出库 / records=出库记录
const operator = getRealName()
/* ===================== 备料出库(MES 工单台账) ===================== */
const queryForm = reactive({ orderNo: '' })
// 目标工位:AGV 配送 toDock 参数,DOCK01~DOCK20 下拉选填(不选默认产线入口 DOCK01)
const targetDock = ref('')
const dockOptions = Array.from({ length: 20 }, (_, i) => 'DOCK' + String(i + 1).padStart(2, '0'))
const loadingPrep = ref(false)
const queried = ref(false)
const rows = ref([])
const orders = ref([])
const ordersLoading = ref(false)
// 工单号来自 MES 实时查询:无输入默认拉「未完成优先」的前 20 条;输入时按工单号模糊远程搜索。
async function searchOrders(query) {
ordersLoading.value = true
try {
const data = await request.get('/orders', { params: { orderNo: query || '', limit: 20 } })
orders.value = Array.isArray(data) ? data : data?.list || []
} catch { orders.value = [] }
finally { ordersLoading.value = false }
}
async function loadOrders() { await searchOrders('') }
function orderLabel(o) { return o.productName ? `${o.workOrderNo} ${o.productName}` : o.workOrderNo }
// 选中下拉项或手输兜底(allow-create)后直接查该工单台账
function onOrderPick(v) { if (v && String(v).trim()) queryPrep() }
onMounted(loadOrders)
const pendingRows = computed(() => rows.value.filter(r => r.status !== '领料完结'))
function gapOf(row) { return Math.max((row.totalQty || 0) - (row.outQty || 0), 0) }
// 一键自动分配批次:合格区 Z02,batchNo 正序 FIFO 凑够缺口
async function autoAllocate(row) {
const gap = gapOf(row)
if (gap <= 0) return
const stock = await request.get('/stock/query', { params: { type: 'batch', materialCode: row.materialCode, page: 1, pageSize: 200 } })
const batches = (stock?.batch || [])
.filter(b => b.qualityStatus === '合格' && b.zoneCode === 'Z02')
.sort((a, b) => String(a.batchNo).localeCompare(String(b.batchNo)))
const plan = []
let remain = gap
for (const b of batches) {
if (remain <= 0) break
const avail = (b.quantity || 0) - (b.lockedQty || 0)
if (avail <= 0) continue
const take = Math.min(avail, remain)
plan.push({ batchNo: b.batchNo, qty: take, zoneCode: b.zoneCode })
remain -= take
}
if (!plan.length) {
ElMessage.warning(`物料 ${row.materialCode} 在合格区(Z02)无可用批次库存`)
return
}
if (remain > 0) {
ElMessage.error(`可用量不足:缺口 ${gap}Z02 仅可凑 ${gap - remain},还差 ${remain}`)
return
}
const lines = plan.map(p => `· 批次 ${p.batchNo} 出库 ${p.qty}`).join('\n')
await ElMessageBox.confirm(
`工单 ${row.orderNo} 物料 ${row.materialCode}\n缺口 ${gap},按 FIFO 分配如下:\n${lines}\n\n操作人:${operator}${targetDock.value ? `\n目标口:${targetDock.value}` : ''}`,
'确认自动分配出库',
{ type: 'warning', confirmButtonText: '确认出库', cancelButtonText: '取消' }
)
const outboundNos = []
let lastStatus = ''
for (const p of plan) {
const res = await request.post('/outbound/create', {
orderNo: row.orderNo, materialCode: row.materialCode, batchNo: p.batchNo,
qty: p.qty, operator, targetDock: targetDock.value.trim() || undefined
})
if (res?.outboundNo) outboundNos.push(res.outboundNo)
lastStatus = res?.status || lastStatus
}
const done = lastStatus === '领料完结'
ElMessage.success(`出库完成:出库单号 ${outboundNos.join('、')};该物料${done ? '已' : '未'}领料完结`)
await queryPrep()
}
function statusTag(s) { return s === '领料完结' ? 'success' : s === '进行中' ? 'primary' : 'info' }
async function queryPrep() {
if (!queryForm.orderNo.trim()) { ElMessage.warning('请输入工单号'); return }
loadingPrep.value = true
try {
const data = await request.get('/ledger/query', { params: { orderNo: queryForm.orderNo.trim(), page: 1, pageSize: 100 } })
rows.value = data?.list || []
queried.value = true
if (!rows.value.length) ElMessage.info('该工单暂无台账记录')
} finally { loadingPrep.value = false }
}
/* ===================== 通用出库(手动,不依赖工单) ===================== */
// 出库类别:退料/退货/样品/报废/发货/其他(客户诉求 L96-108 + U5
const CATEGORY_OPTIONS = [
{ value: 'return', label: '退料' },
{ value: 'supplier_return', label: '退货' },
{ value: 'sample', label: '样品' },
{ value: 'scrap', label: '报废' },
{ value: 'deliver', label: '发货' },
{ value: 'other', label: '其他' }
]
// 归属口径(需求22):工单编号/科技项目号/其他,默认其他;工单编号是独立维度标签,非 MES 工单号
const OWNERSHIP_OPTIONS = [
{ value: 'workorder', label: '工单编号' },
{ value: 'project', label: '科技项目号' },
{ value: 'other', label: '其他' }
]
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
const zones = ref([])
// 目标工位下拉(需求20:来自 MES 工位列表,不允许手输);可用批次下拉(问题18:批次不手输,从该物料在库批次中选)
const stationOptions = ref([])
const batchOptions = ref([])
// 选中物料的展示类型(问题18:类型含「其他」):other=其他品类(按批次管理)/sn=电气件/batch=结构件
const pickedType = ref('')
const genForm = reactive({
materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '',
shelfNo: '', layerNo: '', positionNo: '',
boxNo: '', contractNo: '', remark: '',
outboundCategory: 'other', ownershipType: 'other', ownershipNo: '', targetStation: ''
})
const snInput = ref('')
const snList = ref([])
const savingGen = ref(false)
const genRows = ref([])
const loadingGen = ref(false)
const genPage = reactive({ current: 1, size: 10 })
const genTotal = ref(0)
const SN_SEP = /[,;;、\s]+/
function addSnLines() {
for (let raw of String(snInput.value).split(SN_SEP)) {
const sn = raw.trim()
if (sn && !snList.value.includes(sn)) snList.value.push(sn)
}
snInput.value = ''
}
function removeSn(i) { snList.value.splice(i, 1) }
async function loadDicts() {
loadStations()
if (zones.value.length) return
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条),此处只加载区域
const zs = await request.get('/zone/picker')
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
// 目标工位选项(需求20):来自后端派生的 MES 产线工位列表
async function loadStations() {
if (stationOptions.value.length) return
try {
const d = await request.get('/outbound/stations')
stationOptions.value = d?.list || []
} catch { stationOptions.value = [] }
}
// 可用批次选项(问题18:批次号不手输,从所选物料的在库批次中下拉选择):FIFO 排序,默认带出首个可用批次
async function loadBatchOptions(code) {
if (!code) { batchOptions.value = []; return }
try {
const stock = await request.get('/stock/query', { params: { type: 'batch', materialCode: code, page: 1, pageSize: 200 } })
batchOptions.value = (stock?.batch || [])
.map(b => ({ ...b, avail: (b.quantity || 0) - (b.lockedQty || 0) }))
.filter(b => b.avail > 0)
.sort((a, b) => String(a.batchNo).localeCompare(String(b.batchNo)))
if (batchOptions.value.length && !genForm.batchNo) genForm.batchNo = batchOptions.value[0].batchNo
} catch { batchOptions.value = [] }
}
// 物料选中(问题18:出库类型由物料档案自动判定,杜绝客户手选「电气件/结构件」)
function onMaterialPick(m) {
genForm.mode = m && m.manageMode === 2 ? 2 : 1
pickedType.value = m ? (m.itemType === 4 ? 'other' : (m.manageMode === 2 ? 'sn' : 'batch')) : ''
genForm.batchNo = ''
snList.value = []
snInput.value = ''
batchOptions.value = []
if (!m) return
fillZoneFromHistory()
if (genForm.mode === 1) loadBatchOptions(m.code)
}
// 区域下拉选项:picker 每行是一个货位,同一区域会重复出现,按区域编码去重后只展示一条
const zoneOptions = computed(() => {
const map = new Map()
zones.value.forEach(z => {
if (!z.zoneCode) return
if (!map.has(z.zoneCode)) map.set(z.zoneCode, { zoneCode: z.zoneCode, zoneName: z.zoneName || '' })
})
return [...map.values()]
})
// 库位三级联动(与入库同口径):基于已加载 zones(每行是完整四级货位)派生可选项
function zoneShelves(zoneCode) {
const set = new Set()
zones.value.forEach(z => { if (z.zoneCode === zoneCode && z.shelfNo) set.add(z.shelfNo) })
return [...set].sort()
}
function zoneLayers(zoneCode, shelfNo) {
const set = new Set()
zones.value.forEach(z => { if (z.zoneCode === zoneCode && z.shelfNo === shelfNo && z.layerNo) set.add(z.layerNo) })
return [...set].sort()
}
function zonePositions(zoneCode, shelfNo, layerNo) {
const set = new Set()
zones.value.forEach(z => { if (z.zoneCode === zoneCode && z.shelfNo === shelfNo && z.layerNo === layerNo && z.positionNo) set.add(z.positionNo) })
return [...set].sort()
}
function onGenZoneChange() {
genForm.shelfNo = ''; genForm.layerNo = ''; genForm.positionNo = ''
if (genForm.materialCode || genForm.batchNo) fillZoneFromHistory()
}
function onGenShelfChange() { genForm.layerNo = ''; genForm.positionNo = '' }
function onGenLayerChange() { genForm.positionNo = '' }
function locText(row) {
const parts = []
if (row?.shelfNo) parts.push(row.shelfNo + '架')
if (row?.layerNo) parts.push(row.layerNo + '层')
if (row?.positionNo) parts.push(row.positionNo + '位')
return parts.length ? parts.join('/') : '-'
}
// 出库类别标签(需求17:类别是纯标签记录,列表/出库单都要显示清楚)
function categoryLabel(v) {
const m = { return: '退料', supplier_return: '退货', sample: '样品', scrap: '报废', deliver: '发货', other: '其他' }
return m[v] || (v || '-')
}
// 出库库位联动(客户诉求 一.9「出库相同」):与入库同口径回填 区域/货架/层/位置 四级。
// 优先级:① 指定批次在库的实际库位 ② 该物料最近一次入库库位 ③ 无历史时按所选区域推荐同区空位。
async function fillZoneFromHistory() {
if (!genForm.materialCode && !genForm.batchNo) return
let filled = false
try {
const loc = await request.get('/outbound/location-hint', {
params: { materialCode: genForm.materialCode, batchNo: genForm.batchNo }
})
if (loc && loc.zoneCode) {
genForm.zoneCode = loc.zoneCode
genForm.shelfNo = loc.shelfNo || ''
genForm.layerNo = loc.layerNo || ''
genForm.positionNo = loc.positionNo || ''
filled = true
}
} catch {
/* 查询失败不影响主流程 */
}
if (filled) return
// 无历史库位:按区域推荐同区空位(与入库同源接口,返回数组取第一条)
try {
const v = await request.get('/zone/vacant', { params: { zoneCode: genForm.zoneCode || '', limit: 1 } })
const hit = Array.isArray(v) ? v[0] : (v?.list || [])[0]
if (hit) {
genForm.zoneCode = hit.zoneCode
genForm.shelfNo = hit.shelfNo || ''
genForm.layerNo = hit.layerNo || ''
genForm.positionNo = hit.positionNo || ''
}
} catch {
/* 忽略 */
}
}
watch(() => genForm.batchNo, (v) => { if (v && genForm.materialCode) fillZoneFromHistory() })
async function loadGenRows() {
loadingGen.value = true
try {
const data = await request.get('/outbound/query', { params: { outboundType: 'general', page: genPage.current, pageSize: genPage.size } })
genRows.value = (data?.list || []).map(r => ({ ...r, snCodes: (r.details || []).map(d => d.snCode).filter(Boolean).join(',') }))
genTotal.value = data?.total || 0
} finally { loadingGen.value = false }
}
async function submitGen() {
if (!genForm.materialCode) { ElMessage.warning('请选择物料'); return }
if (!genForm.outboundCategory) { ElMessage.warning('请选择出库类别'); return }
if (genForm.ownershipType !== 'other' && !genForm.ownershipNo.trim()) {
ElMessage.warning('请填写归属编号(工单编号 / 科技项目号)'); return
}
if (genForm.outboundCategory === 'other' && !genForm.remark.trim()) {
ElMessage.warning('出库类别选「其他」时,请在备注中填写具体原因'); return
}
const payload = {
materialCode: genForm.materialCode, operator,
outboundCategory: genForm.outboundCategory,
ownershipType: genForm.ownershipType,
ownershipNo: genForm.ownershipNo.trim() || undefined,
targetStation: genForm.targetStation.trim() || undefined,
zoneCode: genForm.zoneCode || undefined,
shelfNo: genForm.shelfNo || undefined,
layerNo: genForm.layerNo || undefined,
positionNo: genForm.positionNo || undefined,
boxNo: genForm.boxNo.trim() || undefined,
contractNo: genForm.contractNo.trim() || undefined,
remark: genForm.remark.trim() || undefined
}
if (genForm.mode === 1) {
if (!genForm.batchNo) { ElMessage.warning('请从该物料可用批次中选择出库批次'); return }
if (!genForm.qty || genForm.qty <= 0) { ElMessage.warning('请输入有效数量'); return }
payload.batchNo = genForm.batchNo
payload.qty = genForm.qty
} else {
if (!snList.value.length) { ElMessage.warning('请录入至少一个 SN'); return }
payload.snList = [...snList.value]
payload.qty = snList.value.length
}
const go = await confirmAction(`确认提交通用出库?物料【${genForm.materialCode}】、数量 ${payload.qty}、类别【${genForm.outboundCategory}】。`, '通用出库确认')
if (!go) return
savingGen.value = true
try {
const res = await request.post('/outbound/general', payload)
ElMessage.success(`通用出库成功:${res?.outboundNo}${res?.boxNo ? ' 箱号 ' + res.boxNo : ''}`)
Object.assign(genForm, { materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '', shelfNo: '', layerNo: '', positionNo: '', boxNo: '', contractNo: '', remark: '', outboundCategory: 'other', ownershipType: 'other', ownershipNo: '', targetStation: '' })
snInput.value = ''
snList.value = []
batchOptions.value = []
pickedType.value = ''
genPage.current = 1
await loadGenRows()
if (activeTab.value === 'records') await loadRecords()
} finally { savingGen.value = false }
}
/* ===================== 出库记录(统一主表,全部 + 导出) ===================== */
// 出库记录默认展示近 3 个月(与后端导出时间硬规则一致,保证「列表条数 = 导出条数」)
function recDefaultRange() {
const p = (n) => String(n).padStart(2, '0')
const fmt = (d) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
const end = new Date(); const start = new Date(); start.setMonth(start.getMonth() - 3)
return [fmt(start), fmt(end)]
}
const recFilters = reactive({ outboundType: '', status: '', materialCode: '', boxNo: '', ownershipNo: '', dateRange: recDefaultRange() })
const recRows = ref([])
const recTotal = ref(0)
const loadingRec = ref(false)
const recPage = reactive({ current: 1, size: 10 })
function snOf(row) { return (row.details || []).map(d => d.snCode).filter(Boolean).join(',') }
async function loadRecords() {
loadingRec.value = true
try {
const params = {
outboundType: recFilters.outboundType,
status: recFilters.status,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim(),
ownershipNo: recFilters.ownershipNo.trim(),
page: recPage.current, pageSize: recPage.size
}
if (Array.isArray(recFilters.dateRange) && recFilters.dateRange.length === 2) {
params.startDate = recFilters.dateRange[0]
params.endDate = recFilters.dateRange[1]
}
const data = await request.get('/outbound/query', { params })
recRows.value = (data?.list || []).map(r => ({ ...r, snCodes: snOf(r) }))
recTotal.value = data?.total || 0
} finally { loadingRec.value = false }
}
function searchRec() { recPage.current = 1; loadRecords() }
function resetRec() { Object.assign(recFilters, { outboundType: '', status: '', materialCode: '', boxNo: '', ownershipNo: '', dateRange: recDefaultRange() }); searchRec() }
function downloadCSV() {
// 时间窗口与列表保持一致:未选/已清空时回落近 3 个月,保证确认框条数 = 实际导出条数
const dr = (Array.isArray(recFilters.dateRange) && recFilters.dateRange.length === 2) ? recFilters.dateRange : recDefaultRange()
exportXlsxFetch('/api/outbound/export', {
outboundType: recFilters.outboundType,
status: recFilters.status,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim(),
ownershipNo: recFilters.ownershipNo.trim(),
startDate: dr[0],
endDate: dr[1]
}, '出库管理.xlsx', { countUrl: '/outbound/query', name: '出库记录' })
.then((name) => { if (name) ElMessage.success('已导出:' + name) })
.catch(() => ElMessage.error('导出失败'))
}
function onTabChange(tab) {
if (tab === 'general') { loadDicts(); loadGenRows() }
else if (tab === 'records') { loadDicts(); loadRecords() }
}
onMounted(loadDicts)
</script>
<template>
<div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- ============ 备料出库MES 工单台账驱动 ============ -->
<el-tab-pane label="工单备料出库(MES" name="prep">
<el-alert type="info" :closable="false" class="mb12"
title="本页对接 MES 工单台账:输入 MES 工单号查询其物料台账并领料。"
description="未启用 MES 或工单未同步时,工单下拉可能为空——此时可直接手输工单号(前提是台账已同步),或改用「通用出库」处理退料/样品/报废/发货等不挂靠工单的场景。" />
<el-card shadow="never" class="mb12">
<el-form inline label-width="90px" @submit.prevent>
<el-form-item label="工单号">
<el-select v-model="queryForm.orderNo" filterable remote allow-create default-first-option
clearable reserve-keyword :remote-method="searchOrders" :loading="ordersLoading"
placeholder="选择或输入工单号(默认未完成优先前20条,可模糊搜索)"
style="width:340px" @change="onOrderPick">
<el-option v-for="o in orders" :key="o.workOrderNo" :value="o.workOrderNo" :label="orderLabel(o)">
<span style="float:left">{{ o.workOrderNo }}</span>
<span style="float:right;color:#909399;font-size:12px">{{ o.productName }} {{ o.status }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-input :model-value="operator" disabled style="width:160px" />
</el-form-item>
<el-form-item label="目标工位">
<el-select v-model="targetDock" placeholder="选填,默认 DOCK01" clearable filterable
style="width:180px">
<el-option v-for="d in dockOptions" :key="d" :value="d" :label="d" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loadingPrep" @click="queryPrep">查询备料台账</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="queried" shadow="never">
<template #header>
台账明细 {{ rows.length }} 待补 {{ pendingRows.length }}
</template>
<el-table :data="rows" border v-loading="loadingPrep">
<el-table-column prop="orderNo" label="工单号" min-width="140" />
<el-table-column prop="materialCode" label="图号" min-width="150" />
<el-table-column prop="materialName" label="物料名称" min-width="140">
<template #default="{ row }">{{ row.materialName || '-' }}</template>
</el-table-column>
<el-table-column prop="totalQty" label="需求总量" width="100" align="center" />
<el-table-column prop="outQty" label="已出库" width="90" align="center" />
<el-table-column label="缺口" width="90" align="center">
<template #default="{ row }"><span :class="{ gap: gapOf(row) > 0 }">{{ gapOf(row) }}</span></template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button v-if="row.status !== '领料完结'" type="primary" size="small" @click="autoAllocate(row)">一键分配出库</el-button>
<span v-else style="color:#67c23a">已完结</span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-empty v-else description="请先按工单号查询备料台账" />
</el-tab-pane>
<!-- ============ 通用出库 ============ -->
<el-tab-pane label="通用出库" name="general">
<el-card shadow="never" class="mb12" header="通用出库(不依赖工单:退料 / 样品 / 报废 / 发货)">
<el-form label-width="96px" style="max-width:880px">
<el-form-item label="物料" required>
<MaterialSelect v-model="genForm.materialCode" placeholder="选择物料(编码/图号/类型/品类)" @item-change="onMaterialPick" />
</el-form-item>
<el-form-item label="出库类型">
<template v-if="genForm.materialCode">
<el-tag size="small" :type="pickedType === 'sn' ? 'warning' : 'info'">
{{ pickedType === 'sn' ? '电气件(SN 管理)' : pickedType === 'other' ? '其他(按批次管理)' : '结构件(批次管理)' }}
</el-tag>
<span style="color:#909399;font-size:12px;margin-left:8px">类型由所选物料档案自动判定无需手选</span>
</template>
<span v-else style="color:#909399;font-size:12px">请先选择物料系统自动带出类型电气件/结构件</span>
</el-form-item>
<el-form-item label="出库类别" required>
<el-select v-model="genForm.outboundCategory" style="width:100%">
<el-option v-for="c in CATEGORY_OPTIONS" :key="c.value" :value="c.value" :label="c.label" />
</el-select>
</el-form-item>
<el-form-item label="归属">
<el-select v-model="genForm.ownershipType" style="width:160px;margin-right:8px">
<el-option v-for="o in OWNERSHIP_OPTIONS" :key="o.value" :value="o.value" :label="o.label" />
</el-select>
<el-input v-if="genForm.ownershipType !== 'none'" v-model="genForm.ownershipNo"
placeholder="归属编号(工单号/科技项目号/其他)" clearable style="width:260px" />
</el-form-item>
<el-form-item label="目标工位">
<el-select v-model="genForm.targetStation" filterable clearable
placeholder="选择目标工位(来自 MES 工位列表,不可手输)" style="width:100%">
<el-option v-for="s in stationOptions" :key="s.stationNo" :value="String(s.stationNo)"
:label="`工位${s.stationNo}${s.name ? ' ' + s.name : ''}`" />
</el-select>
</el-form-item>
<template v-if="genForm.mode === 1">
<el-form-item label="批次号">
<el-select v-model="genForm.batchNo" filterable clearable
placeholder="从该物料可用批次中选择(默认带出 FIFO 首个)" style="width:100%">
<el-option v-for="b in batchOptions" :key="b.batchNo" :value="b.batchNo"
:label="`${b.batchNo}(可用 ${b.avail}${b.qualityStatus || '未检'}${b.zoneCode ? '' + b.zoneCode : ''}`" />
</el-select>
</el-form-item>
<el-form-item label="数量" required>
<el-input-number v-model="genForm.qty" :min="1" style="width:160px" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="SN 录入" required>
<div style="width:100%">
<el-input v-model="snInput" type="textarea" :rows="3"
placeholder="连续扫描 SN,支持 逗号/分号/顿号/空格/回车 分隔,一次可录入多条" />
<div style="margin-top:6px">
<el-button type="primary" plain :disabled="!snInput.trim()" @click="addSnLines">录入到列表</el-button>
<span style="margin-left:8px;color:#909399;font-size:12px">已录入 {{ snList.length }} </span>
</div>
<el-table :data="snList.map((v, i) => ({ i, v }))" size="small" border max-height="180"
empty-text="暂无 SN" style="margin-top:8px;width:100%">
<el-table-column type="index" label="#" width="56" align="center" />
<el-table-column prop="v" label="SN 序列号" min-width="200" />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }"><el-button link type="danger" size="small" @click="removeSn(row.i)">移除</el-button></template>
</el-table-column>
</el-table>
</div>
</el-form-item>
</template>
<el-form-item label="区域">
<el-select v-model="genForm.zoneCode" filterable clearable placeholder="选择区域(选物料/批次后自动带出最近库位)" style="width:100%" @change="onGenZoneChange">
<el-option v-for="z in zoneOptions" :key="z.zoneCode" :value="z.zoneCode" :label="`${z.zoneCode} ${z.zoneName}`" />
</el-select>
</el-form-item>
<el-form-item label="货架 / 层 / 位置">
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<el-select v-model="genForm.shelfNo" placeholder="货架" clearable style="width:120px" @change="onGenShelfChange">
<el-option v-for="s in zoneShelves(genForm.zoneCode)" :key="s" :label="s + ' 架'" :value="s" />
</el-select>
<el-select v-model="genForm.layerNo" placeholder="层" clearable style="width:100px" :disabled="!genForm.shelfNo" @change="onGenLayerChange">
<el-option v-for="l in zoneLayers(genForm.zoneCode, genForm.shelfNo)" :key="l" :label="l + ' 层'" :value="l" />
</el-select>
<el-select v-model="genForm.positionNo" placeholder="位置" clearable style="width:110px" :disabled="!genForm.shelfNo">
<el-option v-for="p in zonePositions(genForm.zoneCode, genForm.shelfNo, genForm.layerNo)" :key="p" :label="p + ' 位'" :value="p" />
</el-select>
</div>
</el-form-item>
<el-form-item label="箱号">
<el-input v-model="genForm.boxNo" placeholder="选填:整箱发货时填写,装箱信息随出库单记录(无需单独录入装箱)" clearable style="width:100%" />
</el-form-item>
<el-form-item label="合同号">
<el-input v-model="genForm.contractNo" placeholder="选填" clearable style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="genForm.remark" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingGen" @click="submitGen">确认通用出库</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" header="通用出库记录">
<el-table :data="genRows" border size="small" v-loading="loadingGen" empty-text="暂无通用出库记录">
<el-table-column prop="outboundNo" label="出库单号" min-width="150" />
<el-table-column label="出库类别" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ categoryLabel(row.outboundCategory) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料" min-width="130" />
<el-table-column prop="batchNo" label="批次号" min-width="120">
<template #default="{ row }">{{ row.batchNo || '-' }}</template>
</el-table-column>
<el-table-column label="库位" min-width="120" align="center">
<template #default="{ row }">{{ row.zoneCode ? row.zoneCode + ' ' + locText(row) : '-' }}</template>
</el-table-column>
<el-table-column label="SN" min-width="180">
<template #default="{ row }">{{ row.snCodes || '-' }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column label="归属" min-width="130">
<template #default="{ row }">
<span v-if="row.ownershipType === 'workorder'">工单编号 {{ row.ownershipNo || '' }}</span>
<span v-else-if="row.ownershipType === 'project'">科技项目号 {{ row.ownershipNo || '' }}</span>
<span v-else-if="row.ownershipType === 'other'">其他 {{ row.ownershipNo || '' }}</span>
<span v-else>其他</span>
</template>
</el-table-column>
<el-table-column prop="targetStation" label="目标工位" width="100" align="center">
<template #default="{ row }">{{ row.targetStation || '-' }}</template>
</el-table-column>
<el-table-column prop="boxNo" label="箱号" min-width="120">
<template #default="{ row }">{{ row.boxNo || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100" align="center" />
<el-table-column label="时间" width="150" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination background layout="total, sizes, prev, pager, next" :total="genTotal"
:current-page="genPage.current" :page-size="genPage.size" :page-sizes="[10, 20, 50, 100]"
@current-change="(p) => { genPage.current = p; loadGenRows() }"
@size-change="(s) => { genPage.size = s; genPage.current = 1; loadGenRows() }" />
</div>
</el-card>
</el-tab-pane>
<!-- ============ 出库记录(统一主表) ============ -->
<el-tab-pane label="出库记录" name="records">
<el-card shadow="never" class="mb12">
<el-form inline label-width="80px" @submit.prevent>
<el-form-item label="类型">
<el-select v-model="recFilters.outboundType" clearable placeholder="全部" style="width:140px">
<el-option label="备料出库" value="workorder" />
<el-option label="工位补料" value="refill" />
<el-option label="通用出库" value="general" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="recFilters.status" clearable placeholder="全部" style="width:120px">
<el-option label="已出库" value="已出库" />
<el-option label="已送达" value="已送达" />
</el-select>
</el-form-item>
<el-form-item label="物料">
<MaterialSelect v-model="recFilters.materialCode" placeholder="物料" width="180px" />
</el-form-item>
<el-form-item label="箱号">
<el-input v-model="recFilters.boxNo" placeholder="按箱号查询" clearable style="width:160px" @keyup.enter="searchRec" />
</el-form-item>
<el-form-item label="归属单号">
<el-input v-model="recFilters.ownershipNo" placeholder="工单号/科技项目号" clearable style="width:160px" @keyup.enter="searchRec" />
</el-form-item>
<el-form-item label="时间">
<el-date-picker v-model="recFilters.dateRange" type="daterange" value-format="YYYY-MM-DD"
range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:240px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loadingRec" @click="searchRec">查询</el-button>
<el-button @click="resetRec">重置</el-button>
<el-button v-if="can('outbound:export')" type="success" @click="downloadCSV">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" header="统一出库主表备料 + 通用装箱信息同表记录">
<el-table :data="recRows" border size="small" v-loading="loadingRec" empty-text="暂无出库记录">
<el-table-column prop="outboundNo" label="出库单号" min-width="150" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.outboundType === 'general' ? 'warning' : row.outboundType === 'refill' ? 'danger' : 'primary'" size="small">
{{ row.outboundType === 'general' ? '通用' : row.outboundType === 'refill' ? '补料' : '备料' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '已送达' ? 'success' : 'info'" size="small">{{ row.status || '已出库' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderNo" label="工单号" min-width="130">
<template #default="{ row }">{{ row.orderNo || '-' }}</template>
</el-table-column>
<el-table-column label="出库类别" width="100" align="center">
<template #default="{ row }">
<span v-if="row.outboundType === 'general'">{{ categoryLabel(row.outboundCategory) }}</span>
<span v-else style="color:#909399">-</span>
</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料" min-width="120" />
<el-table-column prop="batchNo" label="批次号" min-width="110">
<template #default="{ row }">{{ row.batchNo || '-' }}</template>
</el-table-column>
<el-table-column label="库位" min-width="120" align="center">
<template #default="{ row }">{{ row.zoneCode ? row.zoneCode + ' ' + locText(row) : '-' }}</template>
</el-table-column>
<el-table-column label="SN" min-width="160">
<template #default="{ row }">{{ row.snCodes || '-' }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="70" align="center" />
<el-table-column label="归属" min-width="130">
<template #default="{ row }">
<span v-if="row.ownershipType === 'workorder'">工单编号 {{ row.ownershipNo || '' }}</span>
<span v-else-if="row.ownershipType === 'project'">科技项目号 {{ row.ownershipNo || '' }}</span>
<span v-else-if="row.ownershipType === 'other'">其他 {{ row.ownershipNo || '' }}</span>
<span v-else>其他</span>
</template>
</el-table-column>
<el-table-column prop="targetStation" label="目标工位" width="100" align="center">
<template #default="{ row }">{{ row.targetStation || '-' }}</template>
</el-table-column>
<el-table-column prop="boxNo" label="箱号" min-width="120">
<template #default="{ row }">{{ row.boxNo || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100" align="center" />
<el-table-column label="时间" width="150" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination background layout="total, sizes, prev, pager, next" :total="recTotal"
:current-page="recPage.current" :page-size="recPage.size" :page-sizes="[10, 20, 50, 100]"
@current-change="(p) => { recPage.current = p; loadRecords() }"
@size-change="(s) => { recPage.size = s; recPage.current = 1; loadRecords() }" />
</div>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
<PageHelp :data="helpOutbound" page="Outbound" />
</template>
<style scoped>
.mb12 { margin-bottom: 12px; }
.gap { color: #f56c6c; font-weight: 700; }
.pager { display: flex; justify-content: flex-end; margin-top: 10px; }
</style>