Files
bj_power/bj_power_wms/frontend/src/pages/Outbound.vue
T

721 lines
38 KiB
Vue
Raw Normal View History

2026-08-28 15:06:01 +08:00
<script setup>
defineOptions({ name: 'Outbound' })
import { computed, onMounted, reactive, ref, watch } from 'vue'
2026-08-28 15:06:01 +08:00
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'
2026-09-03 18:13:59 +08:00
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'
2026-08-28 15:06:01 +08:00
const activeTab = ref('prep') // prep=备料出库 / general=通用出库 / records=出库记录
2026-08-28 15:06:01 +08:00
const operator = getRealName()
/* ===================== 备料出库(MES 工单台账) ===================== */
const queryForm = reactive({ orderNo: '' })
// 目标工位:AGV 配送 toDock 参数,DOCK01~DOCK20 下拉选填(不选默认产线入口 DOCK01)
2026-08-28 15:06:01 +08:00
const targetDock = ref('')
const dockOptions = Array.from({ length: 20 }, (_, i) => 'DOCK' + String(i + 1).padStart(2, '0'))
const loadingPrep = ref(false)
2026-08-28 15:06:01 +08:00
const queried = ref(false)
const rows = ref([])
const orders = ref([])
const ordersLoading = ref(false)
// 工单号来自 MES 实时查询:无输入默认拉「未完成优先」的前 20 条;输入时按工单号模糊远程搜索。
async function searchOrders(query) {
ordersLoading.value = true
2026-08-29 15:30:25 +08:00
try {
const data = await request.get('/orders', { params: { orderNo: query || '', limit: 20 } })
2026-08-29 15:30:25 +08:00
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)
2026-08-29 15:30:25 +08:00
2026-08-28 15:06:01 +08:00
const pendingRows = computed(() => rows.value.filter(r => r.status !== '领料完结'))
function gapOf(row) { return Math.max((row.totalQty || 0) - (row.outQty || 0), 0) }
2026-08-28 15:06:01 +08:00
// 一键自动分配批次:合格区 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
2026-08-28 15:06:01 +08:00
})
if (res?.outboundNo) outboundNos.push(res.outboundNo)
lastStatus = res?.status || lastStatus
}
const done = lastStatus === '领料完结'
ElMessage.success(`出库完成:出库单号 ${outboundNos.join('、')};该物料${done ? '已' : '未'}领料完结`)
await queryPrep()
2026-08-28 15:06:01 +08:00
}
function statusTag(s) { return s === '领料完结' ? 'success' : s === '进行中' ? 'primary' : 'info' }
2026-08-28 15:06:01 +08:00
async function queryPrep() {
if (!queryForm.orderNo.trim()) { ElMessage.warning('请输入工单号'); return }
loadingPrep.value = true
2026-08-28 15:06:01 +08:00
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)
}
2026-09-18 18:54:54 +08:00
// 区域下拉选项: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
2026-08-28 15:06:01 +08:00
}
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: '', ownershipType: '', 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(),
ownershipType: recFilters.ownershipType,
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: '', 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(),
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() }
2026-08-28 15:06:01 +08:00
}
onMounted(loadDicts)
2026-08-28 15:06:01 +08:00
</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 }}
2026-08-28 15:06:01 +08:00
</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">
2026-09-18 18:54:54 +08:00
<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-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>
2026-09-03 18:13:59 +08:00
<el-button v-if="can('outbound:export')" type="success" @click="downloadCSV">导出</el-button>
</el-form-item>
</el-form>
</el-card>
2026-08-28 15:06:01 +08:00
<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>
2026-08-28 15:06:01 +08:00
</div>
2026-09-03 18:13:59 +08:00
<PageHelp :data="helpOutbound" page="Outbound" />
2026-08-28 15:06:01 +08:00
</template>
<style scoped>
.mb12 { margin-bottom: 12px; }
.gap { color: #f56c6c; font-weight: 700; }
.pager { display: flex; justify-content: flex-end; margin-top: 10px; }
</style>