fix(overseas): restore pending resource confirmation from original command

This commit is contained in:
2026-10-05 05:40:21 +08:00
parent 28eb43893a
commit 578df0f927
2 changed files with 162 additions and 27 deletions
@@ -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<ReturnType<typeof mountView>>, rows:
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
function cabinetDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '按柜生成入库计划预览')!
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
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<ReturnType<typeof listContainerPlanProgress>>) => 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<ReturnType<typeof convertLastMileContainerToInboundPlans>>) => 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()
@@ -124,13 +124,20 @@ interface ContainerConversionResult {
existingPlanNos: Set<string>
newPlanCount: number
existingPlanCount: number
resourceRecovered: boolean
}
const containerConversionResult = ref<ContainerConversionResult | null>(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() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
@@ -1116,19 +1128,24 @@ onMounted(() => { void load() })
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview">
<div class="container-preview__context">
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || containerPreview.sourceContainerBookingId">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
</div>
<div class="container-preview__metrics">
<div v-if="!containerIsResourceRecovery" class="container-preview__metrics">
<div class="container-preview__metric"><span>头程已有托盘码</span><strong>{{ containerPreview.sourcePalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>海外仓补充托盘码</span><strong>{{ containerPreview.supplementPalletCount }}</strong></div>
<div class="container-preview__metric"><span>最终生成托盘</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
<div class="container-preview__metric"><span>本次处理订单</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
</div>
<div v-else class="container-preview__metrics">
<div class="container-preview__metric"><span>已保存计划托盘</span><strong>{{ containerPreview.totalPlannedPalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>资源确认</span><strong>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</strong></div>
</div>
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" class="container-convert-alert" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>生成完成</strong><span>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<header><strong>{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</strong><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
@@ -1140,8 +1157,9 @@ onMounted(() => { void load() })
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:最终 {{ group.pendingPalletCount }} 托</strong>
<span>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</strong>
<span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span>
<span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
</header>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>