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

444 lines
22 KiB
Vue
Raw Normal View History

2026-08-28 15:06:01 +08:00
<script setup>
defineOptions({ name: 'Outbound' })
2026-08-29 15:30:25 +08:00
import { computed, onMounted, reactive, ref } 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 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([])
onMounted(loadOrders)
async function loadOrders() {
2026-08-29 15:30:25 +08:00
try {
const data = await request.get('/orders', { params: { page: 1, pageSize: 200 } })
orders.value = Array.isArray(data) ? data : data?.list || []
} catch { orders.value = [] }
}
const orders = ref([])
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 }
}
/* ===================== 通用出库(手动,不依赖工单) ===================== */
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
const zones = ref([])
const genForm = reactive({ materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '', boxNo: '', contractNo: '', remark: '' })
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() {
if (zones.value.length) return
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条),此处只加载区域
const zs = await request.get('/zone/picker')
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
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 }
const payload = {
materialCode: genForm.materialCode, operator,
zoneCode: genForm.zoneCode || undefined,
boxNo: genForm.boxNo.trim() || undefined,
contractNo: genForm.contractNo.trim() || undefined,
remark: genForm.remark.trim() || undefined
}
if (genForm.mode === 1) {
if (!genForm.batchNo.trim()) { ElMessage.warning('请输入批次号'); return }
if (!genForm.qty || genForm.qty <= 0) { ElMessage.warning('请输入有效数量'); return }
payload.batchNo = genForm.batchNo.trim()
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
}
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: '', boxNo: '', contractNo: '', remark: '' })
snInput.value = ''
snList.value = []
genPage.current = 1
await loadGenRows()
if (activeTab.value === 'records') await loadRecords()
} finally { savingGen.value = false }
}
/* ===================== 出库记录(统一主表,全部 + 导出) ===================== */
const recFilters = reactive({ outboundType: '', materialCode: '', boxNo: '', dateRange: [] })
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,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim(),
page: recPage.current, pageSize: recPage.size
}
if (Array.isArray(recFilters.dateRange) && recFilters.dateRange.length === 2) {
params.startAt = new Date(recFilters.dateRange[0]).getTime()
params.endAt = new Date(recFilters.dateRange[1]).getTime() + 86399999
}
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: '', materialCode: '', boxNo: '', dateRange: [] }); searchRec() }
function downloadCSV() {
exportXlsxFetch('/api/outbound/export', {
outboundType: recFilters.outboundType,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim()
}, '出库管理.xlsx')
.then(() => ElMessage.success('导出成功'))
.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-input v-model="queryForm.orderNo" placeholder="输入工单号回车查询" clearable autofocus
style="width:260px" @keyup.enter="queryPrep" />
</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="选择物料" />
</el-form-item>
<el-form-item label="出库类型">
<el-radio-group v-model="genForm.mode">
<el-radio :value="1">结构件批次</el-radio>
<el-radio :value="2">电气件SN</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="genForm.mode === 1">
<el-form-item label="批次号" required>
<el-input v-model="genForm.batchNo" placeholder="结构件批次号" style="width:100%" />
</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%">
<el-option v-for="z in zones" :key="z.code" :value="z.code" :label="`${z.code} ${z.name || ''}`" />
</el-select>
</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 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="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 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="general" />
</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' : 'primary'" size="small">
{{ row.outboundType === 'general' ? '通用' : '备料' }}
</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 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="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 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>