diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts b/src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts new file mode 100644 index 00000000..02deec05 --- /dev/null +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts @@ -0,0 +1,131 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ElMessage } from 'element-plus' +import { ApiResponseError, http } from '@/shared/api/http' +import { getPreloadPlan } from '../api/platformLoadingLists' +import PreloadPlanDetailView from './PreloadPlanDetailView.vue' + +const routerReplace = vi.hoisted(() => vi.fn()) +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { planId: '1900000000000000001' }, query: { warehouseId: '1900000000000000002' } }), + useRouter: () => ({ replace: routerReplace, push: vi.fn(), back: vi.fn() }), +})) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('../api/platformLoadingLists', () => ({ + confirmPreloadPlan: vi.fn(), + getPreloadPlan: vi.fn(), + updateLoadingItemWarehouseRemark: vi.fn(), + updatePreloadTransport: vi.fn(), +})) + +async function mountView() { + const wrapper = shallowMount(PreloadPlanDetailView, { + global: { + stubs: { + AdminPage: { template: '
' }, + LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['error', 'loading'], template: '
' }, + OperationErrorToast: true, + AppDialog: true, + }, + }, + }) + await flushPromises() + return wrapper +} + +function drawerError(wrapper: Awaited>) { + return wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error') +} + +function lifecycle(detailResource: 'PRELOAD' | 'LOADING_LIST', planStatus: string) { + return { code: '200', message: 'success', data: { planId: '1900000000000000001', detailResource, planStatus } } +} + +describe('PreloadPlanDetailView lifecycle handoff', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + routerReplace.mockResolvedValue(undefined) + }) + + it('replaces the current tab after authoritative lifecycle resolution', async () => { + vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'COMPLETED')) + const message = vi.spyOn(ElMessage, 'success').mockImplementation(() => undefined as never) + + const wrapper = await mountView() + + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/first-leg/loading-plans/1900000000000000001/lifecycle', + { params: { warehouseId: '1900000000000000002' } }, + ) + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(routerReplace).toHaveBeenCalledTimes(1) + expect(routerReplace).toHaveBeenCalledWith({ + name: 'loading-list-detail', + params: { planId: '1900000000000000001' }, + query: { warehouseId: '1900000000000000002' }, + }) + expect(message).toHaveBeenCalledWith('装柜已完成,已切换至装柜清单详情') + expect(drawerError(wrapper)).toBeNull() + expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false) + }) + + it('loads preload detail only while the lifecycle remains PRELOAD', async () => { + vi.spyOn(http, 'get').mockResolvedValue(lifecycle('PRELOAD', 'LOADING')) + vi.mocked(getPreloadPlan).mockResolvedValue({ + summary: { + status: 'LOADING', containerNo: null, + readiness: { preloadPushed: true, pdaTaskStatus: null }, + }, + items: [], histories: [], + } as never) + + const wrapper = await mountView() + + expect(getPreloadPlan).toHaveBeenCalledWith('1900000000000000001', '1900000000000000002') + expect(routerReplace).not.toHaveBeenCalled() + expect(drawerError(wrapper)).toBeNull() + }) + + it('shows A0404 only when lifecycle resolution confirms the plan is absent', async () => { + vi.spyOn(http, 'get').mockRejectedValue(new ApiResponseError('装柜计划不存在', 'A0404', 404, undefined, 'request-lifecycle', 'trace-lifecycle')) + + const wrapper = await mountView() + + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(routerReplace).not.toHaveBeenCalled() + expect(drawerError(wrapper)).toMatchObject({ code: 'A0404', requestId: 'request-lifecycle', traceId: 'trace-lifecycle', message: '装柜计划不存在' }) + }) + + it.each([ + ['A0403', 403, 'request-forbidden', 'trace-forbidden'], + ['S0503', 503, 'request-unavailable', 'trace-unavailable'], + ])('does not swallow a lifecycle %s error', async (code, status, requestId, traceId) => { + vi.spyOn(http, 'get').mockRejectedValue(new ApiResponseError('装柜计划解析失败', code, status, undefined, requestId, traceId)) + + const wrapper = await mountView() + + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(routerReplace).not.toHaveBeenCalled() + expect(drawerError(wrapper)).toMatchObject({ code, requestId, traceId }) + }) + + it('surfaces a network failure without guessing from another detail endpoint', async () => { + vi.spyOn(http, 'get').mockRejectedValue(new Error('Network Error')) + + const wrapper = await mountView() + + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(routerReplace).not.toHaveBeenCalled() + expect(drawerError(wrapper)).toMatchObject({ message: '预装清单详情加载失败,请稍后重试' }) + }) + + it('does not redirect for an internally inconsistent lifecycle payload', async () => { + vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'LOADING')) + + const wrapper = await mountView() + + expect(routerReplace).not.toHaveBeenCalled() + expect(drawerError(wrapper)).toMatchObject({ code: 'MODE_STATUS_MISMATCH' }) + }) +}) diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts b/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts index fa75c615..1e0ae5ed 100644 --- a/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.test.ts @@ -20,6 +20,17 @@ describe('PreloadPlanDetailView prototype contract', () => { expect(source).toContain('MODE_STATUS_MISMATCH') }) + it('switches a completed preload lifecycle to the confirmed detail without swallowing unrelated errors', () => { + expect(source).toContain('/loading-plans/${encodeURIComponent(planId.value)}/lifecycle') + expect(source).toContain("lifecycle.detailResource === 'LOADING_LIST'") + expect(source).toContain("lifecycle.planStatus !== 'COMPLETED'") + expect(source).toContain("router.replace({ name: 'loading-list-detail'") + expect(source).toContain('装柜已完成,已切换至装柜清单详情') + expect(source).not.toContain("preloadError.code !== 'A0404'") + expect(source).not.toContain('getConfirmedLoadingList') + expect(source).not.toContain("router.push({ name: 'loading-list-detail'") + }) + it('uses the real optimistic-lock transport endpoint without inventing note writes', () => { expect(source).toContain('updatePreloadTransport') expect(source).toContain('expectedVersion: detail.value.summary.version') diff --git a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue index 661a51a4..df31bfc5 100644 --- a/src/modules/loading-lists/pages/PreloadPlanDetailView.vue +++ b/src/modules/loading-lists/pages/PreloadPlanDetailView.vue @@ -7,7 +7,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' -import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue' import { confirmPreloadPlan, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists' @@ -36,6 +36,15 @@ const remarkOperationId = ref('') let loadSequence = 0 const planId = computed(() => String(route.params.planId || '')) const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '') +type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' } + +async function resolveLoadingPlanLifecycle() { + const response = await http.get, ApiEnvelope>( + `/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId.value)}/lifecycle`, + { params: { warehouseId: warehouseId.value } }, + ) + return unwrapApiResponse(response) +} const canEdit = computed(() => access.can('platform:firstleg:preload:edit')) const canCorrectTransport = computed(() => { const summary = detail.value?.summary @@ -76,6 +85,21 @@ async function loadDetail() { loading.value = true error.value = null try { + const lifecycle = await resolveLoadingPlanLifecycle() + if (sequence !== loadSequence) return + if (lifecycle.detailResource === 'LOADING_LIST') { + if (lifecycle.planStatus !== 'COMPLETED') { + error.value = { code: 'MODE_STATUS_MISMATCH', message: '装柜计划阶段异常,暂时无法切换详情,请稍后重试。' } + return + } + await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query: { warehouseId: warehouseId.value } }) + if (sequence === loadSequence) ElMessage.success('装柜已完成,已切换至装柜清单详情') + return + } + if (lifecycle.detailResource !== 'PRELOAD') { + error.value = { code: 'MODE_STATUS_MISMATCH', message: '无法识别当前装柜计划阶段,请稍后重试。' } + return + } const result = await getPreloadPlan(planId.value, warehouseId.value) if (sequence !== loadSequence) return if (!['EDITING', 'LOADING'].includes(result.summary.status)) {