feat: add dense lane outbound workflow

This commit is contained in:
zgw
2026-07-16 10:54:21 +08:00
parent dd82132205
commit 4641bddcea
15 changed files with 1481 additions and 1 deletions

View File

@@ -0,0 +1,553 @@
<template>
<div class="app-container dense-lane-outbound">
<el-form
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
v-show="showSearch"
label-width="76px"
>
<el-form-item label="仓库">
<el-input value="03仓密集库" disabled class="warehouse-input" />
</el-form-item>
<el-form-item label="通道编码" prop="groupCoding">
<el-input
v-model="queryParams.groupCoding"
placeholder="请输入通道组编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="层" prop="theFloor">
<el-input-number
v-model="queryParams.theFloor"
:min="1"
:precision="0"
controls-position="right"
placeholder="全部"
class="floor-input"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-alert
title="仅显示03仓有托盘的密集通道;生成时严格按当前端头顺序选择整托。"
type="info"
:closable="false"
show-icon
class="lane-hint"
/>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table
v-loading="loading"
:data="laneList"
row-key="laneKey"
border
stripe
@expand-change="handleExpandChange"
>
<el-table-column type="expand" width="44">
<template slot-scope="scope">
<div class="inventory-expand" v-loading="scope.row.inventoryLoading">
<div class="inventory-heading">
<span>当前最外侧托盘库存</span>
<span class="inventory-subtitle">
{{ scope.row.outermostTrayCode || '-' }} / {{ scope.row.outermostCargoSpaceId || '-' }}
</span>
</div>
<el-table :data="scope.row.inventoryDetails || []" size="mini" border max-height="260">
<el-table-column label="项目号" prop="projectNo" min-width="120" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="130" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="150" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="110" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="110" align="right" />
<el-table-column label="入库日期" prop="inDate" width="160" />
<template slot="empty">
<div class="empty-detail">{{ scope.row.inventoryLoaded ? '当前端头托盘无库存明细' : '正在读取库存明细' }}</div>
</template>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="通道组编码" prop="groupCoding" min-width="140" fixed="left" show-overflow-tooltip />
<el-table-column label="货区" prop="cargoArea" width="90" align="center" />
<el-table-column label="排" prop="thePlatoon" width="72" align="center" />
<el-table-column label="层" prop="theFloor" width="72" align="center" />
<el-table-column label="出库方向" prop="directionType" width="110" align="center">
<template slot-scope="scope">
<el-tag size="mini" effect="plain">{{ directionLabel(scope.row.directionType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="满托盘数" prop="fullTrayCount" width="105" align="right" />
<el-table-column label="可出库托盘" prop="availableTrayCount" width="120" align="right">
<template slot-scope="scope">
<span :class="{ 'available-count': Number(scope.row.availableTrayCount) > 0 }">
{{ scope.row.availableTrayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="总库存" prop="totalQuantity" min-width="110" align="right" />
<el-table-column label="当前最外侧托盘" prop="outermostTrayCode" min-width="145" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.outermostTrayCode || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="端头货位" prop="outermostCargoSpaceId" min-width="130" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-s-promotion"
:disabled="Number(scope.row.availableTrayCount || 0) < 1"
v-hasPermi="['out:denseLaneOutbound:generate']"
@click="openGenerateDialog(scope.row)"
>整托出库</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="指定通道整托出库"
:visible.sync="dialogVisible"
width="920px"
append-to-body
:close-on-click-modal="false"
@closed="resetDialog"
>
<el-descriptions v-if="currentLane" :column="4" size="small" border class="lane-summary">
<el-descriptions-item label="通道">{{ currentLane.groupCoding }}</el-descriptions-item>
<el-descriptions-item label="货区 / 排">{{ currentLane.cargoArea || '-' }} / {{ currentLane.thePlatoon || '-' }}</el-descriptions-item>
<el-descriptions-item label="层">{{ currentLane.theFloor }}</el-descriptions-item>
<el-descriptions-item label="可出库">{{ currentLane.availableTrayCount || 0 }} 托</el-descriptions-item>
</el-descriptions>
<el-form :model="generateForm" size="small" :inline="true" label-width="86px" class="generate-form">
<el-form-item label="整托数量" required>
<el-input-number
v-model="generateForm.trayCount"
:min="1"
:max="currentLane ? Number(currentLane.availableTrayCount || 1) : 1"
:precision="0"
controls-position="right"
@change="handleTrayCountChange"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
icon="el-icon-view"
:loading="previewLoading"
v-hasPermi="['out:denseLaneOutbound:preview']"
@click="loadPreview"
>刷新预览</el-button>
</el-form-item>
<el-form-item label="出库口" required class="exit-form-item">
<el-select
v-model="generateForm.exitId"
placeholder="请选择与托盘类型匹配的出库口"
filterable
clearable
:loading="exitLoading"
class="exit-select"
>
<el-option
v-for="point in compatibleExitOptions"
:key="point.pointId"
:label="formatExitLabel(point)"
:value="point.pointId"
/>
</el-select>
</el-form-item>
</el-form>
<div class="preview-header">
<div>
<span class="preview-title">本次准确出库托盘</span>
<span class="preview-note">提交前会再次校验端头顺序和库存</span>
</div>
<div class="preview-total">合计库存:<strong>{{ previewData.totalQuantity || 0 }}</strong></div>
</div>
<el-table
v-loading="previewLoading"
:data="previewData.trays || []"
row-key="cargoSpaceId"
border
max-height="390"
>
<el-table-column type="expand" width="42">
<template slot-scope="scope">
<el-table :data="scope.row.stockDetails || []" size="mini" border class="preview-detail-table">
<el-table-column label="项目号" prop="projectNo" min-width="110" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="120" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="100" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="105" align="right" />
</el-table>
</template>
</el-table-column>
<el-table-column label="顺序" type="index" width="62" align="center" />
<el-table-column label="托盘号" prop="trayCode" min-width="140" show-overflow-tooltip />
<el-table-column label="货位编码" prop="cargoSpaceId" min-width="140" show-overflow-tooltip />
<el-table-column label="货位名称" prop="cargoSpaceName" min-width="140" show-overflow-tooltip />
<el-table-column label="列" prop="theColumn" width="72" align="center" />
<el-table-column label="库存数量" prop="stockQuantity" width="115" align="right" />
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="generating"
:disabled="!canGenerate"
@click="confirmGenerate"
>确认生成 {{ generateForm.trayCount || 0 }} 托出库作业</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listDenseLaneOutbound,
previewDenseLaneOutbound,
generateDenseLaneOutbound
} from '@/api/out/denseLaneOutbound'
import { listStationPoint } from '@/api/bas/stationpoint'
const emptyPreview = () => ({ trays: [], totalQuantity: 0, previewToken: null })
export default {
name: 'DenseLaneOutbound',
data() {
return {
loading: false,
showSearch: true,
laneList: [],
queryParams: {
warehouseId: '03',
groupCoding: null,
theFloor: null
},
dialogVisible: false,
currentLane: null,
previewLoading: false,
generating: false,
exitLoading: false,
exitOptions: [],
previewData: emptyPreview(),
generateForm: {
warehouseId: '03',
groupCoding: null,
theFloor: null,
trayCount: 1,
exitId: null,
previewToken: null
}
}
},
computed: {
compatibleExitOptions() {
const trayTypes = Array.from(new Set((this.previewData.trays || [])
.map(tray => this.trayType(tray.trayCode))
.filter(Boolean)))
return this.exitOptions.filter(point => trayTypes.every(type => this.pointSupportsTrayType(point, type)))
},
canGenerate() {
return Boolean(
this.previewData.previewToken &&
this.generateForm.exitId &&
(this.previewData.trays || []).length === Number(this.generateForm.trayCount)
)
}
},
created() {
this.getList()
},
methods: {
laneKey(row) {
return `${row.groupCoding}-${row.theFloor}`
},
getList() {
this.loading = true
listDenseLaneOutbound(this.queryParams).then(response => {
this.laneList = (response.data || []).map(row => ({
...row,
inventoryLoading: false,
inventoryLoaded: false,
inventoryDetails: []
}))
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.queryParams.warehouseId = '03'
this.getList()
},
handleExpandChange(row, expandedRows) {
const expanded = expandedRows.some(item => this.laneKey(item) === this.laneKey(row))
if (!expanded || row.inventoryLoaded || row.inventoryLoading || !row.outermostTrayCode) {
return
}
this.$set(row, 'inventoryLoading', true)
previewDenseLaneOutbound(this.previewParams(row, 1)).then(response => {
const tray = ((response.data || {}).trays || [])[0]
this.$set(row, 'inventoryDetails', tray ? (tray.stockDetails || []) : [])
this.$set(row, 'inventoryLoaded', true)
this.$set(row, 'inventoryLoading', false)
}).catch(() => {
this.$set(row, 'inventoryLoading', false)
})
},
openGenerateDialog(row) {
if (Number(row.availableTrayCount || 0) < 1) {
this.$modal.msgWarning('当前通道没有可出库的端头托盘')
return
}
this.currentLane = row
this.generateForm = {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: 1,
exitId: null,
previewToken: null
}
this.previewData = emptyPreview()
this.dialogVisible = true
this.loadExitOptions()
this.loadPreview()
},
resetDialog() {
this.currentLane = null
this.previewData = emptyPreview()
this.exitOptions = []
this.generateForm.previewToken = null
},
handleTrayCountChange() {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.generateForm.exitId = null
},
previewParams(row, trayCount) {
return {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: trayCount
}
},
loadPreview() {
if (!this.currentLane || !this.generateForm.trayCount) {
return
}
this.previewLoading = true
this.generateForm.exitId = null
previewDenseLaneOutbound(this.previewParams(this.currentLane, this.generateForm.trayCount)).then(response => {
this.previewData = response.data || emptyPreview()
this.generateForm.previewToken = this.previewData.previewToken
this.selectOnlyCompatibleExit()
this.previewLoading = false
}).catch(() => {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.previewLoading = false
})
},
loadExitOptions() {
this.exitLoading = true
listStationPoint({ warehouseId: '03', status: '0' }).then(response => {
const rows = response.rows || response.data || []
this.exitOptions = rows.filter(point => ['2', '3'].includes(String(point.pointType)) &&
String(point.pointStatus || '0') === '0')
this.selectOnlyCompatibleExit()
this.exitLoading = false
}).catch(() => {
this.exitOptions = []
this.exitLoading = false
})
},
selectOnlyCompatibleExit() {
this.$nextTick(() => {
if (this.compatibleExitOptions.length === 1) {
this.generateForm.exitId = this.compatibleExitOptions[0].pointId
}
})
},
trayType(trayCode) {
return trayCode ? String(trayCode).trim().slice(0, 1).toUpperCase() : null
},
pointSupportsTrayType(point, trayType) {
if (!trayType || !point.trayType) {
return true
}
return String(point.trayType).split(',').map(item => item.trim().toUpperCase()).includes(trayType)
},
formatExitLabel(point) {
const typeName = String(point.pointType) === '3' ? '入出库' : '出库'
const trayType = point.trayType ? `,${point.trayType}类托盘` : ''
return `${point.pointName || point.pointId}(${point.pointId},${typeName}${trayType})`
},
directionLabel(directionType) {
if (String(directionType) === '1') return '大列号端'
if (String(directionType) === '2') return '小列号端'
if (String(directionType) === '3') return '最近端头'
return '-'
},
confirmGenerate() {
if (!this.canGenerate) {
this.$modal.msgWarning('请先刷新预览并选择匹配的出库口')
return
}
const trays = (this.previewData.trays || []).map(item => item.trayCode).join('、')
this.$modal.confirm(`确认按当前端头顺序生成 ${this.generateForm.trayCount} 托出库作业?托盘:${trays}`).then(() => {
this.generating = true
return generateDenseLaneOutbound({
...this.generateForm,
previewToken: this.previewData.previewToken
})
}).then(response => {
const ids = response.data || []
this.$modal.msgSuccess(`已生成 ${ids.length} 条出库作业`)
this.generating = false
this.dialogVisible = false
this.getList()
}).catch(error => {
this.generating = false
if (error && error.message && error.message.includes('预览已失效')) {
this.$modal.msgWarning('端头托盘或库存已变化,已为你刷新预览,请重新确认')
this.loadPreview()
}
})
}
}
}
</script>
<style scoped>
.dense-lane-outbound .warehouse-input {
width: 150px;
}
.dense-lane-outbound .floor-input {
width: 128px;
}
.lane-hint {
width: auto;
padding: 5px 12px;
}
.available-count {
color: #1890ff;
font-weight: 600;
}
.inventory-expand {
padding: 8px 18px 14px 58px;
background: #f8fafc;
}
.inventory-heading,
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.inventory-subtitle,
.preview-note {
margin-left: 10px;
color: #909399;
font-size: 12px;
font-weight: 400;
}
.empty-detail {
color: #909399;
}
.lane-summary {
margin-bottom: 16px;
}
.generate-form {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 12px 0;
margin-bottom: 14px;
border: 1px solid #ebeef5;
background: #f8fafc;
}
.exit-form-item {
flex: 1;
min-width: 360px;
}
.exit-select {
width: 100%;
}
.preview-title {
color: #303133;
}
.preview-total {
color: #606266;
font-weight: 400;
}
.preview-total strong {
color: #1890ff;
font-size: 16px;
}
.preview-detail-table {
width: calc(100% - 48px);
margin: 4px 18px 10px 48px;
}
@media (max-width: 768px) {
.inventory-expand {
padding-left: 10px;
}
.generate-form {
display: block;
}
.exit-form-item {
min-width: 100%;
}
.preview-header {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
}
</style>