fix(dispatch): align dispatch actions and native return workflows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user