From 578df0f927f8408b414f33c5769b946a12979681 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 05:40:21 +0800 Subject: [PATCH] fix(overseas): restore pending resource confirmation from original command --- .../pages/LastMileOrdersView.behavior.test.ts | 149 ++++++++++++++++-- .../pages/LastMileOrdersView.vue | 40 +++-- 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 002c4f7c..60002795 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -91,6 +91,26 @@ const preview = { }], }], } +// Recorded LOCAL_COMMITTED shape: complete local plan/pallet, zero new work, PLANNED, +// and an explicit server resourceRetry holding the original command identity. +const resourceCommittedPreview = { + ...preview, snapshotVersion: 10, snapshotHash: 'b'.repeat(64), + totalOrderCount: 1, readyOrderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0, + sourcePalletCount: 0, supplementPalletCount: 0, existingPlannedPalletCount: 1, + totalSuggestedPalletCount: 1, totalPlannedPalletCount: 1, + groups: [{ + ...preview.groups[0], destinationType: 'FBA' as const, + orderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0, sourcePalletCount: 0, + supplementPalletCount: 0, existingPlannedPalletCount: 1, suggestedPalletCount: 1, plannedPalletCount: 1, + orders: [{ ...preview.groups[0].orders[0], destinationType: 'FBA' as const, fbaWarehouseCode: 'ONT8', + packageCount: 1, suggestedPalletCount: 1, sourcePalletCount: 1, targetPalletCount: 1, + supplementPalletCount: 0, plannedPalletCount: 1, existingPlanNo: 'LIP-ORIGINAL', + planStatus: 'PENDING_ASSIGNMENT', readiness: 'PLANNED' as const, blockers: [], + }], + }], + resourceRetry: { idempotencyKey: 'original-container-command', snapshotVersion: 9, snapshotHash: 'a'.repeat(64) }, +} +const originalResourcePlans = [{ planNo: 'LIP-ORIGINAL', pallets: [{ id: 'PALLET-ORIGINAL', palletNo: 'PLT-ORIGINAL' }] }] const receivingWarehouse = { ...fbaWarehouse, id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS', @@ -144,6 +164,9 @@ async function selectRows(wrapper: Awaited>, rows: function button(wrapper: Awaited>, text: string) { return wrapper.findAll('button').find((item) => item.text().includes(text)) } +function cabinetDialog(wrapper: Awaited>) { + return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '按柜生成入库计划预览')! +} async function openContainerDialog(wrapper: Awaited>) { await button(wrapper, '按柜批量生成')!.trigger('click') @@ -729,32 +752,126 @@ describe('LastMileOrdersView pending order and container conversion', () => { records: [{ ...summary, pendingPlanCount: 0, resourcePendingPlanCount: 1, allPlansReady: true }], page: 1, size: 10, total: 1, pages: 1, }) - const reopenedPreview = { - ...preview, snapshotVersion: 10, snapshotHash: 'b'.repeat(64), - groups: preview.groups.map((group) => ({ - ...group, destinationType: 'FBA' as const, - orders: group.orders.map((order) => ({ - ...order, destinationType: 'FBA' as const, fbaWarehouseCode: 'ONT8', - readiness: 'RESOURCE_PENDING' as const, existingPlanNo: 'LIP-ORIGINAL', plannedPalletCount: 5, - })), - })), - resourceRetry: { idempotencyKey: 'original-container-command', snapshotVersion: 9, snapshotHash: 'a'.repeat(64) }, - } - vi.mocked(getContainerInboundPlanPreview).mockResolvedValueOnce(reopenedPreview) + vi.mocked(getContainerInboundPlanPreview).mockResolvedValueOnce(resourceCommittedPreview) + vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never) const wrapper = await mountView() await openHiddenContainerWorkbench(wrapper) await button(wrapper, '重试生成托盘码')!.trigger('click') await flushPromises() - const confirm = [...document.body.querySelectorAll('button')] - .find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement - expect(confirm.disabled).toBe(false) - confirm.click() + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false) + expect(cabinetDialog(wrapper).props('confirmText')).toBe('重试资源确认') + cabinetDialog(wrapper).vm.$emit('confirm') await flushPromises() expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64), }, 'original-container-command') }) + it('reopens a complete local plan and confirms only the original resource key/version/hash without showing zero generation', async () => { + const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never)) + vi.mocked(getContainerInboundPlanPreview) + .mockResolvedValueOnce(resourceCommittedPreview) + .mockResolvedValueOnce(resourceCommittedPreview) + .mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) + vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never) + const wrapper = await openPending() + await openContainerDialog(wrapper); await chooseContainer(wrapper) + cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises() + await openContainerDialog(wrapper); await chooseContainer(wrapper) + const dialog = cabinetDialog(wrapper) + expect(dialog.props('confirmDisabled')).toBe(false) + expect(dialog.props('confirmText')).toBe('重试资源确认') + expect(document.body.textContent).toContain('资源仍待确认') + expect(document.body.textContent).not.toContain('最终生成 0 个托盘') + expect(document.body.textContent).not.toContain('最终 0 托') + dialog.vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledExactlyOnceWith({ + sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64), + }, 'original-container-command') + expect(document.body.textContent).toContain('资源确认完成') + expect(document.body.textContent).toContain('PLT-ORIGINAL') + expect(cabinetDialog(wrapper).props('confirmText')).toBe('已确认') + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + expect(success).toHaveBeenCalledWith('已完成 1 个已有计划的资源确认') + expect(createManualInboundPlan).not.toHaveBeenCalled() + }) + + it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => { + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) + const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成') + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + }) + + it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => { + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never) + const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const identity = document.body.querySelector('.container-preview__identity')! + expect(identity.textContent).toBe('未录入货柜号') + expect(identity.getAttribute('title')).toBe('未录入货柜号') + expect(identity.getAttribute('title')).not.toContain(bookingId) + }) + + it('keeps server readiness blocks effective even for a resource retry', async () => { + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 }) + const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + }) + + it('guards resource retry at the handler when the caller lacks create permission', async () => { + canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create') + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) + const wrapper = await openPending() + const view = wrapper.vm as unknown as { openContainerConversion(): void } + view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + }) + + it('keeps preview and candidate loading guards effective for recovery commands', async () => { + let resolvePreview!: (value: typeof resourceCommittedPreview) => void + let resolveOptions!: (value: Awaited>) => void + vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve })) + vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve })) + const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + resolvePreview(resourceCommittedPreview); await flushPromises() + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + resolveOptions({ records: [summary], page: 1, size: 50, total: 1, pages: 1 }); await flushPromises() + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false) + }) + + it('preserves the original recovery command on failure and blocks duplicate or completed submissions', async () => { + const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never)) + vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) + vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503)) + const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(cabinetDialog(wrapper).props('modelValue')).toBe(true) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false) + expect(success).not.toHaveBeenCalled() + let resolveCommand!: (value: Awaited>) => void + vi.mocked(convertLastMileContainerToInboundPlans).mockReturnValueOnce(new Promise(resolve => { resolveCommand = resolve })) + cabinetDialog(wrapper).vm.$emit('confirm'); cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2) + expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) + resolveCommand(originalResourcePlans as never); await flushPromises() + cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2) + for (const args of vi.mocked(convertLastMileContainerToInboundPlans).mock.calls) expect(args).toEqual([ + { sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64) }, 'original-container-command', + ]) + }) + it('renders the all-cabinet empty state and unit', async () => { vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) const wrapper = await mountView() diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 3196fc25..428e047e 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -124,13 +124,20 @@ interface ContainerConversionResult { existingPlanNos: Set newPlanCount: number existingPlanCount: number + resourceRecovered: boolean } const containerConversionResult = ref(null) let containerPreviewRequest = 0 let containerPreviewLoadingBookingId = '' let containerConversionIdempotencyKey = '' -const containerConfirmDisabled = computed(() => !containerPreview.value?.ready - || containerPreview.value.pendingOrderCount <= 0 +const containerIsResourceRecovery = computed(() => Boolean(containerPreview.value?.resourceRetry || containerConversionResult.value?.resourceRecovered)) +const containerConfirmText = computed(() => containerConversionResult.value + ? containerConversionResult.value.resourceRecovered ? '已确认' : '已生成' + : containerPreview.value?.resourceRetry ? '重试资源确认' : '确认生成') +const containerConfirmDisabled = computed(() => !access.can(permission.inboundOrderCreate) + || !containerPreview.value?.ready + || (!containerPreview.value.resourceRetry && containerPreview.value.pendingOrderCount <= 0) + || containerOptionsLoading.value || containerPreviewLoading.value || actionLoading.value || Boolean(containerConversionResult.value)) @@ -755,8 +762,10 @@ async function loadContainerPreview(sourceContainerBookingId = selectedContainer async function confirmContainerInboundPlans() { const preview = containerPreview.value - if (!preview?.ready || preview.pendingOrderCount <= 0 || actionLoading.value) return - if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan') + if (!preview || containerConfirmDisabled.value) return + if (preview.resourceRetry) containerConversionIdempotencyKey = preview.resourceRetry.idempotencyKey + else if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan') + const resourceRecovery = Boolean(preview.resourceRetry) const bookingId = preview.sourceContainerBookingId const commandSnapshot = preview.resourceRetry ?? preview const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders) @@ -777,8 +786,11 @@ async function confirmContainerInboundPlans() { existingPlanNos, newPlanCount: plans.length - existingPlanCount, existingPlanCount, + resourceRecovered: resourceRecovery, } - ElMessage.success(`已生成 ${plans.length - existingPlanCount} 个入库计划`) + ElMessage.success(resourceRecovery + ? `已完成 ${plans.length} 个已有计划的资源确认` + : `已生成 ${plans.length - existingPlanCount} 个入库计划`) containerPreview.value = null await Promise.all([load(), loadContainerPreview(bookingId, true)]) } catch (error) { @@ -1101,7 +1113,7 @@ onMounted(() => { void load() }) - +

先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。

@@ -1116,19 +1128,24 @@ onMounted(() => { void load() })
-
{{ containerPreview.containerNo || '未录入货柜号' }}
+
{{ containerPreview.containerNo || '未录入货柜号' }}
{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}
-
+
头程已有托盘码{{ containerPreview.sourcePalletCount }}
海外仓补充托盘码{{ containerPreview.supplementPalletCount }}
最终生成托盘{{ containerPreview.pendingPalletCount }}
本次处理订单{{ containerPreview.pendingOrderCount }}
+
+
已保存计划托盘{{ containerPreview.totalPlannedPalletCount }}
+
资源确认{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}
+
+
-
生成完成新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个
+
{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}已确认 {{ containerConversionResult.plans.length }} 个已有计划新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个
{{ plan.planNo }}{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}
@@ -1140,8 +1157,9 @@ onMounted(() => { void load() })
- {{ previewGroupTitle(group) }}:最终 {{ group.pendingPalletCount }} 托 - 头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票 + {{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }} + 已保存计划托数 {{ group.plannedPalletCount }} + 头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票