功能:新增托盘业务操作页面

This commit is contained in:
焦龙言
2026-07-27 08:31:31 +08:00
parent e5006c6e43
commit d5da207206
6 changed files with 3586 additions and 2 deletions
@@ -0,0 +1,461 @@
<template>
<div class="app-container">
<el-form ref="queryRef" :model="queryParams" :inline="true">
<el-form-item label="装填单号" prop="fillingNo">
<el-input v-model="queryParams.fillingNo" placeholder="请输入装填单号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input v-model="queryParams.batchNo" placeholder="请输入生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:filling:list']">搜索</el-button>
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:filling:list']">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-if="canAddFilling"
class="business-action"
type="primary"
plain
icon="Plus"
:disabled="operationPending"
@click="handleOpenFilling"
v-hasPermi="['business:filling:add']"
>托盘装填</el-button>
</el-col>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" empty-text="暂无数据" :data="fillingList">
<el-table-column label="装填单号" prop="fillingNo" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="托盘" min-width="120">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="仓库ID" prop="warehouseId" width="110" />
<el-table-column label="菌种" min-width="160">
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
</el-table-column>
<el-table-column label="生产批次" prop="batchNo" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="包装规格" prop="packageSpec" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="初始包数" prop="initialPackages" width="100" align="right" />
<el-table-column label="操作时间" prop="operatedAt" width="180">
<template #default="scope">{{ parseTime(scope.row.operatedAt) }}</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog
v-model="open"
title="托盘装填"
width="min(680px, 94vw)"
append-to-body
:close-on-click-modal="!operationPending"
:close-on-press-escape="!operationPending"
:show-close="!operationPending"
:before-close="handleDialogBeforeClose"
>
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="fillingRef" :model="form" :rules="rules" label-width="96px">
<el-form-item label="扫描托盘">
<pallet-scan-input
:disabled="operationPending || optionLoading"
:loading="scanLoading"
@scan="handleFillingScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
</el-form-item>
<el-descriptions v-if="scannedPallet" :column="2" border class="scan-summary">
<el-descriptions-item label="托盘">{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</el-descriptions-item>
<el-descriptions-item label="仓库">#{{ scannedPallet.warehouseId }}</el-descriptions-item>
<el-descriptions-item label="流转状态">{{ scannedPallet.flowState }}</el-descriptions-item>
<el-descriptions-item label="托盘容量">{{ scannedPallet.capacityPackages }} 包</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="请先扫描待装填托盘" :image-size="72" />
<el-form-item label="菌种" prop="strainId">
<el-select
v-model="form.strainId"
class="business-control"
placeholder="请选择菌种"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || optionLoading"
>
<el-option
v-for="option in strainOptions"
:key="option.id"
:label="option.name || `#${option.id}`"
:value="option.id"
/>
</el-select>
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input
v-model="form.batchNo"
class="business-control"
maxlength="64"
show-word-limit
placeholder="请输入生产批次"
:disabled="operationPending"
/>
</el-form-item>
<el-form-item label="包装规格" prop="packageSpec">
<el-input
v-model="form.packageSpec"
class="business-control"
maxlength="64"
show-word-limit
placeholder="请输入包装规格"
:disabled="operationPending"
/>
</el-form-item>
<el-form-item label="初始包数" prop="initialPackages">
<el-input-number
v-model="form.initialPackages"
class="business-control"
:min="1"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button
class="business-action"
type="primary"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet"
@click="submitFilling"
v-hasPermi="['business:filling:add']"
>确认装填</el-button>
<el-button class="business-action" :disabled="operationPending" @click="cancelFilling">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="BusinessFilling">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import { addFilling, getFillingStrainOptions, listFilling, scanFillingPallet } from '@/api/business/filling'
const { proxy } = getCurrentInstance()
const fillingList = ref([])
const strainOptions = ref([])
const scannedPallet = ref(null)
const loading = ref(false)
const optionLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const loadError = ref('')
const optionError = ref('')
const scanError = ref('')
const formError = ref('')
const open = ref(false)
const total = ref(0)
const fillingIdempotencyKey = ref('')
const canAddFilling = computed(() => proxy.$auth.hasPermiAnd(['business:filling:query', 'business:filling:add']))
const operationPending = computed(() => submitLoading.value)
let listGeneration = 0
let optionGeneration = 0
let scanGeneration = 0
let dialogSession = 0
function validateRequiredText(rule, value, callback) {
const labels = { batchNo: '生产批次', packageSpec: '包装规格' }
if (typeof value !== 'string' || value.trim() === '') {
callback(new Error(`${labels[rule.field]}不能为空`))
return
}
callback()
}
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
callback(new Error('初始包数必须为正整数'))
return
}
callback()
}
const data = reactive({
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined },
queryParams: { pageNum: 1, pageSize: 10, fillingNo: undefined, palletCode: undefined, batchNo: undefined },
rules: {
strainId: [{ required: true, message: '请选择菌种', trigger: 'change' }],
batchNo: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
packageSpec: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
initialPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true
loadError.value = ''
listFilling(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
fillingList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
fillingList.value = []
total.value = 0
loadError.value = '装填记录加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false
})
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
}
function loadStrainOptions() {
const optionContext = { generation: ++optionGeneration, dialogSession }
optionLoading.value = true
optionError.value = ''
getFillingStrainOptions().then((response) => {
if (!isCurrentOptionRequest(optionContext)) return
strainOptions.value = Array.isArray(response.data) ? response.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
strainOptions.value = []
optionError.value = '菌种选项加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
}
function invalidateScanRequest() {
scanGeneration += 1
scanLoading.value = false
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration && scanContext.dialogSession === dialogSession
}
function clearFillingIdempotencyKey() {
fillingIdempotencyKey.value = ''
}
function ensureFillingIdempotencyKey() {
if (!fillingIdempotencyKey.value) {
fillingIdempotencyKey.value = createIdempotencyKey()
}
return fillingIdempotencyKey.value
}
function resetFillingOperation() {
dialogSession += 1
invalidateOptionRequest()
invalidateScanRequest()
clearFillingIdempotencyKey()
scannedPallet.value = null
form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined }
optionError.value = ''
scanError.value = ''
formError.value = ''
proxy.resetForm('fillingRef')
}
function handleOpenFilling() {
if (!canAddFilling.value || operationPending.value) return
resetFillingOperation()
open.value = true
loadStrainOptions()
}
function cancelFilling() {
if (operationPending.value) return
clearFillingIdempotencyKey()
open.value = false
resetFillingOperation()
}
function handleDialogBeforeClose(done) {
if (operationPending.value) return
resetFillingOperation()
done()
}
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
function handleFillingScan(qrToken) {
if (operationPending.value || scanLoading.value) return
clearFillingIdempotencyKey()
scannedPallet.value = null
form.value.qrToken = ''
form.value.warehouseId = undefined
const scanContext = { generation: ++scanGeneration, dialogSession, qrToken }
scanLoading.value = true
scanError.value = ''
scanFillingPallet(qrToken).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE') {
scanError.value = '该托盘当前不可装填'
return
}
scannedPallet.value = {
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
warehouseId: pallet.warehouseId,
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages
}
form.value.qrToken = qrToken
form.value.warehouseId = pallet.warehouseId
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
scanError.value = '托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentScanRequest(scanContext)) return
scanLoading.value = false
})
}
function handleScanInvalid(message) {
clearFillingIdempotencyKey()
scannedPallet.value = null
form.value.qrToken = ''
form.value.warehouseId = undefined
scanError.value = message
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function submitFilling() {
if (operationPending.value || scanLoading.value || optionLoading.value) return
submitLoading.value = true
proxy.$refs.fillingRef.validate((valid) => {
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallet.value || !form.value.qrToken || !form.value.warehouseId) {
formError.value = '请先扫描待装填托盘'
submitLoading.value = false
return
}
const idempotencyKey = ensureFillingIdempotencyKey()
formError.value = ''
addFilling({
qrToken: form.value.qrToken,
warehouseId: form.value.warehouseId,
strainId: form.value.strainId,
batchNo: form.value.batchNo.trim(),
packageSpec: form.value.packageSpec.trim(),
initialPackages: form.value.initialPackages,
idempotencyKey: idempotencyKey
}).then((response) => {
clearFillingIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '托盘装填成功')
open.value = false
resetFillingOperation()
getList()
}).catch(() => {
formError.value = '装填提交失败;修正业务输入会生成新幂等键,直接重试将复用原键'
}).finally(() => {
submitLoading.value = false
})
})
}
watch(
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.packageSpec, form.value.initialPackages],
() => {
if (!submitLoading.value) clearFillingIdempotencyKey()
}
)
getList();
</script>
<style lang="scss" scoped>
.scan-summary {
margin: 0 0 18px 96px;
}
@media (max-width: 767px) {
:deep(.el-dialog) {
width: 94vw !important;
}
.scan-summary {
margin-left: 0;
}
:deep(.business-control .el-input__wrapper),
:deep(.business-action) {
min-height: 48px;
}
:deep(.el-form-item__label) {
float: none;
display: block;
width: 100% !important;
text-align: left;
}
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
</style>
@@ -0,0 +1,646 @@
<template>
<div class="app-container">
<el-form ref="queryRef" :model="queryParams" :inline="true">
<el-form-item label="入库单号" prop="inboundNo">
<el-input
v-model="queryParams.inboundNo"
placeholder="请输入入库单号"
clearable
maxlength="64"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="仓库ID" prop="warehouseId">
<el-input-number v-model="queryParams.warehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
</el-form-item>
<el-form-item label="单据状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<el-option label="草稿" value="DRAFT" />
<el-option label="已确认" value="CONFIRMED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:inbound:list']">搜索</el-button>
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:inbound:list']">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-if="canAddInbound"
class="business-action"
type="primary"
plain
icon="Plus"
:disabled="operationPending || detailLoading"
@click="handleAdd"
v-hasPermi="['business:inbound:add']"
>新增入库草稿</el-button>
</el-col>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" empty-text="暂无数据" :data="inboundList">
<el-table-column label="入库单号" prop="inboundNo" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="仓库" min-width="120">
<template #default="scope">{{ optionLabel(warehouseOptions, scope.row.warehouseId) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" width="180">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="确认时间" prop="confirmedAt" width="180">
<template #default="scope">{{ parseTime(scope.row.confirmedAt) || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
v-if="scope.row.status === 'DRAFT' && canEditInbound"
link
type="primary"
icon="Edit"
:disabled="operationPending || detailLoading"
@click="handleUpdate(scope.row)"
v-hasPermi="['business:inbound:edit']"
>编辑</el-button>
<el-button
v-if="scope.row.status === 'DRAFT'"
link
type="danger"
icon="Delete"
:disabled="operationPending"
@click="handleDelete(scope.row)"
v-hasPermi="['business:inbound:remove']"
>删除</el-button>
<el-button
v-if="scope.row.status === 'DRAFT' && canConfirmInbound"
link
type="success"
icon="CircleCheck"
:disabled="operationPending"
@click="handleConfirm(scope.row)"
v-hasPermi="['business:inbound:confirm']"
>确认</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog
v-model="open"
:title="title"
width="min(760px, 94vw)"
append-to-body
:close-on-click-modal="!operationPending"
:close-on-press-escape="!operationPending"
:show-close="!operationPending"
:before-close="handleDialogBeforeClose"
>
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="inboundRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="入库仓库" prop="warehouseId">
<el-select
v-model="form.warehouseId"
class="business-control"
placeholder="请选择授权仓库"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
>
<el-option
v-for="option in warehouseOptions"
:key="option.id"
:label="optionLabel(warehouseOptions, option.id)"
:value="option.id"
/>
</el-select>
</el-form-item>
<el-form-item label="连续扫码">
<pallet-scan-input
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady) || !form.warehouseId"
:loading="scanLoading"
@scan="handleInboundScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<div class="form-tip">仅接收尚未首次入库(UNSTOCKED)的托盘,同一托盘只保留一条。</div>
</el-form-item>
</el-form>
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
<el-table :data="scannedPallets" empty-text="尚未扫描托盘" size="small" max-height="320">
<el-table-column label="托盘" min-width="180">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="托盘ID" prop="palletId" width="110" />
<el-table-column label="流转状态" prop="flowState" min-width="140">
<template #default="scope">{{ scope.row.flowState || '详情未提供' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="scope">
<el-button
class="business-action"
link
type="danger"
:disabled="operationPending || detailLoading || (isEdit && !editDetailReady)"
@click="removeScannedPallet(scope.row.palletId)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="dialog-footer">
<el-button
v-if="!isEdit"
class="business-action"
type="primary"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || detailLoading"
@click="submitInbound"
v-hasPermi="['business:inbound:add']"
>保存草稿</el-button>
<el-button
v-else
class="business-action"
type="primary"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || detailLoading || !editDetailReady"
@click="saveInboundUpdate"
v-hasPermi="['business:inbound:edit']"
>保存修改</el-button>
<el-button
class="business-action"
:disabled="operationPending"
@click="cancelInbound"
>取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="BusinessInbound">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import {
addInbound,
confirmInbound,
delInbound,
getInbound,
getInboundWarehouseOptions,
listInbound,
scanInboundPallet,
updateInbound
} from '@/api/business/inbound'
const { proxy } = getCurrentInstance()
const inboundList = ref([])
const warehouseOptions = ref([])
const scannedPallets = ref([])
const loading = ref(false)
const optionLoading = ref(false)
const detailLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const deleteLoading = ref(false)
const confirmLoading = ref(false)
const loadError = ref('')
const optionError = ref('')
const scanError = ref('')
const formError = ref('')
const open = ref(false)
const title = ref('')
const total = ref(0)
const editDetailReady = ref(false)
const isEdit = computed(() => Boolean(form.value.inboundId))
const canAddInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:add']))
const canEditInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:edit']))
const canConfirmInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:confirm']))
const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value)
let listGeneration = 0
let optionGeneration = 0
let detailGeneration = 0
let scanGeneration = 0
let dialogSession = 0
let hydratingForm = false
const confirmIdempotency = reactive({ inboundId: undefined, key: '' })
const data = reactive({
form: { inboundId: undefined, warehouseId: undefined },
queryParams: { pageNum: 1, pageSize: 10, inboundNo: undefined, warehouseId: undefined, status: undefined },
rules: {
warehouseId: [{ required: true, message: '请选择入库仓库', trigger: 'change' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function optionLabel(options, id) {
const option = options.find((item) => item.id === id)
return option?.name || (id ? `#${id}` : '—')
}
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true
loadError.value = ''
listInbound(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
inboundList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
inboundList.value = []
total.value = 0
loadError.value = '入库单列表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false
})
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
}
function loadWarehouseOptions() {
const optionContext = { generation: ++optionGeneration, dialogSession }
optionLoading.value = true
optionError.value = ''
getInboundWarehouseOptions().then((response) => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = Array.isArray(response.data) ? response.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = []
optionError.value = '授权仓库加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
}
function invalidateDetailRequest() {
detailGeneration += 1
detailLoading.value = false
}
function isCurrentDetailRequest(detailContext) {
return detailContext.generation === detailGeneration && detailContext.dialogSession === dialogSession
}
function invalidateScanRequest() {
scanGeneration += 1
scanLoading.value = false
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration &&
scanContext.dialogSession === dialogSession &&
scanContext.warehouseId === form.value.warehouseId
}
function resetInboundForm() {
dialogSession += 1
invalidateOptionRequest()
invalidateDetailRequest()
invalidateScanRequest()
formError.value = ''
optionError.value = ''
scanError.value = ''
editDetailReady.value = false
form.value = { inboundId: undefined, warehouseId: undefined }
scannedPallets.value = []
proxy.resetForm('inboundRef')
}
function cancelInbound() {
if (operationPending.value) return
open.value = false
resetInboundForm()
}
function handleDialogBeforeClose(done) {
if (operationPending.value) return
resetInboundForm()
done()
}
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
function handleAdd() {
if (!canAddInbound.value || operationPending.value) return
resetInboundForm()
title.value = '新增首次入库草稿'
open.value = true
loadWarehouseOptions()
}
function handleUpdate(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿入库单可以编辑')
return
}
if (!canEditInbound.value || operationPending.value) return
resetInboundForm()
form.value.inboundId = row.inboundId
editDetailReady.value = false
title.value = '编辑首次入库草稿'
open.value = true
loadWarehouseOptions()
const detailContext = {
generation: ++detailGeneration,
dialogSession,
inboundId: row.inboundId
}
detailLoading.value = true
getInbound(detailContext.inboundId).then((response) => {
if (!isCurrentDetailRequest(detailContext)) return
const detail = response.data;
if (detail.status !== 'DRAFT') {
formError.value = '该入库单已不是草稿状态,不能编辑'
return
}
hydratingForm = true
form.value = { inboundId: detailContext.inboundId, warehouseId: detail.warehouseId }
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
flowState: item.flowState || ''
}))
hydratingForm = false
editDetailReady.value = true
}).catch(() => {
if (!isCurrentDetailRequest(detailContext)) return
formError.value = '入库单详情加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentDetailRequest(detailContext)) return
detailLoading.value = false
})
}
function handleInboundScan(qrToken) {
if (isEdit.value && !editDetailReady.value) {
scanError.value = '入库单详情尚未就绪,不能扫码'
return
}
if (operationPending.value || scanLoading.value || !form.value.warehouseId) {
scanError.value = '请先选择入库仓库'
return
}
const scanContext = {
generation: ++scanGeneration,
dialogSession,
warehouseId: form.value.warehouseId,
qrToken
}
scanLoading.value = true
scanError.value = ''
scanInboundPallet(qrToken, {
warehouseId: scanContext.warehouseId
}).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
if (pallet.flowState !== 'UNSTOCKED') {
scanError.value = '该托盘不是未入库状态,不能加入入库草稿'
return
}
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msgWarning('该托盘已在当前明细中')
return
}
scannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
flowState: pallet.flowState
})
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
scanError.value = '托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentScanRequest(scanContext)) return
scanLoading.value = false
})
}
function handleScanInvalid(message) {
scanError.value = message
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function removeScannedPallet(palletId) {
if (operationPending.value || (isEdit.value && !editDetailReady.value)) return
scannedPallets.value = scannedPallets.value.filter((item) => item.palletId !== palletId)
}
function addInboundDraft() {
return addInbound({
warehouseId: form.value.warehouseId,
palletIds: scannedPallets.value.map((item) => item.palletId)
})
}
function updateInboundDraft() {
return updateInbound({
inboundId: form.value.inboundId,
warehouseId: form.value.warehouseId,
palletIds: scannedPallets.value.map((item) => item.palletId)
})
}
function submitInboundForm(editing) {
if (editing !== isEdit.value || (editing && !editDetailReady.value)) {
formError.value = '入库单编辑详情尚未就绪,不能提交'
return
}
if (operationPending.value || scanLoading.value || optionLoading.value || detailLoading.value) return
submitLoading.value = true
proxy.$refs.inboundRef.validate((valid) => {
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallets.value.length) {
formError.value = '请至少扫描一个托盘'
submitLoading.value = false
return
}
formError.value = ''
const submitPromise = editing ? updateInboundDraft() : addInboundDraft()
submitPromise.then((response) => {
proxy.$modal.msgSuccess(response.msg || '入库草稿保存成功')
open.value = false
resetInboundForm()
getList()
}).catch(() => {
formError.value = editing ? '入库草稿修改失败,请根据提示修正后重试' : '入库草稿新增失败,请根据提示修正后重试'
}).finally(() => {
submitLoading.value = false
})
})
}
function submitInbound() {
submitInboundForm(false)
}
function saveInboundUpdate() {
submitInboundForm(true)
}
function handleDelete(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿入库单可以删除')
return
}
if (operationPending.value) return
deleteLoading.value = true
proxy.$modal.confirm(`是否确认删除入库单“${row.inboundNo || `#${row.inboundId}`}”?`).then(() => {
return delInbound(String(row.inboundId))
}).then((response) => {
proxy.$modal.msgSuccess(response.msg || '删除成功')
getList()
}).catch((error) => {
if (error !== 'cancel' && error !== 'close') {
loadError.value = '入库单删除失败,请稍后重试'
}
}).finally(() => {
deleteLoading.value = false
})
}
function clearConfirmIdempotency() {
confirmIdempotency.inboundId = undefined
confirmIdempotency.key = ''
}
function ensureConfirmIdempotencyKey(inboundId) {
if (confirmIdempotency.inboundId !== inboundId) {
clearConfirmIdempotency()
confirmIdempotency.inboundId = inboundId
}
if (!confirmIdempotency.key) {
confirmIdempotency.key = createIdempotencyKey()
}
return confirmIdempotency.key
}
function handleConfirmCancellation(error) {
if (error === 'cancel' || error === 'close') {
clearConfirmIdempotency()
return true
}
return false
}
function handleConfirm(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿入库单可以确认')
return
}
if (operationPending.value) return
confirmLoading.value = true
proxy.$modal.confirm(`确认后入库单“${row.inboundNo || `#${row.inboundId}`}”将不可编辑或删除,是否继续?`).then(() => {
const idempotencyKey = ensureConfirmIdempotencyKey(row.inboundId)
return confirmInbound(row.inboundId, {
idempotencyKey: idempotencyKey
})
}).then((response) => {
clearConfirmIdempotency()
proxy.$modal.msgSuccess(response.msg || '入库确认成功')
getList()
}).catch((error) => {
if (error === 'cancel' || error === 'close') {
handleConfirmCancellation(error)
return
}
loadError.value = '入库确认失败;再次确认会复用本次幂等键'
}).finally(() => {
confirmLoading.value = false
})
}
watch(
() => form.value.warehouseId,
(warehouseId, previousWarehouseId) => {
if (hydratingForm || warehouseId === previousWarehouseId) return
invalidateScanRequest()
scannedPallets.value = []
scanError.value = ''
},
{ flush: 'sync' }
)
getList();
</script>
<style lang="scss" scoped>
.form-tip {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 767px) {
:deep(.el-dialog) {
width: 94vw !important;
}
:deep(.el-form-item__label) {
float: none;
display: block;
width: 100% !important;
text-align: left;
}
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
</style>
@@ -0,0 +1,735 @@
<template>
<div class="app-container return-page">
<el-row v-if="canRequestReturn || canConfirmReturn" :gutter="18" class="return-operations">
<el-col v-if="canRequestReturn" :xs="24" :lg="12">
<el-card shadow="never" class="operation-card">
<template #header><strong>客户发起退回</strong></template>
<el-alert v-if="requestError" :title="requestError" type="error" :closable="false" show-icon class="mb8" />
<el-form label-position="top">
<el-form-item label="目标仓库">
<el-select
v-model="requestForm.targetWarehouseId"
class="business-control"
placeholder="请选择退回目标仓库"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || optionLoading"
@visible-change="handleWarehouseOptionsVisible"
>
<el-option v-for="option in warehouseOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
</el-form-item>
<el-form-item label="连续扫描同一现场托盘">
<pallet-scan-input
:disabled="operationPending || optionLoading || !requestForm.targetWarehouseId"
:loading="scanLoading.request"
@scan="handleRequestScan"
@invalid="handleRequestScanInvalid"
@duplicate="handleScanDuplicate"
/>
</el-form-item>
</el-form>
<el-descriptions :column="2" border class="mb8">
<el-descriptions-item label="来源现场">{{ requestForm.sourceSiteId ? `#${requestForm.sourceSiteId}` : '等待首个扫码' }}</el-descriptions-item>
<el-descriptions-item label="已扫托盘">{{ requestScannedPallets.length }} 个</el-descriptions-item>
</el-descriptions>
<el-table :data="requestScannedPallets" empty-text="尚未扫描退回托盘" size="small" max-height="250">
<el-table-column label="托盘" min-width="150">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="当前余量" prop="remainingPackages" width="100" align="right" />
<el-table-column label="操作" width="80" align="center">
<template #default="scope">
<el-button
class="business-action"
link
type="danger"
:disabled="operationPending"
@click="removeRequestPallet(scope.row.palletId)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<div class="operation-actions">
<el-button
class="business-action"
type="primary"
:loading="requestSubmitLoading"
:disabled="operationPending || scanLoading.request || optionLoading || !requestScannedPallets.length"
@click="submitReturnRequest"
v-hasPermi="['business:return:request']"
>提交退回申请</el-button>
<el-button class="business-action" :disabled="operationPending" @click="resetReturnRequest">清空</el-button>
</div>
</el-card>
</el-col>
<el-col v-if="canConfirmReturn" :xs="24" :lg="12">
<el-card shadow="never" class="operation-card">
<template #header><strong>仓库确认退回</strong></template>
<el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" show-icon class="mb8" />
<el-form label-position="top">
<el-form-item label="待确认退回单">
<el-select
v-model="confirmForm.returnId"
class="business-control"
placeholder="请选择 REQUESTED 退回单"
filterable
clearable
style="width: 100%"
:disabled="operationPending || detailLoading"
@change="handleConfirmReturnSelection"
>
<el-option
v-for="item in requestedReturnOptions"
:key="item.returnId"
:label="item.returnNo || `#${item.returnId}`"
:value="item.returnId"
/>
</el-select>
</el-form-item>
<el-form-item label="逐个扫描到仓托盘">
<pallet-scan-input
:disabled="operationPending || detailLoading || !confirmForm.returnId || !confirmExpectedPalletIds.length"
:loading="scanLoading.confirm"
@scan="handleConfirmScan"
@invalid="handleConfirmScanInvalid"
@duplicate="handleScanDuplicate"
/>
</el-form-item>
</el-form>
<el-descriptions :column="2" border class="mb8">
<el-descriptions-item label="预期托盘">{{ confirmExpectedPalletIds.length }} 个</el-descriptions-item>
<el-descriptions-item label="已扫托盘">{{ confirmScannedPallets.length }} 个</el-descriptions-item>
</el-descriptions>
<el-table :data="confirmExpectedRows" empty-text="请选择待确认退回单" size="small" max-height="250">
<el-table-column label="预期托盘" min-width="150">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="扫码状态" width="100" align="center">
<template #default="scope">
<el-tag :type="isConfirmPalletScanned(scope.row.palletId) ? 'success' : 'info'">
{{ isConfirmPalletScanned(scope.row.palletId) ? '已扫码' : '待扫码' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="scope">
<el-button
v-if="isConfirmPalletScanned(scope.row.palletId)"
class="business-action"
link
type="danger"
:disabled="operationPending"
@click="removeConfirmPallet(scope.row.palletId)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<div class="operation-actions">
<el-button
class="business-action"
type="success"
:loading="confirmSubmitLoading"
:disabled="operationPending || detailLoading || scanLoading.confirm || !confirmForm.returnId"
@click="submitReturnConfirm"
v-hasPermi="['business:return:confirm']"
>复核并确认退回</el-button>
<el-button class="business-action" :disabled="operationPending" @click="resetReturnConfirm">清空</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-divider v-if="canRequestReturn || canConfirmReturn" />
<el-form ref="queryRef" :model="queryParams" :inline="true">
<el-form-item label="退回单号" prop="returnNo">
<el-input v-model="queryParams.returnNo" placeholder="请输入退回单号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="来源现场ID" prop="sourceSiteId">
<el-input-number v-model="queryParams.sourceSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="目标仓库ID" prop="targetWarehouseId">
<el-input-number v-model="queryParams.targetWarehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
</el-form-item>
<el-form-item label="退回状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
<el-option label="待确认" value="REQUESTED" />
<el-option label="已确认" value="CONFIRMED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:return:list']">搜索</el-button>
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:return:list']">重置</el-button>
</el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" empty-text="暂无数据" :data="returnList">
<el-table-column label="退回单号" prop="returnNo" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="来源现场" min-width="120">
<template #default="scope">#{{ scope.row.sourceSiteId }}</template>
</el-table-column>
<el-table-column label="目标仓库" min-width="130">
<template #default="scope">{{ warehouseLabel(scope.row.targetWarehouseId) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'REQUESTED' ? 'warning' : 'success'">
{{ scope.row.status === 'REQUESTED' ? '待确认' : '已确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="申请时间" prop="requestedAt" width="180">
<template #default="scope">{{ parseTime(scope.row.requestedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'REQUESTED' && canConfirmReturn"
link
type="success"
:disabled="operationPending || detailLoading"
@click="startConfirmFromRow(scope.row)"
v-hasPermi="['business:return:confirm']"
>确认退回</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script setup name="BusinessReturn">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import {
confirmReturn,
getReturn,
getReturnWarehouseOptions,
listReturn,
requestReturn,
scanReturnPallet
} from '@/api/business/return'
const { proxy } = getCurrentInstance()
const returnList = ref([])
const warehouseOptions = ref([])
const requestScannedPallets = ref([])
const confirmExpectedRows = ref([])
const confirmExpectedPalletIds = ref([])
const confirmScannedPallets = ref([])
const requestForm = ref({ sourceSiteId: undefined, targetWarehouseId: undefined })
const confirmForm = ref({ returnId: undefined })
const loading = ref(false)
const optionLoading = ref(false)
const detailLoading = ref(false)
const scanLoading = reactive({ request: false, confirm: false })
const requestSubmitLoading = ref(false)
const confirmSubmitLoading = ref(false)
const loadError = ref('')
const requestError = ref('')
const confirmError = ref('')
const total = ref(0)
const requestIdempotencyKey = ref('')
const confirmIdempotency = reactive({ returnId: undefined, key: '' })
const canRequestReturn = computed(() => proxy.$auth.hasPermiAnd(['business:return:query', 'business:return:request']))
const canConfirmReturn = computed(() => proxy.$auth.hasPermiAnd(['business:return:query', 'business:return:confirm']))
const operationPending = computed(() => requestSubmitLoading.value || confirmSubmitLoading.value)
const requestedReturnOptions = computed(() => returnList.value.filter((item) => item.status === 'REQUESTED'))
let listGeneration = 0
let optionGeneration = 0
let detailGeneration = 0
let requestScanGeneration = 0
let confirmScanGeneration = 0
let confirmSubmitGeneration = 0
let requestSession = 0
let confirmSession = 0
const queryParams = ref({
pageNum: 1,
pageSize: 10,
returnNo: undefined,
sourceSiteId: undefined,
targetWarehouseId: undefined,
status: undefined
})
function warehouseLabel(id) {
const option = warehouseOptions.value.find((item) => item.id === id)
return option?.name || (id ? `#${id}` : '—')
}
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true
loadError.value = ''
listReturn(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
returnList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
returnList.value = []
total.value = 0
loadError.value = '退回单列表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false
})
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration && optionContext.requestSession === requestSession
}
function loadReturnWarehouseOptions() {
const optionContext = { generation: ++optionGeneration, requestSession }
optionLoading.value = true
requestError.value = ''
getReturnWarehouseOptions().then((response) => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = Array.isArray(response.data) ? response.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = []
requestError.value = '目标仓库选项加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
}
function handleWarehouseOptionsVisible(visible) {
if (!visible || !canRequestReturn.value || operationPending.value || warehouseOptions.value.length) return
loadReturnWarehouseOptions()
}
function invalidateDetailRequest() {
detailGeneration += 1
detailLoading.value = false
}
function isCurrentDetailRequest(detailContext) {
return detailContext.generation === detailGeneration &&
detailContext.confirmSession === confirmSession &&
detailContext.returnId === confirmForm.value.returnId
}
function invalidateRequestScan() {
requestScanGeneration += 1
scanLoading.request = false
}
function invalidateConfirmScan() {
confirmScanGeneration += 1
scanLoading.confirm = false
}
function isCurrentRequestScan(scanContext) {
return scanContext.generation === requestScanGeneration &&
scanContext.requestSession === requestSession &&
scanContext.targetWarehouseId === requestForm.value.targetWarehouseId
}
function isCurrentConfirmScan(scanContext) {
return scanContext.generation === confirmScanGeneration &&
scanContext.confirmSession === confirmSession &&
scanContext.returnId === confirmForm.value.returnId
}
function clearRequestIdempotencyKey() {
requestIdempotencyKey.value = ''
}
function ensureRequestIdempotencyKey() {
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey()
return requestIdempotencyKey.value
}
function clearReturnConfirmIdempotencyKey() {
confirmIdempotency.returnId = undefined
confirmIdempotency.key = ''
}
function ensureReturnConfirmIdempotencyKey(returnId) {
if (confirmIdempotency.returnId !== returnId) {
clearReturnConfirmIdempotencyKey()
confirmIdempotency.returnId = returnId
}
if (!confirmIdempotency.key) confirmIdempotency.key = createIdempotencyKey()
return confirmIdempotency.key
}
function resetReturnRequest() {
if (operationPending.value) return
requestSession += 1
invalidateOptionRequest()
invalidateRequestScan()
clearRequestIdempotencyKey()
requestForm.value = { sourceSiteId: undefined, targetWarehouseId: undefined }
requestScannedPallets.value = []
requestError.value = ''
}
function resetReturnConfirm() {
if (operationPending.value) return
confirmSession += 1
confirmSubmitGeneration += 1
invalidateDetailRequest()
invalidateConfirmScan()
clearReturnConfirmIdempotencyKey()
confirmForm.value = { returnId: undefined }
confirmExpectedRows.value = []
confirmExpectedPalletIds.value = []
confirmScannedPallets.value = []
confirmError.value = ''
}
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
function handleRequestScan(qrToken) {
if (operationPending.value || scanLoading.request || !requestForm.value.targetWarehouseId) {
requestError.value = '请先选择退回目标仓库'
return
}
invalidateRequestScan()
const scanContext = {
generation: ++requestScanGeneration,
mode: 'request',
requestSession,
targetWarehouseId: requestForm.value.targetWarehouseId,
qrToken
}
scanLoading.request = true
requestError.value = ''
scanReturnPallet(qrToken, {
mode: 'request'
}).then((response) => {
if (!isCurrentRequestScan(scanContext)) return
const pallet = response.data
if (!pallet.siteId) {
requestError.value = '该托盘不在客户现场,不能发起退回'
return
}
if (requestForm.value.sourceSiteId && pallet.siteId !== requestForm.value.sourceSiteId) {
requestError.value = '同一退回申请只能包含同一客户现场的托盘'
return
}
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msgWarning('该托盘已在退回申请中')
return
}
if (!requestForm.value.sourceSiteId) requestForm.value.sourceSiteId = pallet.siteId
requestScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
remainingPackages: pallet.remainingPackages
})
clearRequestIdempotencyKey()
}).catch(() => {
if (!isCurrentRequestScan(scanContext)) return
requestError.value = '退回托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentRequestScan(scanContext)) return
scanLoading.request = false
})
}
function handleRequestScanInvalid(message) {
requestError.value = message
}
function removeRequestPallet(palletId) {
if (operationPending.value) return
requestScannedPallets.value = requestScannedPallets.value.filter((item) => item.palletId !== palletId)
if (!requestScannedPallets.value.length) requestForm.value.sourceSiteId = undefined
clearRequestIdempotencyKey()
}
function submitReturnRequest() {
if (operationPending.value || scanLoading.request || optionLoading.value) return
if (!requestForm.value.sourceSiteId || !requestForm.value.targetWarehouseId || !requestScannedPallets.value.length) {
requestError.value = '请选择目标仓库并至少扫描一个退回托盘'
return
}
const idempotencyKey = ensureRequestIdempotencyKey()
requestSubmitLoading.value = true
requestError.value = ''
requestReturn({
sourceSiteId: requestForm.value.sourceSiteId,
targetWarehouseId: requestForm.value.targetWarehouseId,
palletIds: requestScannedPallets.value.map((item) => item.palletId),
idempotencyKey: idempotencyKey
}).then((response) => {
clearRequestIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '退回申请提交成功')
requestSubmitLoading.value = false
resetReturnRequest()
getList()
}).catch(() => {
requestError.value = '退回申请提交失败;直接重试将复用本次幂等键'
}).finally(() => {
requestSubmitLoading.value = false
})
}
function handleConfirmReturnSelection(returnId) {
if (operationPending.value) return
confirmSession += 1
confirmSubmitGeneration += 1
invalidateDetailRequest()
invalidateConfirmScan()
clearReturnConfirmIdempotencyKey()
confirmExpectedRows.value = []
confirmExpectedPalletIds.value = []
confirmScannedPallets.value = []
confirmError.value = ''
if (!returnId) return
const selected = returnList.value.find((item) => item.returnId === returnId)
if (selected?.status !== 'REQUESTED') {
confirmError.value = '只能选择 REQUESTED 状态的退回单'
return
}
const detailContext = { generation: ++detailGeneration, confirmSession, returnId }
detailLoading.value = true
getReturn(returnId).then((response) => {
if (!isCurrentDetailRequest(detailContext)) return
const detail = response.data
if (detail.status !== 'REQUESTED') {
confirmError.value = '该退回单已不是待确认状态'
return
}
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`
}))
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId)
if (!confirmExpectedPalletIds.value.length) confirmError.value = '该退回单没有可确认的托盘明细'
}).catch(() => {
if (!isCurrentDetailRequest(detailContext)) return
confirmError.value = '退回单详情加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentDetailRequest(detailContext)) return
detailLoading.value = false
})
}
function startConfirmFromRow(row) {
if (row.status !== 'REQUESTED') {
proxy.$modal.msgWarning('只有 REQUESTED 退回单可以确认')
return
}
if (!canConfirmReturn.value || operationPending.value) return
confirmForm.value.returnId = row.returnId
handleConfirmReturnSelection(row.returnId)
}
function handleConfirmScan(qrToken) {
if (operationPending.value || scanLoading.confirm || detailLoading.value || !confirmForm.value.returnId) return
invalidateConfirmScan()
const scanContext = {
generation: ++confirmScanGeneration,
mode: 'confirm',
confirmSession,
returnId: confirmForm.value.returnId,
qrToken
}
scanLoading.confirm = true
confirmError.value = ''
scanReturnPallet(qrToken, {
mode: 'confirm',
returnId: scanContext.returnId
}).then((response) => {
if (!isCurrentConfirmScan(scanContext)) return
const pallet = response.data
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
confirmError.value = '该托盘不属于所选退回单'
return
}
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msgWarning('该托盘已完成扫码')
return
}
confirmScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`
})
clearReturnConfirmIdempotencyKey()
}).catch(() => {
if (!isCurrentConfirmScan(scanContext)) return
confirmError.value = '到仓托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentConfirmScan(scanContext)) return
scanLoading.confirm = false
})
}
function handleConfirmScanInvalid(message) {
confirmError.value = message
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function isConfirmPalletScanned(palletId) {
return confirmScannedPallets.value.some((item) => item.palletId === palletId)
}
function removeConfirmPallet(palletId) {
if (operationPending.value) return
confirmScannedPallets.value = confirmScannedPallets.value.filter((item) => item.palletId !== palletId)
clearReturnConfirmIdempotencyKey()
}
function sameIdSet(left, right) {
if (left.length !== right.length) return false
const leftSet = new Set(left)
const rightSet = new Set(right)
if (leftSet.size !== left.length || rightSet.size !== right.length) return false
return right.every((id) => leftSet.has(id))
}
function isCurrentConfirmSubmit(submitContext) {
return submitContext.generation === confirmSubmitGeneration &&
submitContext.confirmSession === confirmSession &&
submitContext.returnId === confirmForm.value.returnId
}
function submitReturnConfirm() {
if (operationPending.value || detailLoading.value || scanLoading.confirm) return
const returnId = confirmForm.value.returnId
const initialExpectedIds = [...confirmExpectedPalletIds.value]
const scannedPalletIds = [...confirmScannedPallets.value.map((item) => item.palletId)]
if (!returnId || !initialExpectedIds.length || !sameIdSet(initialExpectedIds, scannedPalletIds)) {
confirmError.value = '必须逐个扫描并完整匹配退回单全部托盘'
return
}
const submitContext = { generation: ++confirmSubmitGeneration, confirmSession, returnId }
confirmSubmitLoading.value = true
confirmError.value = ''
getReturn(returnId).then((response) => {
if (!isCurrentConfirmSubmit(submitContext)) throw new Error('STALE_CONFIRM_SUBMIT')
const latestDetail = response.data
if (latestDetail.status !== 'REQUESTED') {
confirmError.value = '退回单状态已变化,请重新选择'
throw new Error('RETURN_STATUS_CHANGED')
}
const latestPalletIds = latestDetail.items.map((item) => item.palletId)
if (!sameIdSet(latestPalletIds, initialExpectedIds) || !sameIdSet(latestPalletIds, scannedPalletIds)) {
confirmError.value = '退回单明细已变化,或实际扫码集合与最新明细不一致;未提交确认'
throw new Error('RETURN_SET_CHANGED')
}
const idempotencyKey = ensureReturnConfirmIdempotencyKey(returnId)
return confirmReturn(returnId, {
scannedPalletIds: scannedPalletIds,
idempotencyKey: idempotencyKey
})
}).then((response) => {
if (!isCurrentConfirmSubmit(submitContext)) return
clearReturnConfirmIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '退回确认成功')
confirmSubmitLoading.value = false
resetReturnConfirm()
getList()
}).catch((error) => {
if (error?.message === 'STALE_CONFIRM_SUBMIT') return
if (error?.message === 'RETURN_STATUS_CHANGED' || error?.message === 'RETURN_SET_CHANGED') return
if (!isCurrentConfirmSubmit(submitContext)) return
confirmError.value = '退回确认失败;直接重试将复用本次幂等键'
}).finally(() => {
if (!isCurrentConfirmSubmit(submitContext)) return
confirmSubmitLoading.value = false
})
}
watch(
() => requestForm.value.targetWarehouseId,
() => {
if (!requestSubmitLoading.value) {
invalidateRequestScan()
clearRequestIdempotencyKey()
}
}
)
getList();
</script>
<style lang="scss" scoped>
.return-operations {
row-gap: 18px;
}
.operation-card {
height: 100%;
border-color: var(--el-border-color-light);
}
.operation-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@media (max-width: 767px) {
.return-page {
padding: 12px;
}
.operation-actions {
display: grid;
grid-template-columns: 1fr;
}
:deep(.business-control .el-input__wrapper),
:deep(.business-action) {
min-height: 52px;
font-size: 16px;
}
}
</style>
@@ -0,0 +1,670 @@
<template>
<div class="app-container">
<el-form ref="queryRef" :model="queryParams" :inline="true">
<el-form-item label="发货单号" prop="shipmentNo">
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="来源仓库ID" prop="sourceWarehouseId">
<el-input-number v-model="queryParams.sourceWarehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
</el-form-item>
<el-form-item label="目标现场ID" prop="targetSiteId">
<el-input-number v-model="queryParams.targetSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="单据状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
<el-option label="草稿" value="DRAFT" />
<el-option label="已确认" value="CONFIRMED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:shipment:list']">搜索</el-button>
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:shipment:list']">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-if="canAddShipment"
class="business-action"
type="primary"
plain
icon="Plus"
:disabled="operationPending || detailLoading"
@click="handleAdd"
v-hasPermi="['business:shipment:add']"
>新增发货草稿</el-button>
</el-col>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" empty-text="暂无数据" :data="shipmentList">
<el-table-column label="发货单号" prop="shipmentNo" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="来源仓库" min-width="130">
<template #default="scope">{{ optionLabel(warehouseOptions, scope.row.sourceWarehouseId) }}</template>
</el-table-column>
<el-table-column label="目标现场" min-width="130">
<template #default="scope">{{ optionLabel(siteOptions, scope.row.targetSiteId) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" width="180">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
v-if="scope.row.status === 'DRAFT' && canEditShipment"
link
type="primary"
icon="Edit"
:disabled="operationPending || detailLoading"
@click="handleUpdate(scope.row)"
v-hasPermi="['business:shipment:edit']"
>编辑</el-button>
<el-button
v-if="scope.row.status === 'DRAFT'"
link
type="danger"
icon="Delete"
:disabled="operationPending"
@click="handleDelete(scope.row)"
v-hasPermi="['business:shipment:remove']"
>删除</el-button>
<el-button
v-if="scope.row.status === 'DRAFT' && canConfirmShipment"
link
type="success"
icon="CircleCheck"
:disabled="operationPending"
@click="handleConfirm(scope.row)"
v-hasPermi="['business:shipment:confirm']"
>确认</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog
v-model="open"
:title="title"
width="min(820px, 95vw)"
append-to-body
:close-on-click-modal="!operationPending"
:close-on-press-escape="!operationPending"
:show-close="!operationPending"
:before-close="handleDialogBeforeClose"
>
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="shipmentRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="来源仓库" prop="sourceWarehouseId">
<el-select
v-model="form.sourceWarehouseId"
class="business-control"
placeholder="请选择授权仓库"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
>
<el-option v-for="option in warehouseOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="目标现场" prop="targetSiteId">
<el-select
v-model="form.targetSiteId"
class="business-control"
placeholder="请选择授权现场"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
>
<el-option v-for="option in siteOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="连续扫码">
<pallet-scan-input
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady) || !form.sourceWarehouseId || !form.targetSiteId"
:loading="scanLoading"
@scan="handleShipmentScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<div class="form-tip">仅接收来源仓库内有料托盘;剩余包数由后端返回,只读不可修改。</div>
</el-form-item>
</el-form>
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
<el-table :data="scannedPallets" empty-text="尚未扫描托盘" size="small" max-height="340">
<el-table-column label="托盘" min-width="180">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="托盘ID" prop="palletId" width="100" />
<el-table-column label="菌种" min-width="150">
<template #default="scope">{{ scope.row.strainName || scope.row.strainCode || (scope.row.strainId ? `#${scope.row.strainId}` : '—') }}</template>
</el-table-column>
<el-table-column label="批次" prop="batchNo" min-width="120" />
<el-table-column label="剩余包数(只读)" prop="remainingPackages" width="140" align="right">
<template #default="scope">{{ scope.row.remainingPackages ?? '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="scope">
<el-button
class="business-action"
link
type="danger"
:disabled="operationPending || detailLoading || (isEdit && !editDetailReady)"
@click="removeScannedPallet(scope.row.palletId)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="dialog-footer">
<el-button
v-if="!isEdit"
class="business-action"
type="primary"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || detailLoading"
@click="submitShipment"
v-hasPermi="['business:shipment:add']"
>保存草稿</el-button>
<el-button
v-else
class="business-action"
type="primary"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || detailLoading || !editDetailReady"
@click="saveShipmentUpdate"
v-hasPermi="['business:shipment:edit']"
>保存修改</el-button>
<el-button class="business-action" :disabled="operationPending" @click="cancelShipment">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="BusinessShipment">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import {
addShipment,
confirmShipment,
delShipment,
getShipment,
getShipmentSiteOptions,
getShipmentWarehouseOptions,
listShipment,
scanShipmentPallet,
updateShipment
} from '@/api/business/shipment'
const { proxy } = getCurrentInstance()
const shipmentList = ref([])
const warehouseOptions = ref([])
const siteOptions = ref([])
const scannedPallets = ref([])
const loading = ref(false)
const optionLoading = ref(false)
const detailLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const deleteLoading = ref(false)
const confirmLoading = ref(false)
const loadError = ref('')
const optionError = ref('')
const scanError = ref('')
const formError = ref('')
const open = ref(false)
const title = ref('')
const total = ref(0)
const editDetailReady = ref(false)
const isEdit = computed(() => Boolean(form.value.shipmentId))
const canAddShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:add']))
const canEditShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:edit']))
const canConfirmShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:confirm']))
const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value)
let listGeneration = 0
let optionGeneration = 0
let detailGeneration = 0
let scanGeneration = 0
let dialogSession = 0
let hydratingForm = false
const confirmIdempotency = reactive({ shipmentId: undefined, key: '' })
const data = reactive({
form: { shipmentId: undefined, sourceWarehouseId: undefined, targetSiteId: undefined },
queryParams: { pageNum: 1, pageSize: 10, shipmentNo: undefined, sourceWarehouseId: undefined, targetSiteId: undefined, status: undefined },
rules: {
sourceWarehouseId: [{ required: true, message: '请选择来源仓库', trigger: 'change' }],
targetSiteId: [{ required: true, message: '请选择目标现场', trigger: 'change' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function optionLabel(options, id) {
const option = options.find((item) => item.id === id)
return option?.name || (id ? `#${id}` : '—')
}
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true
loadError.value = ''
listShipment(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
shipmentList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
shipmentList.value = []
total.value = 0
loadError.value = '发货单列表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false
})
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
}
function loadShipmentOptions() {
const optionContext = { generation: ++optionGeneration, dialogSession }
optionLoading.value = true
optionError.value = ''
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()]).then(([warehouseResponse, siteResponse]) => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = Array.isArray(warehouseResponse.data) ? warehouseResponse.data : []
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
warehouseOptions.value = []
siteOptions.value = []
optionError.value = '仓库或现场选项加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
}
function invalidateDetailRequest() {
detailGeneration += 1
detailLoading.value = false
}
function isCurrentDetailRequest(detailContext) {
return detailContext.generation === detailGeneration && detailContext.dialogSession === dialogSession
}
function invalidateScanRequest() {
scanGeneration += 1
scanLoading.value = false
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration &&
scanContext.dialogSession === dialogSession &&
scanContext.sourceWarehouseId === form.value.sourceWarehouseId &&
scanContext.targetSiteId === form.value.targetSiteId
}
function resetShipmentForm() {
dialogSession += 1
invalidateOptionRequest()
invalidateDetailRequest()
invalidateScanRequest()
form.value = { shipmentId: undefined, sourceWarehouseId: undefined, targetSiteId: undefined }
scannedPallets.value = []
editDetailReady.value = false
optionError.value = ''
scanError.value = ''
formError.value = ''
proxy.resetForm('shipmentRef')
}
function cancelShipment() {
if (operationPending.value) return
open.value = false
resetShipmentForm()
}
function handleDialogBeforeClose(done) {
if (operationPending.value) return
resetShipmentForm()
done()
}
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
function handleAdd() {
if (!canAddShipment.value || operationPending.value) return
resetShipmentForm()
title.value = '新增整托发货草稿'
open.value = true
loadShipmentOptions()
}
function handleUpdate(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿发货单可以编辑')
return
}
if (!canEditShipment.value || operationPending.value) return
resetShipmentForm()
form.value.shipmentId = row.shipmentId
editDetailReady.value = false
title.value = '编辑整托发货草稿'
open.value = true
loadShipmentOptions()
const detailContext = { generation: ++detailGeneration, dialogSession, shipmentId: row.shipmentId }
detailLoading.value = true
getShipment(detailContext.shipmentId).then((response) => {
if (!isCurrentDetailRequest(detailContext)) return
const detail = response.data;
if (detail.status !== 'DRAFT') {
formError.value = '该发货单已不是草稿状态,不能编辑'
return
}
hydratingForm = true
form.value = {
shipmentId: detailContext.shipmentId,
sourceWarehouseId: detail.sourceWarehouseId,
targetSiteId: detail.targetSiteId
}
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
strainId: item.strainId,
strainCode: item.strainCode,
strainName: item.strainName,
batchNo: item.batchNo,
remainingPackages: item.remainingPackages ?? item.packageQty ?? null
}))
hydratingForm = false
editDetailReady.value = true
}).catch(() => {
if (!isCurrentDetailRequest(detailContext)) return
formError.value = '发货单详情加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentDetailRequest(detailContext)) return
detailLoading.value = false
})
}
function handleShipmentScan(qrToken) {
if (isEdit.value && !editDetailReady.value) {
scanError.value = '发货单详情尚未就绪,不能扫码'
return
}
if (operationPending.value || scanLoading.value || !form.value.sourceWarehouseId || !form.value.targetSiteId) {
scanError.value = '请先选择来源仓库和目标现场'
return
}
const scanContext = {
generation: ++scanGeneration,
dialogSession,
sourceWarehouseId: form.value.sourceWarehouseId,
targetSiteId: form.value.targetSiteId,
qrToken
}
scanLoading.value = true
scanError.value = ''
scanShipmentPallet(qrToken, {
sourceWarehouseId: scanContext.sourceWarehouseId,
targetSiteId: scanContext.targetSiteId
}).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
if (pallet.flowState !== 'FILLED_IN_WAREHOUSE' || pallet.remainingPackages <= 0) {
scanError.value = '该托盘不是来源仓库内的有料托盘'
return
}
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msgWarning('该托盘已在当前明细中')
return
}
scannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
strainId: pallet.strainId,
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
remainingPackages: pallet.remainingPackages
})
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
scanError.value = '托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentScanRequest(scanContext)) return
scanLoading.value = false
})
}
function handleScanInvalid(message) {
scanError.value = message
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function removeScannedPallet(palletId) {
if (operationPending.value || (isEdit.value && !editDetailReady.value)) return
scannedPallets.value = scannedPallets.value.filter((item) => item.palletId !== palletId)
}
function addShipmentDraft() {
return addShipment({
sourceWarehouseId: form.value.sourceWarehouseId,
targetSiteId: form.value.targetSiteId,
palletIds: scannedPallets.value.map((item) => item.palletId)
})
}
function updateShipmentDraft() {
return updateShipment({
shipmentId: form.value.shipmentId,
sourceWarehouseId: form.value.sourceWarehouseId,
targetSiteId: form.value.targetSiteId,
palletIds: scannedPallets.value.map((item) => item.palletId)
})
}
function submitShipmentForm(editing) {
if (editing !== isEdit.value || (editing && !editDetailReady.value)) {
formError.value = '发货单编辑详情尚未就绪,不能提交'
return
}
if (operationPending.value || scanLoading.value || optionLoading.value || detailLoading.value) return
submitLoading.value = true
proxy.$refs.shipmentRef.validate((valid) => {
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallets.value.length) {
formError.value = '请至少扫描一个有料托盘'
submitLoading.value = false
return
}
formError.value = ''
const submitPromise = editing ? updateShipmentDraft() : addShipmentDraft()
submitPromise.then((response) => {
proxy.$modal.msgSuccess(response.msg || '发货草稿保存成功')
open.value = false
resetShipmentForm()
getList()
}).catch(() => {
formError.value = editing ? '发货草稿修改失败,请根据提示修正后重试' : '发货草稿新增失败,请根据提示修正后重试'
}).finally(() => {
submitLoading.value = false
})
})
}
function submitShipment() {
submitShipmentForm(false)
}
function saveShipmentUpdate() {
submitShipmentForm(true)
}
function handleDelete(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿发货单可以删除')
return
}
if (operationPending.value) return
deleteLoading.value = true
proxy.$modal.confirm(`是否确认删除发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`).then(() => {
return delShipment(String(row.shipmentId))
}).then((response) => {
proxy.$modal.msgSuccess(response.msg || '删除成功')
getList()
}).catch((error) => {
if (error !== 'cancel' && error !== 'close') loadError.value = '发货单删除失败,请稍后重试'
}).finally(() => {
deleteLoading.value = false
})
}
function clearConfirmIdempotency() {
confirmIdempotency.shipmentId = undefined
confirmIdempotency.key = ''
}
function ensureConfirmIdempotencyKey(shipmentId) {
if (confirmIdempotency.shipmentId !== shipmentId) {
clearConfirmIdempotency()
confirmIdempotency.shipmentId = shipmentId
}
if (!confirmIdempotency.key) confirmIdempotency.key = createIdempotencyKey()
return confirmIdempotency.key
}
function handleConfirmCancellation(error) {
if (error === 'cancel' || error === 'close') {
clearConfirmIdempotency()
return true
}
return false
}
function handleConfirm(row) {
if (row.status !== 'DRAFT') {
proxy.$modal.msgWarning('只有草稿发货单可以确认')
return
}
if (operationPending.value) return
confirmLoading.value = true
proxy.$modal.confirm(`确认后发货单“${row.shipmentNo || `#${row.shipmentId}`}”将不可编辑或删除,是否继续?`).then(() => {
const idempotencyKey = ensureConfirmIdempotencyKey(row.shipmentId)
return confirmShipment(row.shipmentId, {
idempotencyKey: idempotencyKey
})
}).then((response) => {
clearConfirmIdempotency()
proxy.$modal.msgSuccess(response.msg || '发货确认成功')
getList()
}).catch((error) => {
if (error === 'cancel' || error === 'close') {
handleConfirmCancellation(error)
return
}
loadError.value = '发货确认失败;再次确认会复用本次幂等键'
}).finally(() => {
confirmLoading.value = false
})
}
watch(
() => [form.value.sourceWarehouseId, form.value.targetSiteId],
([sourceWarehouseId, targetSiteId], [previousWarehouseId, previousSiteId]) => {
if (hydratingForm || (sourceWarehouseId === previousWarehouseId && targetSiteId === previousSiteId)) return
invalidateScanRequest()
scannedPallets.value = []
scanError.value = ''
},
{ flush: 'sync' }
)
getList();
</script>
<style lang="scss" scoped>
.form-tip {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@media (max-width: 767px) {
:deep(.el-dialog) {
width: 95vw !important;
}
:deep(.el-form-item__label) {
float: none;
display: block;
width: 100% !important;
text-align: left;
}
:deep(.el-form-item__content) {
margin-left: 0 !important;
}
}
</style>
@@ -0,0 +1,496 @@
<template>
<div class="app-container usage-page">
<el-card v-if="canAddUsage" class="usage-operation" shadow="never">
<template #header>
<div class="usage-operation__header">
<span>现场使用 PDA</span>
<el-tag type="info">扫码枪 / 手工输入</el-tag>
</div>
</template>
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
<el-alert
v-if="usageResult"
:title="`使用成功:后端返回余量 ${usageResult.remainingPackages} 包,状态 ${usageResult.flowState}`"
type="success"
:closable="false"
show-icon
class="mb8"
/>
<div class="usage-grid">
<section class="usage-panel">
<div class="usage-label">扫描托盘</div>
<pallet-scan-input
:disabled="operationPending || optionLoading"
:loading="scanLoading"
@scan="handleUsageScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<div class="usage-tip">支持 Android 浏览器连接扫码枪后直接回车提交,不调用摄像头。</div>
</section>
<section class="usage-panel usage-panel--summary">
<template v-if="scannedPallet">
<div class="summary-item"><span>托盘</span><strong>{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</strong></div>
<div class="summary-item"><span>菌种</span><strong>{{ scannedPallet.strainName || scannedPallet.strainCode || (scannedPallet.strainId ? `#${scannedPallet.strainId}` : '—') }}</strong></div>
<div class="summary-item"><span>批次</span><strong>{{ scannedPallet.batchNo || '—' }}</strong></div>
<div class="summary-item"><span>当前余量</span><strong>{{ scannedPallet.remainingPackages }} 包</strong></div>
</template>
<el-empty v-else description="等待扫描托盘" :image-size="72" />
</section>
</div>
<el-form ref="usageRef" :model="form" :rules="rules" label-position="top" class="usage-form">
<el-row :gutter="18">
<el-col :xs="24" :sm="12">
<el-form-item label="使用产线" prop="lineId">
<el-select
v-model="form.lineId"
class="business-control"
placeholder="请先扫描托盘"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || lineLocked || optionLoading || !scannedPallet"
>
<el-option v-for="option in lineOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
<div v-if="lineLocked" class="usage-tip">托盘已绑定产线,当前操作不可切换。</div>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="本次使用包数" prop="usedPackages">
<el-input-number
v-model="form.usedPackages"
class="business-control"
:min="1"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending || !scannedPallet"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="usage-actions">
<el-button
class="business-action"
type="primary"
size="large"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet || Boolean(usageResult)"
@click="submitUsage"
v-hasPermi="['business:usage:add']"
>提交使用</el-button>
<el-button class="business-action" size="large" :disabled="operationPending" @click="resetUsageOperation">清空重扫</el-button>
</div>
</el-card>
<el-divider v-if="canAddUsage" />
<el-form ref="queryRef" :model="queryParams" :inline="true">
<el-form-item label="使用记录号" prop="usageNo">
<el-input v-model="queryParams.usageNo" placeholder="请输入使用记录号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="产线ID" prop="lineId">
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:usage:list']">搜索</el-button>
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:usage:list']">重置</el-button>
</el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" empty-text="暂无数据" :data="usageList">
<el-table-column label="使用记录号" prop="usageNo" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="托盘" min-width="120">
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="现场ID" prop="siteId" width="100" />
<el-table-column label="产线ID" prop="lineId" width="100" />
<el-table-column label="使用包数" prop="usedPackages" width="105" align="right" />
<el-table-column label="使用后余量" prop="remainingPackagesAfter" width="115" align="right" />
<el-table-column label="发生时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script setup name="BusinessUsage">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import { addUsage, getUsageLineOptions, listUsage, scanUsagePallet } from '@/api/business/usage'
const { proxy } = getCurrentInstance()
const usageList = ref([])
const lineOptions = ref([])
const scannedPallet = ref(null)
const usageResult = ref(null)
const lineLocked = ref(false)
const loading = ref(false)
const optionLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const loadError = ref('')
const operationError = ref('')
const total = ref(0)
const usageIdempotencyKey = ref('')
const canAddUsage = computed(() => proxy.$auth.hasPermiAnd(['business:usage:query', 'business:usage:add']))
const operationPending = computed(() => submitLoading.value)
let listGeneration = 0
let optionGeneration = 0
let scanGeneration = 0
let operationSession = 0
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
callback(new Error('使用包数必须为正整数'))
return
}
callback()
}
const data = reactive({
form: { qrToken: '', lineId: undefined, usedPackages: undefined },
queryParams: { pageNum: 1, pageSize: 10, usageNo: undefined, palletCode: undefined, siteId: undefined, lineId: undefined },
rules: {
lineId: [{ required: true, message: '请选择使用产线', trigger: 'change' }],
usedPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
};
loading.value = true
loadError.value = ''
listUsage(listContext.query).then((response) => {
if (!isCurrentListRequest(listContext)) {
return;
}
usageList.value = response.rows
total.value = response.total
}).catch(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
usageList.value = []
total.value = 0
loadError.value = '使用记录加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) {
return;
}
loading.value = false
})
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration &&
optionContext.operationSession === operationSession &&
optionContext.scanGeneration === scanGeneration &&
optionContext.palletId === scannedPallet.value?.palletId
}
function invalidateScanRequest() {
scanGeneration += 1
scanLoading.value = false
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
}
function loadLineOptions(pallet, scanContext) {
const optionContext = {
generation: ++optionGeneration,
operationSession,
scanGeneration: scanContext.generation,
palletId: pallet.palletId,
siteId: pallet.siteId
}
optionLoading.value = true
getUsageLineOptions(pallet.siteId).then((response) => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = Array.isArray(response.data) ? response.data : []
if (!lineOptions.value.length) operationError.value = '该现场暂无可用产线'
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = []
operationError.value = '产线选项加载失败,请稍后重新扫码'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
}
function clearUsageIdempotencyKey() {
usageIdempotencyKey.value = ''
}
function ensureUsageIdempotencyKey() {
if (!usageIdempotencyKey.value) usageIdempotencyKey.value = createIdempotencyKey()
return usageIdempotencyKey.value
}
function resetUsageOperation() {
if (operationPending.value) return
operationSession += 1
invalidateOptionRequest()
invalidateScanRequest()
clearUsageIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
operationError.value = ''
proxy.resetForm('usageRef')
}
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
function resetQuery() {
proxy.resetForm('queryRef')
handleQuery()
}
function handleUsageScan(qrToken) {
if (operationPending.value || scanLoading.value || optionLoading.value) return
invalidateOptionRequest()
clearUsageIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
const scanContext = { generation: ++scanGeneration, operationSession, qrToken }
scanLoading.value = true
operationError.value = ''
scanUsagePallet(qrToken).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
if (!['AT_SITE_UNUSED', 'AT_SITE_PARTIAL'].includes(pallet.flowState) || pallet.remainingPackages <= 0) {
operationError.value = '该托盘当前不可记录使用'
return
}
scannedPallet.value = {
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
currentLineId: pallet.currentLineId,
strainId: pallet.strainId,
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState
}
form.value.qrToken = qrToken
form.value.usedPackages = undefined
if (pallet.currentLineId == null) {
lineLocked.value = false
form.value.lineId = undefined
lineOptions.value = []
loadLineOptions(pallet, scanContext)
} else {
lineLocked.value = true
form.value.lineId = pallet.currentLineId
lineOptions.value = [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }]
}
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
operationError.value = '托盘扫码校验失败,请检查后重试'
}).finally(() => {
if (!isCurrentScanRequest(scanContext)) return
scanLoading.value = false
})
}
function handleScanInvalid(message) {
invalidateOptionRequest()
clearUsageIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
operationError.value = message
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function submitUsage() {
if (operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return
submitLoading.value = true
proxy.$refs.usageRef.validate((valid) => {
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallet.value || !form.value.qrToken) {
operationError.value = '请先扫描待使用托盘'
submitLoading.value = false
return
}
const idempotencyKey = ensureUsageIdempotencyKey()
operationError.value = ''
addUsage({
qrToken: form.value.qrToken,
lineId: form.value.lineId,
usedPackages: form.value.usedPackages,
idempotencyKey: idempotencyKey
}).then((response) => {
const result = response.data;
usageResult.value = {
remainingPackages: result.remainingPackages,
flowState: result.flowState
}
clearUsageIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '使用记录提交成功')
getList()
}).catch(() => {
operationError.value = '使用提交失败;直接重试将复用本次幂等键'
}).finally(() => {
submitLoading.value = false
})
})
}
watch(
() => [form.value.qrToken, form.value.lineId, form.value.usedPackages],
() => {
if (!submitLoading.value) clearUsageIdempotencyKey()
}
)
getList();
</script>
<style lang="scss" scoped>
.usage-operation {
border-color: var(--el-color-primary-light-7);
}
.usage-operation__header,
.usage-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.usage-operation__header {
font-size: 18px;
font-weight: 600;
}
.usage-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.8fr);
gap: 18px;
margin-bottom: 20px;
}
.usage-panel {
padding: 18px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.usage-label {
margin-bottom: 10px;
font-size: 16px;
font-weight: 600;
}
.usage-tip {
margin-top: 7px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.summary-item {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.summary-item:last-child {
border-bottom: 0;
}
.summary-item span {
color: var(--el-text-color-secondary);
}
.usage-actions {
justify-content: flex-end;
}
@media (max-width: 767px) {
.usage-page {
padding: 12px;
}
.usage-grid {
grid-template-columns: 1fr;
}
.usage-panel {
padding: 14px;
}
.usage-actions {
display: grid;
grid-template-columns: 1fr;
}
:deep(.business-control .el-input__wrapper),
:deep(.business-action) {
min-height: 56px;
font-size: 16px;
}
}
</style>