Files
bj_power/bj_power_wms/frontend/src/pages/Outbound.vue
T
2026-09-18 18:54:54 +08:00

522 lines
26 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 } 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 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([])
onMounted(loadOrders)
async function loadOrders() {
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([])
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: '其他' }
]
const OWNERSHIP_OPTIONS = [
{ value: 'none', label: '无' },
{ value: 'workorder', label: '工单号' },
{ value: 'project', label: '科技项目号' },
{ value: 'other', label: '其他' }
]
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条)
const zones = ref([])
const genForm = reactive({
materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '',
boxNo: '', contractNo: '', remark: '',
outboundCategory: 'other', ownershipType: 'none', 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() {
if (zones.value.length) return
// 物料下拉由 MaterialSelect 组件按需远程搜索(最近 100 条),此处只加载区域
const zs = await request.get('/zone/picker')
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
// 区域下拉选项: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()]
})
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 !== 'none' && !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.ownershipType !== 'none' ? genForm.ownershipNo.trim() : undefined,
targetStation: genForm.targetStation.trim() || undefined,
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
}
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: '', outboundCategory: 'other', ownershipType: 'none', ownershipNo: '', targetStation: '' })
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: '', ownershipType: '', 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(),
ownershipType: recFilters.ownershipType,
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() }
}
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-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 }}
</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>
<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-input v-model="genForm.targetStation" placeholder="选填:如 1~12 或 DOCK01" clearable style="width:100%" />
</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 zoneOptions" :key="z.zoneCode" :value="z.zoneCode" :label="`${z.zoneCode} ${z.zoneName}`" />
</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 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="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>
<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' : '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 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>