725 lines
38 KiB
Vue
725 lines
38 KiB
Vue
<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>
|