fix(dispatch): preserve return sources when restoring prearrangement drafts

This commit is contained in:
2026-10-03 15:57:53 +08:00
parent 6a135c3982
commit 9fa73f50d6
7 changed files with 107 additions and 9 deletions
@@ -0,0 +1,58 @@
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 SmartDispatchDialog from './SmartDispatchDialog.vue'
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, type DispatchPalletCandidateDto } 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('../api/dispatches', () => ({
listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(),
createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
}))
const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) {
return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.())
} })
const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) {
return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载')
} })
const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN',
warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' }
const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT',
warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' }
beforeEach(() => vi.resetAllMocks())
it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }])
vi.mocked(listDispatchCandidates)
.mockResolvedValueOnce([plan])
.mockRejectedValueOnce(new Error('恢复来源失败'))
.mockResolvedValueOnce([plan])
.mockResolvedValueOnce([plan, returned])
const wrapper = 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,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
await flushPromises()
const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')!
await submit().trigger('click')
await flushPromises()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="retry"]').trigger('click')
await flushPromises()
await submit().trigger('click')
await flushPromises()
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11'], returnReceiptIds: ['22'], draftToken: 'original-draft' })
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' })
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
@@ -84,6 +84,7 @@ const orderGroups = computed(() => {
})
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
const unresolvedAssignedIds = computed(() => assignedIds.value.filter(id => !palletSnapshot(id)))
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
@@ -98,6 +99,7 @@ const containerOverweight = (container: LoadingContainer) => {
}
function palletId(item: DispatchPalletCandidateDto) {
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
return String(item.planPalletId || item.warehousePalletId || '')
}
function text(value: unknown) {
@@ -175,6 +177,8 @@ async function load() {
draftToken: ensureDraftToken(),
})
rememberPallets(candidates.value)
if (unresolvedAssignedIds.value.length) await hydrateDraftPallets()
if (unresolvedAssignedIds.value.length) throw new Error('草稿中部分已选来源尚未恢复,请重试加载;原选择已保留')
activeOrderKey.value = orderGroups.value[0]?.key || ''
} catch (error) {
candidates.value = []
@@ -300,6 +304,10 @@ async function close(value = false) {
}
async function submit() {
if (loading.value || unresolvedAssignedIds.value.length) {
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
return
}
if (!assignedIds.value.length) {
ElMessage.warning('请至少移入一个托盘')
return
@@ -322,7 +330,8 @@ async function submit() {
mutationError.value = null
try {
await preArrangeDispatch({
planPalletIds: assignedIds.value,
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)),
...(relabelOrders.length ? { relabelNo: relabelOrders[0] } : { draftToken: ensureDraftToken() }),
})
emit('update:modelValue', false)
@@ -364,7 +373,6 @@ watch(
palletIds: c.palletIds || [],
}
})
await hydrateDraftPallets().catch(() => undefined)
}
await load()
})()
@@ -484,6 +492,7 @@ watch([warehouseTab, () => query.orderType], () => {
<div class="pallet-copy">
<strong>{{ text(item.palletNo) }}</strong
><span>{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}</span>
<span v-if="item.sourceType === 'RETURN_RECEIPT'">退回实收:{{ text(item.returnReceiptNo) }}</span>
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
<span>{{ mark }}</span