feat: 完成客户第12条需求+多系统改造

1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
This commit is contained in:
SunYF
2026-09-19 17:04:58 +08:00
parent 62e45b7379
commit 05b0b8a909
89 changed files with 7912 additions and 429 deletions
+7 -2
View File
@@ -16,7 +16,7 @@ export const helpInbound = {
{ name: '数量', source: '手工填写整数。', purpose: '本批到货数量', fill: '整数 ≥1' },
{ name: '区域', source: '下拉选择(必填)。数据来自"区域维护"。', purpose: '放到哪个库区(待检/合格/不合格…)', fill: '必填;新到货一般先放"待检"区,检验合格后再转"合格"区' },
{ name: '货架/层/位', source: '手工填写(选填)。库位四级贯通的三级货位。', purpose: '精确到货位,便于拣配与盘点', fill: '如 货架A / 3层 / 12位;可留空只记区域' },
{ name: '库位自动带入', source: '选物料后前端自动调 /inbound/last-location。', purpose: '回填该物料最近一次入库库位(区域/货架/层/位置号),免手输', fill: '选物料即自动填入;可手动改到其他库位' },
{ name: '库位自动带入', source: '选物料后前端自动调 /inbound/last-location;该物料无历史库位时(新物料)再调 /zone/vacant 推荐同区空位。', purpose: '有历史→回填最近一次入库库位;无历史→按所选区域自动挑一个未被占用的空闲货位,免手输也避免撞位', fill: '选物料即自动填入;切换区域会按新区域重新推荐空位;结果均可手动改' },
{ name: '生产日期', source: '手动选择日期。', purpose: '生产时间,用于时效和批次管理', fill: '选择日期' },
{ name: '供应商', source: '手工填写。', purpose: '来料来源', fill: '供应商名称,可空' },
{ name: '批量导入', source: '本页签下方。', purpose: '整表校验后一次性入库', fill: '模板(首行忽略表头):图号 | 批次号(可空自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注。任一行错误则整体回退' }
@@ -68,9 +68,13 @@ export const helpOutbound = {
{ name: '备料出库-目标工位', source: '下拉选择 DOCK01~DOCK20(选填,可过滤搜索)。', purpose: 'AGV 配送送达的接驳台(toDock 参数)', fill: '选择如 DOCK05;不选默认运到产线入口 DOCK01' },
{ name: '备料出库-一键分配出库', source: '系统自动按 FIFO 分配。数据来自合格区Z02的批次库存。', purpose: '从合格区按先进先出凑够缺口并出库', fill: '点按钮,系统自动算批次并二次确认' },
{ name: '通用出库-物料/类型', source: '下拉选择物料;类型选"结构件(批次)"或"电气件(SN)"。', purpose: '决定走批次扣减还是 SN 扣减', fill: '选物料后选类型;批次填批次号+数量,SN 扫入 SN 列表' },
{ name: '通用出库-区域 / 货架 / 层 / 位置(四级库位)', source: '与入库同口径的逐级联动下拉(选填)。选物料或批次后自动带入:① 该批次在库的实际库位 ② 该物料最近一次入库库位 ③ 都无则按所选区域推荐同区空位。', purpose: '指明从哪个货位取料,免手工挑选,出库留痕到具体货位', fill: '选物料/批次即自动填入四级,可逐级手动改;切换上级会自动清空下级' },
{ name: '通用出库-箱号', source: '手动填写(选填)。', purpose: '整箱发货时填写,装箱信息随出库单记录(无需再单独录入装箱)', fill: '填箱号后,本出库单即带装箱属性,可在"出库记录"按箱号追溯' },
{ name: '通用出库-合同号', source: '手动填写(选填)。', purpose: '关联合同,便于按合同追溯', fill: '可空' },
{ name: '出库记录-导出 CSV', source: '按钮操作。', purpose: '把统一主表当前筛选结果一次导出为 CSV(含箱号/装箱SN)', fill: '点【导出 CSV】下载全部出库记录' }
{ name: '出库类型(备料/补料/通用)', source: '系统按来源自动写入:备料=按日排产自动出库;补料=工位叫料或数量不符补发(优先发);通用=本页手工出库。', purpose: '区分出库来源,仓库先发补料', fill: '只读,可在"出库记录"按类型筛选' },
{ name: '出库状态(已出库/已送达)', source: '系统维护:出库即记「已出库」;叫 AGV 配送到位后自动转「已送达」。工位接料在 MES 备料单上确认,不在本表。', purpose: '跟踪料是否已送到接驳台', fill: '只读,可按状态筛选' },
{ name: '超领原因', source: '出库数量超过工单物料清单需求时必填(损耗/报废/返修)。', purpose: '超领不硬卡但必须留痕,可统计、可预警', fill: 'WMS/MES 出库时按提示填写原因后放行' },
{ name: '出库记录-导出 CSV', source: '按钮操作。', purpose: '把统一主表当前筛选结果一次导出为 CSV(含库位/箱号/装箱SN)', fill: '点【导出 CSV】下载全部出库记录' }
]
}
@@ -141,6 +145,7 @@ export const helpSemi = {
{ name: '物料', source: '下拉选择。来自物料档案(最近100条+模糊搜索)。', purpose: '入库物料(MES 自动同步时以请求传入为准,WMS 补齐物料名称)', fill: '选择图号' },
{ name: '已完成工序', source: 'MES 自动带出(doneProcessCodes,如 [1,3,5]);手工录入填逗号分隔,如 1,3,5。', purpose: '该件已做到哪些工序,重上线时 MES 从下一工序继续', fill: '如 1,3,5,可空' },
{ name: '工单号', source: '自动带出/手工填写。来自工单。', purpose: '来源工单', fill: '输入工单号' },
{ name: '归属', source: '下拉:工单号 / 科技项目号 / 其他(必选,与入库管理同口径,不再有"无")。', purpose: '标记该件归属哪个工单或科技项目', fill: '必选一项;选"其他"可不填编号,选"工单号/科技项目号"须填归属编号' },
{ name: '区域', source: '手动填写(可空)。', purpose: '存放库区', fill: '如 Z05' },
{ name: '扫码录入', source: '扫码枪连续扫码。来自工件 SN 条码。', purpose: '半成品 SN 清单', fill: '扫入 SN 回车追加' }
]
+29 -1
View File
@@ -78,7 +78,11 @@ function zonePositions(zoneCode, shelfNo, layerNo) {
return [...set].sort()
}
// 切换上级库位时清空下级,避免残留无效值(用户诉求:库位逐级联动选择,禁止手填)
function onZoneChange(form) { form.shelfNo = ''; form.layerNo = ''; form.positionNo = '' }
function onZoneChange(form) {
form.shelfNo = ''; form.layerNo = ''; form.positionNo = ''
// 手动切换区域后,按新区域重新推荐空位(物料已选时)
if (form.materialCode) suggestVacant(form)
}
function onShelfChange(form) { form.layerNo = ''; form.positionNo = '' }
function onLayerChange(form) { form.positionNo = '' }
@@ -502,6 +506,7 @@ function locText(row) {
// 后端 /inbound/last-location 取该物料 created_at 最近一条入库记录的库位;未找到则不动。
async function fillLastLocation(form) {
if (!form.materialCode) return
let filled = false
try {
const loc = await request.get('/inbound/last-location', { params: { materialCode: form.materialCode } })
if (loc && loc.zoneCode) {
@@ -509,10 +514,33 @@ async function fillLastLocation(form) {
form.shelfNo = loc.shelfNo || ''
form.layerNo = loc.layerNo || ''
form.positionNo = loc.positionNo || ''
filled = true
}
} catch {
/* 查询失败不影响主流程 */
}
// 无历史库位(新物料):按同区空位自动推荐一个空闲货位
if (!filled) await suggestVacant(form)
}
// 同区空位推荐:物料无历史入库库位时,自动在当前区域(未选区域则取第一个可用区域)内挑一个空闲货位回填。
// 后端 /zone/vacant 只返回未被在库库存占用的位置节点(level=4)。
async function suggestVacant(form, notifyEmpty = false) {
try {
const v = await request.get('/zone/vacant', { params: { zoneCode: form.zoneCode || '', limit: 1 } })
const hit = Array.isArray(v) ? v[0] : null
if (!hit) {
if (notifyEmpty) ElMessage.info('当前区域暂无可推荐空位,请先在「基础数据 → 区域库位」中维护位置节点')
return false
}
form.zoneCode = hit.zoneCode
form.shelfNo = hit.shelfNo || ''
form.layerNo = hit.layerNo || ''
form.positionNo = hit.positionNo || ''
return true
} catch {
return false
}
}
function onOwnershipChange(form) {
// 切换归属类型:清空已填编号(归属类型必选,不再有"无")
+5 -1
View File
@@ -57,8 +57,10 @@ function onTabChange(name) {
if (name === 'return' && !retQueried.value) loadReturn()
}
// 缺口按净领用计算:净领用 = 已出库 − 已退回(退料自动回补缺口,退料后可再次补料)
function gapOf(row) {
return Math.max((row.totalQty || 0) - (row.outQty || 0), 0)
const net = (row.outQty || 0) - (row.returnedQty || 0)
return Math.max((row.totalQty || 0) - net, 0)
}
async function load() {
@@ -159,6 +161,8 @@ function statusView(row) {
<template #default="{ row }">{{ row.availableQty ?? 0 }}</template>
</el-table-column>
<el-table-column prop="outQty" label="已出库" width="90" align="center" />
<el-table-column prop="returnedQty" label="已退回" width="90" align="center" />
<el-table-column prop="consumedQty" label="已消耗" width="90" align="center" />
<el-table-column label="缺口" width="90" align="center">
<template #default="{ row }">{{ gapOf(row) }}</template>
</el-table-column>
+112 -8
View File
@@ -1,6 +1,6 @@
<script setup>
defineOptions({ name: 'Outbound' })
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { getRealName } from '../utils/auth'
@@ -118,6 +118,7 @@ const OWNERSHIP_OPTIONS = [
const zones = ref([])
const genForm = reactive({
materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '',
shelfNo: '', layerNo: '', positionNo: '',
boxNo: '', contractNo: '', remark: '',
outboundCategory: 'other', ownershipType: 'none', ownershipNo: '', targetStation: ''
})
@@ -155,6 +156,73 @@ const zoneOptions = computed(() => {
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('/') : '-'
}
// 出库库位联动(客户诉求 一.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.materialCode, (v) => { if (v) { genForm.batchNo = ''; fillZoneFromHistory() } })
watch(() => genForm.batchNo, (v) => { if (v && genForm.materialCode) fillZoneFromHistory() })
async function loadGenRows() {
loadingGen.value = true
try {
@@ -180,6 +248,9 @@ async function submitGen() {
ownershipNo: genForm.ownershipType !== 'none' ? 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
@@ -198,7 +269,7 @@ async function submitGen() {
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: '' })
Object.assign(genForm, { materialCode: '', mode: 1, batchNo: '', qty: 1, zoneCode: '', shelfNo: '', layerNo: '', positionNo: '', boxNo: '', contractNo: '', remark: '', outboundCategory: 'other', ownershipType: 'none', ownershipNo: '', targetStation: '' })
snInput.value = ''
snList.value = []
genPage.current = 1
@@ -208,7 +279,7 @@ async function submitGen() {
}
/* ===================== 出库记录(统一主表,全部 + 导出) ===================== */
const recFilters = reactive({ outboundType: '', materialCode: '', boxNo: '', ownershipType: '', dateRange: [] })
const recFilters = reactive({ outboundType: '', status: '', materialCode: '', boxNo: '', ownershipType: '', dateRange: [] })
const recRows = ref([])
const recTotal = ref(0)
const loadingRec = ref(false)
@@ -221,6 +292,7 @@ async function loadRecords() {
try {
const params = {
outboundType: recFilters.outboundType,
status: recFilters.status,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim(),
ownershipType: recFilters.ownershipType,
@@ -236,11 +308,12 @@ async function loadRecords() {
} finally { loadingRec.value = false }
}
function searchRec() { recPage.current = 1; loadRecords() }
function resetRec() { Object.assign(recFilters, { outboundType: '', materialCode: '', boxNo: '', dateRange: [] }); searchRec() }
function resetRec() { Object.assign(recFilters, { outboundType: '', status: '', materialCode: '', boxNo: '', dateRange: [] }); searchRec() }
function downloadCSV() {
exportXlsxFetch('/api/outbound/export', {
outboundType: recFilters.outboundType,
status: recFilters.status,
materialCode: recFilters.materialCode.trim(),
boxNo: recFilters.boxNo.trim()
}, '出库管理.xlsx')
@@ -374,11 +447,24 @@ onMounted(loadDicts)
</el-form-item>
</template>
<el-form-item label="出库区域">
<el-select v-model="genForm.zoneCode" filterable clearable placeholder="可选" style="width:100%">
<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>
@@ -401,6 +487,9 @@ onMounted(loadDicts)
<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>
@@ -440,9 +529,16 @@ onMounted(loadDicts)
<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>
@@ -466,11 +562,16 @@ onMounted(loadDicts)
<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 :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>
@@ -478,6 +579,9 @@ onMounted(loadDicts)
<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>
+17 -7
View File
@@ -26,7 +26,8 @@ const todayStr = new Date().toLocaleDateString('sv-SE')
function newInForm() {
return {
materialCode: '', zoneCode: '', orderNo: '', completedProcess: '',
ownershipType: 'workorder', ownershipNo: '',
// 归属:与入库管理同口径——必选(无「无」选项),选其他可不填编号,工单号/科技项目号必填编号
ownershipType: '', ownershipNo: '',
productStatus: '', relatedStandard: '', testRecordNo: '',
recordedBy: operator, recordedAt: todayStr
}
@@ -64,6 +65,15 @@ async function submitInbound(cat) {
ElMessage.warning('成品入库请选择产品状态(已通过/部分通过/返修)')
return
}
// 归属必选(与入库管理同口径,不再有「无」);选其他可不填编号,工单号/科技项目号必须填编号
if (!f.ownershipType) {
ElMessage.warning('请选择归属(工单号 / 科技项目号 / 其他)')
return
}
if ((f.ownershipType === 'workorder' || f.ownershipType === 'project') && !f.ownershipNo.trim()) {
ElMessage.warning('请填写归属编号(工单号 / 科技项目号)')
return
}
inbounding[cat] = true
let ok = 0
try {
@@ -209,12 +219,12 @@ onMounted(() => {
</el-form-item>
<el-form-item label="归属">
<div style="display:flex;gap:8px;width:100%">
<el-select v-model="inForms[2].ownershipType" style="width:150px">
<el-select v-model="inForms[2].ownershipType" placeholder="请选择" style="width:150px">
<el-option value="workorder" label="工单号" />
<el-option value="project" label="科技项目号" />
<el-option value="none" label="" />
<el-option value="other" label="其他" />
</el-select>
<el-input v-model="inForms[2].ownershipNo" placeholder="归属编号(可空)" clearable />
<el-input v-model="inForms[2].ownershipNo" placeholder="归属编号" clearable />
</div>
</el-form-item>
<el-form-item label="相关标准">
@@ -276,12 +286,12 @@ onMounted(() => {
</el-form-item>
<el-form-item label="归属">
<div style="display:flex;gap:8px;width:100%">
<el-select v-model="inForms[3].ownershipType" style="width:150px">
<el-select v-model="inForms[3].ownershipType" placeholder="请选择" style="width:150px">
<el-option value="workorder" label="工单号" />
<el-option value="project" label="科技项目号" />
<el-option value="none" label="" />
<el-option value="other" label="其他" />
</el-select>
<el-input v-model="inForms[3].ownershipNo" placeholder="归属编号(可空)" clearable />
<el-input v-model="inForms[3].ownershipNo" placeholder="归属编号" clearable />
</div>
</el-form-item>
<el-form-item label="相关标准">