From 3add5dc5b8aaf888f2cef7ad83efa19481c0cbaa Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 31 Aug 2026 15:58:38 +0800
Subject: [PATCH] fix(admin): resolve preload detail lifecycle
---
.../PreloadPlanDetailView.lifecycle.test.ts | 131 ++++++++++++++++++
.../pages/PreloadPlanDetailView.test.ts | 11 ++
.../pages/PreloadPlanDetailView.vue | 26 +++-
3 files changed, 167 insertions(+), 1 deletion(-)
create mode 100644 src/modules/loading-lists/pages/PreloadPlanDetailView.lifecycle.test.ts
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)) {