fix(dispatch): align dispatch actions and native return workflows

This commit is contained in:
2026-10-18 16:37:37 +08:00
parent 4806f88d7c
commit a5a1976924
29 changed files with 854 additions and 150 deletions
@@ -125,6 +125,22 @@ it('submits two actual batches using refreshed remaining sources and independent
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
wrapper.unmount()
})
it('receives the native original carton whole into a verified target without inventing inventory', async () => {
const carton = snapshot('EX-1', 1, 1)
carton.returnSources = [{ outboundPalletItemId: '10', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }]
vi.mocked(getExceptionDetail).mockResolvedValueOnce(carton).mockResolvedValue(snapshot('EX-1', 2, 0))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 1, replayed: false })
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('原退货箱 RETURN-CARTON-1(须整箱实收)')
expect(wrapper.text()).not.toContain('9007199254740997')
await fill(wrapper, 0.5); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).not.toHaveBeenCalled()
await fill(wrapper, 1); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'complete', expect.objectContaining({ receipts: [{ outboundPalletItemId: '10', returnCartonId: '9007199254740997', receivedPackages: 1, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }] }))
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
wrapper.unmount()
})
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
const wrapper = open(); await flushPromises(); await fill(wrapper)
@@ -8,6 +8,7 @@ import { getPallet, listPallets, type PalletDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
const emit = defineEmits<{ changed: [] }>()
@@ -105,12 +106,15 @@ async function execute(action: ExceptionAction) {
if (action === 'complete' && warehouseReturn.value && (!selected.length || selected.some(row => !Number.isSafeInteger(row.quantity) || Number(row.quantity) > row.maxReturnPackages || !row.palletNo || !row.locationId || !row.locationCode))) {
error.value = '请按剩余可收量填写本次实收,并选择已核对真实库位的目标托盘'; return
}
if (action === 'complete' && warehouseReturn.value && selected.some(row => row.returnCartonId && (row.quantity !== 1 || row.maxReturnPackages !== 1))) { error.value = '原退货箱必须按原箱整箱实收,请核对原箱及数量'; return }
try { if (action === 'complete' && warehouseReturn.value) selected.forEach(returnSourceReference) }
catch { error.value = '退回来源标识不完整或冲突,请重新读取详情'; return }
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
if (action === 'complete') {
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, inventoryPositionId: row.inventoryPositionId, receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
}
pendingByScope.set(identity.value, { action, payload })
}
@@ -206,8 +210,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
<template v-if="warehouseReturn">
<p>只填写本批实际收到的箱数;未收齐保持处理中。目标库位取所选托盘的真实库位。</p>
<div v-for="(row, index) in receipts" :key="`${row.outboundPalletItemId}:${row.inventoryPositionId}`">
<p>{{ row.sourcePalletNo }} · SKU {{ row.skuCode || '—' }} · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
<div v-for="(row, index) in receipts" :key="returnSourceKey(row)">
<p>{{ row.sourcePalletNo }} · <span v-if="row.returnCartonId">原退货箱 {{ row.cartonMark || '—' }}(须整箱实收)</span><span v-else>SKU {{ row.skuCode || '—' }}</span> · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
<!-- Element Plus initializes number-input ARIA on mount; remount when the form lock changes. -->
<el-form-item label="本批实收箱数"><el-input-number :key="locked ? 'locked' : 'editable'" v-model="row.quantity" :data-test="`quantity-${index}`" :min="0" :max="row.maxReturnPackages" :precision="0" placeholder="未收到可留空" /></el-form-item>
<el-form-item label="实际收货目标托盘"><el-select :model-value="row.target" :data-test="`target-${index}`" filterable remote :remote-method="searchTargets" placeholder="输入托盘码查询" @visible-change="onTargetVisibility" @update:model-value="onTargetChange(index, $event)"><el-option v-for="target in targets" :key="target.id" :value="String(target.id)" :label="target.palletCode" /></el-select></el-form-item>
@@ -1,16 +1,17 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, lockDispatchDraftPallet, unlockDispatchDraftPallet, releaseDispatchDraft, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/dispatches', () => ({
listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(),
createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
releaseDispatchDraft: vi.fn(),
}))
const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
@@ -31,12 +32,12 @@ function mountDraft() {
return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElTag: shell, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
}
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', palletNo: 'PAL-C' }
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', palletNo: 'PAL-D' }
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', warehousePalletId: '103', palletNo: 'PAL-C' }
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', warehousePalletId: '104', palletNo: 'PAL-D' }
const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [
{ id: 'container-1', capacity: 30, palletIds: ['11', '13'] },
{ id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] },
@@ -181,7 +182,7 @@ it.each([
it('rejects a relabel order split across multiple containers before either API command', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['13'] },
{ id: 'c1', capacity: 30, palletIds: ['101'] }, { id: 'c2', capacity: 30, palletIds: ['103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
@@ -194,12 +195,12 @@ it('rejects a relabel order split across multiple containers before either API c
it('keeps one complete relabel order on the single-dispatch endpoint', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['11', '13'] },
{ id: 'c1', capacity: 30, palletIds: ['101', '103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL', appointmentBatches: [] })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11', '13'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101', '103'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
@@ -237,3 +238,101 @@ it('retains every restored selection on candidate failure and submits both sourc
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
it('shows a draft recovery error and only loads candidates after a successful retry with the original token', async () => {
vi.mocked(listDispatchDrafts).mockRejectedValueOnce(new Error('草稿查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('keeps local relabel choices on close and reopen while describing their actual unlocked state', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel])
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('已选择,尚未锁定')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已选择,尚未锁定')
expect(listDispatchDrafts).toHaveBeenCalledTimes(2)
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click'); await flushPromises()
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each(['RELABEL_APPLICATION', 'RETURN_RELABEL'])('rejects a partial %s application after refreshing its complete candidate set', async orderType => {
const relabel = { ...plan, sourceStage: 'RELABEL' as const, orderRefs: 'RL-1', orderType }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: ['101'] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...relabel, planPalletId: '13', warehousePalletId: '103' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ sourceStage: 'RELABEL', orderNo: 'RL-1', draftToken: 'local-relabel' })
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')?.message).toContain('全部应排托盘')
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.emitted('created')).toBeUndefined()
wrapper.unmount()
})
it('preserves all previous selections when reopening fails to read drafts, then recovers the same groups on retry', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockRejectedValueOnce(new Error('查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('button').find(button => button.text() === '预排车')!.attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('requires explicit release to restart an expired draft and keeps choices when release fails', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockResolvedValue([])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
vi.mocked(releaseDispatchDraft).mockRejectedValueOnce(new Error('释放超时')).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原草稿已失效')
expect(releaseDispatchDraft).not.toHaveBeenCalled()
const restart = () => wrapper.findAll('button').find(button => button.text() === '释放草稿并重新选择')!
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenLastCalledWith('original-draft')
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原选择已保留')
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenNthCalledWith(2, 'original-draft')
expect(wrapper.find('[data-test="retry"]').exists()).toBe(false)
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each([plan, returned])('does not claim a failed source lock succeeded for $sourceType', async candidate => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'lock-draft', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([candidate])
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' } } }).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')).toMatchObject({ message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' })
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已锁定')
expect(lockDispatchDraftPallet).toHaveBeenLastCalledWith(candidate.sourceType === 'RETURN_RECEIPT' ? 'RETURN:22' : '11', 'lock-draft', 'c1')
wrapper.unmount()
})
@@ -3,14 +3,14 @@ import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeig
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { GripVertical, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
@@ -37,6 +37,10 @@ const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const draftError = ref<ApiErrorDetails | null>(null)
const draftLoading = ref(false)
const draftLoaded = ref(false)
const draftBlocked = computed(() => draftLoading.value || !draftLoaded.value || Boolean(draftError.value))
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
@@ -73,7 +77,7 @@ const visibleCandidates = computed(() =>
const orderGroups = computed(() => {
const groups = new Map<string, DispatchPalletCandidateDto[]>()
for (const item of visibleCandidates.value) {
const key = String(item.orderId || item.orderNo || item.planId)
const key = item.sourceStage === 'RELABEL' ? `RELABEL:${String(item.orderRefs || '')}` : String(item.orderId || item.orderNo || item.planId)
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.entries()].map(([key, pallets]) => ({
@@ -99,6 +103,7 @@ const containerOverweight = (container: LoadingContainer) => {
}
function palletId(item: DispatchPalletCandidateDto) {
if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
return String(item.planPalletId || item.warehousePalletId || '')
}
@@ -166,6 +171,7 @@ async function hydrateDraftPallets() {
}
async function load() {
if (draftBlocked.value) return
loading.value = true
loadError.value = null
try {
@@ -198,12 +204,12 @@ function reset() {
}
function addContainer() {
if (saving.value) return
if (saving.value || draftBlocked.value) return
selectedVehicle.value = ''
vehicleDialogOpen.value = true
}
async function confirmAddContainer() {
if (saving.value) return
if (saving.value || draftBlocked.value) return
const spec = selectedVehicleSpec.value
if (!spec) {
ElMessage.warning('请选择车型')
@@ -226,7 +232,7 @@ async function confirmAddContainer() {
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
const id = palletId(item)
if (saving.value || busyPalletIds.value.includes(id)) return
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const target = containers.value.find((container) => container.id === containerId)
if (!target || target.palletIds.includes(id)) return
if (!String(item.overseasWarehouseId || '').trim()) {
@@ -270,7 +276,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
}
async function removePallet(id: string) {
if (saving.value || busyPalletIds.value.includes(id)) return
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const owner = containerFor(id)
if (!owner) return
busyPalletIds.value.push(id)
@@ -286,13 +292,17 @@ async function removePallet(id: string) {
}
async function deleteContainer(container: LoadingContainer) {
if (saving.value) return
if (saving.value || draftBlocked.value) return
for (const id of [...container.palletIds]) {
await removePallet(id)
}
if (!container.palletIds.length) {
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
containers.value = containers.value.filter((item) => item.id !== container.id)
try {
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
containers.value = containers.value.filter((item) => item.id !== container.id)
} catch (error) {
mutationError.value = getApiErrorDetails(error, '删除装车车型失败,请重试')
}
}
}
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
@@ -311,11 +321,15 @@ async function close(value = false) {
async function submit() {
if (saving.value) return
if (draftBlocked.value) {
ElMessage.warning('排车草稿尚未恢复,请先重试恢复草稿;原选择已保留')
return
}
if (busyPalletIds.value.length) {
ElMessage.warning('托盘来源正在锁定,请稍后再预排车')
return
}
if (loading.value || unresolvedAssignedIds.value.length) {
if (loading.value || loadError.value || unresolvedAssignedIds.value.length) {
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
return
}
@@ -358,9 +372,16 @@ async function submit() {
mutationError.value = null
try {
if (relabelOrders.length) {
const latest = await listDispatchCandidates({ sourceStage: 'RELABEL', orderNo: relabelOrders[0], draftToken: ensureDraftToken() })
const required = latest.filter(item => item.sourceStage === 'RELABEL' && String(item.orderRefs || '').trim() === relabelOrders[0])
const selected = new Set(assignedIds.value)
if (!required.length || required.length !== selected.size || required.some(item => !selected.has(palletId(item)))) {
mutationError.value = { message: '换标必须一次选择本申请全部应排托盘,并放入同一装车容器;候选可能已变化,请重新查询核对,原选择已保留' }
return
}
await preArrangeDispatch({
planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(palletId),
returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
planPalletIds: assigned.value.map(palletId),
returnReceiptIds: [],
relabelNo: relabelOrders[0],
})
} else {
@@ -385,6 +406,10 @@ async function submit() {
}
emit('update:modelValue', false)
emit('created')
draftLoaded.value = false
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
ElMessage.success('排车明细已同步到预排列表')
await router.push({ name: 'overseas-dispatches' })
} catch (error) {
@@ -394,37 +419,72 @@ async function submit() {
}
}
async function restoreDraft() {
if (draftLoading.value) return
draftLoading.value = true
draftError.value = null
try {
const drafts = await listDispatchDrafts()
const draft = draftToken.value ? drafts.find(item => item.draftToken === draftToken.value) : drafts[0]
if (draftToken.value && containers.value.length && (!draft || containers.value.some(container =>
container.palletIds.some(id => palletSnapshot(id)?.sourceStage === 'RELABEL') && !draft.containers?.some(item => item.id === container.id)))) {
draftError.value = { message: '原草稿已失效或装车容器已变化,原选择已保留。请重试恢复;仍无法恢复时可释放草稿后重新选择。' }
return
}
if (draft?.draftToken) {
const restoredIds = new Set((draft.containers || []).flatMap(container => container.palletIds || []))
if (assignedIds.value.some(id => palletSnapshot(id)?.sourceStage !== 'RELABEL' && !restoredIds.has(id))) {
ElMessage.warning('原草稿部分来源锁已释放,已按服务端当前草稿恢复,请重新选择这些来源')
}
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
const localRelabel = containers.value.find(item => item.id === c.id)?.palletIds.filter(id => palletSnapshot(id)?.sourceStage === 'RELABEL') || []
return { id: c.id, capacity: c.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: [...new Set([...(c.palletIds || []), ...localRelabel])] }
})
} else {
ensureDraftToken()
}
draftLoaded.value = true
} catch (error) {
draftError.value = getApiErrorDetails(error, '排车草稿恢复失败,原选择已保留,请重试恢复后继续')
} finally {
draftLoading.value = false
}
if (draftLoaded.value && !draftError.value) await load()
}
async function restartDraft() {
if (draftLoading.value || saving.value || !draftToken.value) return
try {
await ElMessageBox.confirm('释放原草稿的普通及退回来源锁,并清空本页换标选择后重新开始?', '重新选择排车来源', { customClass: 'admin-density-confirm', confirmButtonText: '释放并重新选择', cancelButtonText: '保留草稿', type: 'warning' })
} catch { return }
draftLoading.value = true
try {
await releaseDispatchDraft(draftToken.value)
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
draftLoaded.value = false
draftError.value = null
} catch (error) {
draftError.value = getApiErrorDetails(error, '释放草稿失败,原选择已保留,请重试')
} finally { draftLoading.value = false }
if (!draftError.value) await restoreDraft()
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
draftToken.value = makeDraftToken()
containers.value = []
palletSnapshots.value = {}
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
mutationError.value = null
void (async () => {
const drafts = await listDispatchDrafts().catch(() => [])
const draft = drafts[0]
if (draft?.draftToken) {
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
return {
id: c.id,
capacity: c.capacity,
maxWeightLb: spec.maxWeightLb,
vehicleLabel: spec.label,
palletIds: c.palletIds || [],
}
})
}
await load()
})()
draftLoaded.value = false
void restoreDraft()
},
{ immediate: true },
)
@@ -445,7 +505,7 @@ watch([warehouseTab, () => query.orderType], () => {
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。沿用原入库库存和托盘,按本次换标箱唛及新目的仓排车,无需重新生成入库单。</p>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。</p>
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
<button
@@ -463,10 +523,12 @@ watch([warehouseTab, () => query.orderType], () => {
</button>
</nav>
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
<LoadErrorState v-if="draftError" title="排车草稿恢复失败" :message="draftError.message" :loading="draftLoading" @retry="restoreDraft"><template #details><ApiErrorMeta :details="draftError" /></template></LoadErrorState>
<p v-else-if="draftLoading" role="status">正在恢复排车草稿,请稍候…</p>
<LoadErrorState v-else-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
><template #details><ApiErrorMeta :details="loadError" /></template
></LoadErrorState>
<div v-else class="dispatch-workbench">
<div v-else v-loading="draftLoading" class="dispatch-workbench">
<section class="order-pane" aria-label="可排车入仓单">
<header>
<div>
@@ -549,7 +611,7 @@ watch([warehouseTab, () => query.orderType], () => {
</div>
</div>
</div>
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
<el-tag v-if="containerFor(palletId(item))" :type="item.sourceStage === 'RELABEL' ? 'warning' : 'success'" size="small">{{ item.sourceStage === 'RELABEL' ? '已选择,尚未锁定' : '已锁定' }}</el-tag>
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
<template #dropdown
@@ -599,7 +661,7 @@ watch([warehouseTab, () => query.orderType], () => {
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
><el-button class="density-action" data-tone="danger" :disabled="saving" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘;换标选择提交时校验,其他来源移入后锁定</span>
</div>
</div>
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
@@ -608,8 +670,8 @@ watch([warehouseTab, () => query.orderType], () => {
</div>
<template #footer><footer class="dialog-footer">
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
<span>普通/退回来源移入后锁定;换标选择尚未锁定,仅保留在当前页面。重量按 1kg = 2.2046lb 换算。</span>
<div><el-button v-if="draftError && draftToken" :disabled="draftLoading" @click="restartDraft">释放草稿并重新选择</el-button><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="draftBlocked || !assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
</footer></template>
</AppDialog>
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>