From c3ea54dcd46ce135059d65110732a3fd8bf2ecd4 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 25 Aug 2026 19:56:37 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/components/AdminSidebarMenu.test.ts | 45 ++++--
src/app/components/AdminSidebarMenu.vue | 28 +++-
src/core/routing/navigationRecovery.test.ts | 80 ++++++++++
src/core/routing/navigationRecovery.ts | 74 +++++++++
src/main.ts | 29 +++-
.../api/platformContainerLoading.test.ts | 6 +-
.../api/platformContainerLoading.ts | 1 +
.../pages/ContainersView.test.ts | 145 +++++++++++++++---
.../pages/ContainersView.vue | 107 +++++++++----
.../pages/GlobalSmartLoadingView.test.ts | 2 +-
.../pages/SmartLoadingWorkbenchView.test.ts | 2 +-
src/modules/container-loading/types.ts | 2 +
.../api/platformOceanBookings.test.ts | 12 +-
.../api/platformOceanBookings.ts | 23 ++-
src/modules/ocean-bookings/index.ts | 2 +-
src/modules/ocean-bookings/types.ts | 24 +++
16 files changed, 511 insertions(+), 71 deletions(-)
create mode 100644 src/core/routing/navigationRecovery.test.ts
create mode 100644 src/core/routing/navigationRecovery.ts
diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts
index 8b259de6..116661c4 100644
--- a/src/app/components/AdminSidebarMenu.test.ts
+++ b/src/app/components/AdminSidebarMenu.test.ts
@@ -1,4 +1,6 @@
-import { mount } from '@vue/test-utils'
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
@@ -19,10 +21,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
]))
const stubs = {
- 'el-menu': {
+ 'el-menu': defineComponent({
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
- template: '',
- },
+ emits: ['select'],
+ methods: { updateActiveIndex() {} },
+ template: '',
+ }),
'el-sub-menu': {
props: ['index'],
template: '',
@@ -34,10 +38,20 @@ const stubs = {
}
describe('AdminSidebarMenu', () => {
+ function routerPlugin() {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: '
' } },
+ { path: '/warehouses', component: { template: '' } },
+ ],
+ })
+ }
+
it('renders the visible phase-one modules and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
- global: { stubs },
+ global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
@@ -62,7 +76,7 @@ describe('AdminSidebarMenu', () => {
it('forwards desktop collapse state to Element Plus', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/', collapsed: true },
- global: { stubs },
+ global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
@@ -72,7 +86,7 @@ describe('AdminSidebarMenu', () => {
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/', collapsed: false },
- global: { stubs },
+ global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.text()).toContain('菜单权限')
@@ -100,7 +114,7 @@ describe('AdminSidebarMenu', () => {
}]
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/tracking', collapsed: false },
- global: { stubs },
+ global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
@@ -110,7 +124,7 @@ describe('AdminSidebarMenu', () => {
it('recreates the menu when asynchronously loaded groups change', async () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
- global: { stubs },
+ global: { stubs, plugins: [routerPlugin()] },
})
const initialMenu = wrapper.get('nav').element
@@ -119,4 +133,17 @@ describe('AdminSidebarMenu', () => {
expect(wrapper.get('nav').element).not.toBe(initialMenu)
expect(wrapper.text()).toContain('系统管理')
})
+
+ it('navigates explicitly when a leaf menu item is selected', async () => {
+ const router = routerPlugin()
+ await router.push('/')
+ const wrapper = mount(AdminSidebarMenu, {
+ props: { items: visibleNavigation, activePath: '/', collapsed: false },
+ global: { stubs, plugins: [router] },
+ })
+
+ await wrapper.get('nav').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/warehouses')
+ })
})
diff --git a/src/app/components/AdminSidebarMenu.vue b/src/app/components/AdminSidebarMenu.vue
index 7577593e..b34e9ec6 100644
--- a/src/app/components/AdminSidebarMenu.vue
+++ b/src/app/components/AdminSidebarMenu.vue
@@ -1,5 +1,7 @@
diff --git a/src/core/routing/navigationRecovery.test.ts b/src/core/routing/navigationRecovery.test.ts
new file mode 100644
index 00000000..fac05a03
--- /dev/null
+++ b/src/core/routing/navigationRecovery.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ clearNavigationRecoveryAttempt,
+ isStaleRouteAssetError,
+ recoverNavigationError,
+ type NavigationRecoveryDependencies,
+} from './navigationRecovery'
+
+function memoryStorage() {
+ const values = new Map()
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ }
+}
+
+function dependencies(overrides: Partial = {}) {
+ return {
+ storage: memoryStorage(),
+ now: () => 1_000,
+ hasUnsavedWork: () => false,
+ confirmDiscard: vi.fn(async () => true),
+ discardUnsavedWork: vi.fn(),
+ navigate: vi.fn(),
+ showError: vi.fn(),
+ ...overrides,
+ } satisfies NavigationRecoveryDependencies
+}
+
+describe('navigationRecovery', () => {
+ it.each([
+ 'Failed to fetch dynamically imported module: /assets/page.js',
+ 'Importing a module script failed',
+ 'ChunkLoadError: Loading chunk 42 failed',
+ 'Loading CSS chunk page failed',
+ ])('recognizes stale deployed route assets: %s', (message) => {
+ expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
+ })
+
+ it('navigates once to the intended route after a stale lazy chunk fails', async () => {
+ const deps = dependencies()
+ await expect(recoverNavigationError(
+ new TypeError('Failed to fetch dynamically imported module'),
+ '/overseas-warehouse/inventory',
+ deps,
+ )).resolves.toBe(true)
+ expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
+ expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
+ expect(deps.showError).not.toHaveBeenCalled()
+ })
+
+ it('asks before discarding dirty work and stays put when the user cancels', async () => {
+ const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
+ await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
+ expect(deps.confirmDiscard).toHaveBeenCalledOnce()
+ expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
+ expect(deps.navigate).not.toHaveBeenCalled()
+ })
+
+ it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
+ const storage = memoryStorage()
+ const deps = dependencies({ storage })
+ const error = new Error('ChunkLoadError: Loading chunk page failed')
+ await recoverNavigationError(error, '/system/users', deps)
+ await recoverNavigationError(error, '/system/users', deps)
+ expect(deps.navigate).toHaveBeenCalledOnce()
+ expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
+ clearNavigationRecoveryAttempt(storage)
+ await recoverNavigationError(error, '/system/users', deps)
+ expect(deps.navigate).toHaveBeenCalledTimes(2)
+ })
+
+ it('reports ordinary router failures without reloading the application', async () => {
+ const deps = dependencies()
+ await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
+ expect(deps.navigate).not.toHaveBeenCalled()
+ expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
+ })
+})
diff --git a/src/core/routing/navigationRecovery.ts b/src/core/routing/navigationRecovery.ts
new file mode 100644
index 00000000..9852ab18
--- /dev/null
+++ b/src/core/routing/navigationRecovery.ts
@@ -0,0 +1,74 @@
+const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
+const RECOVERY_WINDOW_MS = 30_000
+
+const staleAssetErrorPatterns = [
+ /failed to fetch dynamically imported module/i,
+ /error loading dynamically imported module/i,
+ /importing a module script failed/i,
+ /chunkloaderror/i,
+ /loading (css )?chunk [\w-]+ failed/i,
+]
+
+export interface NavigationRecoveryDependencies {
+ storage: Pick
+ now: () => number
+ hasUnsavedWork: () => boolean
+ confirmDiscard: () => Promise
+ discardUnsavedWork: () => void
+ navigate: (target: string) => void
+ showError: (message: string) => void
+}
+
+interface RecoveryAttempt {
+ target: string
+ attemptedAt: number
+}
+
+function errorMessage(error: unknown) {
+ if (error instanceof Error) return error.message
+ return typeof error === 'string' ? error : ''
+}
+
+export function isStaleRouteAssetError(error: unknown) {
+ const message = errorMessage(error)
+ return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
+}
+
+function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
+ try {
+ const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
+ return Boolean(parsed
+ && parsed.target === target
+ && dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
+ } catch {
+ return false
+ }
+}
+
+export async function recoverNavigationError(
+ error: unknown,
+ target: string,
+ dependencies: NavigationRecoveryDependencies,
+) {
+ if (!isStaleRouteAssetError(error)) {
+ dependencies.showError('页面切换失败,请重试')
+ return false
+ }
+ if (recentAttempt(dependencies, target)) {
+ dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
+ return false
+ }
+ if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
+
+ dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
+ target,
+ attemptedAt: dependencies.now(),
+ } satisfies RecoveryAttempt))
+ dependencies.discardUnsavedWork()
+ dependencies.navigate(target)
+ return true
+}
+
+export function clearNavigationRecoveryAttempt(storage: Pick) {
+ storage.removeItem(RECOVERY_STORAGE_KEY)
+}
diff --git a/src/main.ts b/src/main.ts
index 4c269445..0ac17ad6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,11 +1,12 @@
import { computed, createApp } from 'vue'
import { createPinia } from 'pinia'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import App from './App.vue'
import router from '@/app/router'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
+import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
@@ -36,6 +37,32 @@ registerAuthSessionExpiredHandler(async () => {
})
})
+router.onError((error, to) => {
+ void recoverNavigationError(error, to.fullPath, {
+ storage: sessionStorage,
+ now: Date.now,
+ hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
+ confirmDiscard: async () => {
+ try {
+ await ElMessageBox.confirm(
+ '检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
+ '重新加载页面?',
+ { confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning' },
+ )
+ return true
+ } catch {
+ return false
+ }
+ },
+ discardUnsavedWork: () => unsaved.clear(),
+ navigate: (target) => window.location.assign(target),
+ showError: (message) => ElMessage.error(message),
+ })
+})
+router.afterEach((_to, _from, failure) => {
+ if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
+})
+
window.addEventListener('beforeunload', (event) => {
if (!unsaved.entries.some((entry) => entry.dirty)) return
event.preventDefault()
diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts
index e3d24eab..e43aff2b 100644
--- a/src/modules/container-loading/api/platformContainerLoading.test.ts
+++ b/src/modules/container-loading/api/platformContainerLoading.test.ts
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: (response: { data: T }) => response.data,
}))
-const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
+const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const plan = {
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
@@ -38,14 +38,14 @@ describe('platform container loading API', () => {
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
- expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
+ expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
})
it('uses full-edit and transport whitelist request shapes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
- const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
+ const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts
index aa637ef6..083b835a 100644
--- a/src/modules/container-loading/api/platformContainerLoading.ts
+++ b/src/modules/container-loading/api/platformContainerLoading.ts
@@ -76,6 +76,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
customerId: optionalId(raw.customerId),
shippingCompanyId: optionalId(raw.shippingCompanyId),
+ terminalTruckingCompanyId: optionalId(raw.terminalTruckingCompanyId),
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts
index 4f41fc2f..87f4423c 100644
--- a/src/modules/container-loading/pages/ContainersView.test.ts
+++ b/src/modules/container-loading/pages/ContainersView.test.ts
@@ -9,13 +9,13 @@ import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
-import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
-import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
+import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
+import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
-vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
+vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
@@ -38,9 +38,10 @@ describe('ContainersView', () => {
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
+ vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
- vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
- vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
+ vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
+ vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -75,7 +76,7 @@ describe('ContainersView', () => {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
- terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
@@ -104,7 +105,7 @@ describe('ContainersView', () => {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
- terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
@@ -264,17 +265,125 @@ describe('ContainersView', () => {
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
- it('links a simulation to a released requirement and records the port container number', () => {
- const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
- expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
- expect(source).toContain('booking.containerRequirements')
- expect(source).toContain('booking.containers.filter((container) => container.requirementId === requirement.requirementId).length')
- expect(source).toContain('请输入港口取得的真实集装箱号')
- expect(source).toContain('oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo')
- expect(source).toContain(':disabled="candidate.disabled"')
- expect(source).not.toContain('realContainerBookingId')
- expect(source).not.toContain('MERGED')
- expect(source).not.toContain('解除订舱关联')
+ it('loads one frozen candidate page per open and ignores repeated clicks while it is pending', async () => {
+ let resolveCandidates!: (value: Awaited>) => void
+ vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+ await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
+ await flushPromises()
+ const linkAction = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item'))
+ .find((item) => item.textContent?.includes('关联已放舱订舱'))!
+
+ linkAction.click()
+ linkAction.click()
+
+ expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
+ expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, page: 1, size: 200 })
+ expect(getOceanBooking).not.toHaveBeenCalled()
+ resolveCandidates({ records: [{ oceanBookingId: '101', bookingNo: 'OB-1', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
+ await flushPromises()
+ expect(document.body.textContent).toContain('OB-1 · 40HC × 1')
+ wrapper.unmount()
+ })
+
+ it('discards a candidate response from a closed link session', async () => {
+ let resolveFirst!: (value: Awaited>) => void
+ let resolveSecond!: (value: Awaited>) => void
+ vi.mocked(listOceanBookingLinkCandidates)
+ .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
+ .mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
+ await flushPromises()
+ Array.from(document.body.querySelectorAll('.el-dropdown-menu__item'))
+ .find((item) => item.textContent?.includes('关联已放舱订舱'))!.click()
+ await flushPromises()
+ Array.from(document.body.querySelectorAll('button'))
+ .find((button) => button.textContent?.trim() === '取消')!.click()
+ await flushPromises()
+
+ await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
+ await flushPromises()
+ Array.from(document.body.querySelectorAll('.el-dropdown-menu__item'))
+ .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
+ resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
+ await flushPromises()
+ resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
+ await flushPromises()
+
+ expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
+ expect(document.body.textContent).toContain('OB-LATEST')
+ expect(document.body.textContent).not.toContain('OB-STALE')
+ wrapper.unmount()
+ })
+
+ it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
+ vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
+ vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
+ vi.mocked(updateContainer).mockResolvedValue({} as never)
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
+ await flushPromises()
+ await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
+ await flushPromises()
+
+ expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
+ terminalTruckingCompanyId: '9007199254740998',
+ terminalTruckingCompany: '测试拖车供应商',
+ }))
+ })
+
+ it('clears the trucking supplier id and name together', async () => {
+ vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
+ vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
+ vi.mocked(updateContainer).mockResolvedValue({} as never)
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
+ await flushPromises()
+ const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
+ truckingSelect!.vm.$emit('update:modelValue', '')
+ await flushPromises()
+ await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
+ await flushPromises()
+
+ expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
+ terminalTruckingCompanyId: null,
+ terminalTruckingCompany: null,
+ }))
+ })
+
+ it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
+ vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
+ vi.mocked(updateContainer).mockClear()
+ const router = createTestRouter()
+ await router.push('/'); await router.isReady()
+ const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
+ await flushPromises()
+
+ await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
+ await flushPromises()
+ const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
+ expect(truckingSelect?.props('modelValue')).toBe('')
+ await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
+ await flushPromises()
+
+ expect(updateContainer).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
})
})
diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue
index b259e8e8..212391de 100644
--- a/src/modules/container-loading/pages/ContainersView.vue
+++ b/src/modules/container-loading/pages/ContainersView.vue
@@ -8,7 +8,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
-import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
+import { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -92,8 +92,10 @@ const linkCandidateKey = ref('')
const linkContainerNo = ref('')
const linkCandidates = ref>([])
const selectedTruckingSupplierId = ref('')
+const unresolvedTruckingSupplierName = ref(null)
let listSequence = 0
let detailSequence = 0
+let linkSequence = 0
function emptyForm(): ContainerForm {
return {
@@ -101,7 +103,7 @@ function emptyForm(): ContainerForm {
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
- capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
+ capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -110,7 +112,7 @@ const form = reactive(emptyForm())
const rules: FormRules = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
- terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
+ terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
}
const canView = computed(() => access.can('platform:firstleg:container:list'))
@@ -205,10 +207,30 @@ function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
- selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
+ reconcileTruckingSupplier()
+}
+function reconcileTruckingSupplier() {
+ const supplierId = optional(form.terminalTruckingCompanyId)
+ const supplierName = optional(form.terminalTruckingCompany) || unresolvedTruckingSupplierName.value
+ const supplier = supplierId
+ ? truckingCompanies.value.find((item) => item.id === supplierId)
+ : truckingCompanies.value.find((item) => item.name === supplierName)
+ selectedTruckingSupplierId.value = supplier?.id || ''
+ unresolvedTruckingSupplierName.value = supplier ? null : supplierName
+ if (supplier) {
+ form.terminalTruckingCompanyId = supplier.id
+ form.terminalTruckingCompany = supplier.name
+ } else if (!supplierName) {
+ form.terminalTruckingCompanyId = null
+ form.terminalTruckingCompany = null
+ } else {
+ form.terminalTruckingCompanyId = null
+ form.terminalTruckingCompany = supplierName
+ }
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
+ const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
return {
containerNo: optional(form.containerNo),
warehouseId: form.warehouseId,
@@ -219,7 +241,9 @@ function payload(): ContainerSaveRequest {
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
- capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
+ capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg,
+ terminalTruckingCompanyId: selectedTruckingSupplier?.id || null,
+ terminalTruckingCompany: selectedTruckingSupplier?.name || null,
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
@@ -230,8 +254,11 @@ function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
function selectTruckingSupplier(supplierId: string) {
+ if (!supplierId && unresolvedTruckingSupplierName.value) return
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
selectedTruckingSupplierId.value = supplier?.id || ''
+ unresolvedTruckingSupplierName.value = null
+ form.terminalTruckingCompanyId = supplier?.id || null
form.terminalTruckingCompany = supplier?.name || null
}
function portLabel(port: PortOption) {
@@ -300,7 +327,10 @@ async function loadBusinessOptions() {
])
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
- if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
+ if (supplierResult.status === 'fulfilled') {
+ truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
+ if (form.id) reconcileTruckingSupplier()
+ }
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
@@ -351,14 +381,19 @@ async function saveSimulation() {
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
-async function loadReleasedBookings() {
- const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
- if (first.pages <= 1) return first.records
- const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
- return [first, ...rest].flatMap((result) => result.records)
-}
async function openLink(source: object) {
+ if (linkLoading.value) return
const row = source as ContainerBookingResource
+ const warehouseId = optional(row.warehouseId)
+ const channelCode = optional(row.channelCode)
+ if (!warehouseId || !channelCode || row.capacityCbm == null || row.maxWeightKg == null) {
+ linkTarget.value = row
+ linkCandidates.value = []
+ linkError.value = { message: '当前模拟柜缺少仓库、渠道或固定柜型规格,无法查询可关联订舱' }
+ linkOpen.value = true
+ return
+ }
+ const sequence = ++linkSequence
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
@@ -367,27 +402,24 @@ async function openLink(source: object) {
linkOpen.value = true
linkLoading.value = true
try {
- const bookings = await loadReleasedBookings()
- const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
- linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
- .map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
- .filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
- .map((requirement) => {
- const specificationMatches = booking.warehouseId === row.warehouseId
- && booking.channelCode === row.channelCode
- && requirement.containerType === row.containerType
- && Number(requirement.capacityCbm) === Number(row.capacityCbm)
- && Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
- return {
- key: `${booking.id}:${requirement.requirementId}`,
- oceanBookingId: booking.id,
- requirementId: String(requirement.requirementId),
- disabled: !specificationMatches,
- label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
- }
- }))
- } catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
- finally { linkLoading.value = false }
+ const result = await listOceanBookingLinkCandidates({
+ warehouseId, channelCode, containerType: row.containerType,
+ capacityCbm: row.capacityCbm, maxWeightKg: row.maxWeightKg,
+ page: 1, size: 200,
+ })
+ if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
+ linkCandidates.value = result.records.map((candidate) => ({
+ key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
+ oceanBookingId: candidate.oceanBookingId,
+ requirementId: candidate.requirementId,
+ disabled: false,
+ label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
+ }))
+ } catch (error) {
+ if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
+ } finally {
+ if (sequence === linkSequence) linkLoading.value = false
+ }
}
async function saveLink() {
const target = linkTarget.value
@@ -521,6 +553,10 @@ async function openEdit(source: object) {
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
+ if (unresolvedTruckingSupplierName.value) {
+ editorError.value = { message: '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
+ return
+ }
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
@@ -564,6 +600,11 @@ function goContainerProcess(source: object) {
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
+watch(linkOpen, (open) => {
+ if (open) return
+ linkSequence += 1
+ linkLoading.value = false
+})
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
index 77c2c289..d31afa41 100644
--- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
+++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
@@ -45,7 +45,7 @@ const container = {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
- terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const plan = {
diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
index 6464f1b1..2621cc3c 100644
--- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
+++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
@@ -22,7 +22,7 @@ const container = {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
- terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
+ terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const planResponse = {
diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts
index f2bde19b..e2cdd977 100644
--- a/src/modules/container-loading/types.ts
+++ b/src/modules/container-loading/types.ts
@@ -32,6 +32,7 @@ export interface ContainerBookingResource {
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
+ terminalTruckingCompanyId: string | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
@@ -205,6 +206,7 @@ export interface ContainerSaveRequest {
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
+ terminalTruckingCompanyId: string | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts
index 1f48ce55..ce575c44 100644
--- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts
+++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { createOceanBooking, deleteOceanBookingDraft, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
+import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'DRAFT') => ({
@@ -23,6 +23,16 @@ describe('ocean bookings api', () => {
expect(result.records[0]).toMatchObject({ shipperName: '发货企业', consigneeName: '收货企业', containerCount: 3 })
})
+ it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
+ vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
+ const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, page: 1, size: 200 }
+
+ const result = await listOceanBookingLinkCandidates(query)
+
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/link-candidates', { params: query })
+ expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 })
+ })
+
it('uses version zero for create and extracts the id from the returned Detail', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] } })
diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts
index 68896d93..374ae1ef 100644
--- a/src/modules/ocean-bookings/api/platformOceanBookings.ts
+++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
+import type { OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
@@ -59,6 +59,27 @@ export async function listOceanBookings(query: OceanBookingQuery) {
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
}
+export async function listOceanBookingLinkCandidates(query: OceanBookingLinkCandidateQuery) {
+ const response = await http.get, ApiEnvelope>(`${basePath}/link-candidates`, { params: query })
+ const result = unwrapApiResponse(response)
+ return {
+ ...result,
+ records: (result.records || []).map((candidate) => ({
+ ...candidate,
+ oceanBookingId: String(candidate.oceanBookingId),
+ requirementId: String(candidate.requirementId),
+ capacityCbm: Number(candidate.capacityCbm),
+ maxWeightKg: Number(candidate.maxWeightKg),
+ requiredQuantity: Number(candidate.requiredQuantity),
+ allocatedQuantity: Number(candidate.allocatedQuantity),
+ remainingQuantity: Number(candidate.remainingQuantity),
+ })),
+ page: pageNumber(result.page, query.page),
+ size: pageNumber(result.size, query.size),
+ total: pageNumber(result.total, 0),
+ pages: pageNumber(result.pages, 0),
+ } satisfies OceanBookingLinkCandidatePage
+}
export async function getOceanBooking(id: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
diff --git a/src/modules/ocean-bookings/index.ts b/src/modules/ocean-bookings/index.ts
index a0ec8c0e..5bfe4427 100644
--- a/src/modules/ocean-bookings/index.ts
+++ b/src/modules/ocean-bookings/index.ts
@@ -1,4 +1,4 @@
export { oceanBookingRoutes } from './routes'
-export { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
+export { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
export * from './status'
export * from './types'
diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts
index c1d4ce41..5801024e 100644
--- a/src/modules/ocean-bookings/types.ts
+++ b/src/modules/ocean-bookings/types.ts
@@ -52,5 +52,29 @@ export interface OceanBookingDetail extends OceanBookingResource { oceanBooking:
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
+export interface OceanBookingLinkCandidate {
+ oceanBookingId: string
+ bookingNo: string
+ requirementId: string
+ containerType: string
+ capacityCbm: number
+ maxWeightKg: number
+ requiredQuantity: number
+ allocatedQuantity: number
+ remainingQuantity: number
+ customerName: string
+ warehouseName: string
+ channelName: string
+}
+export interface OceanBookingLinkCandidatePage { records: OceanBookingLinkCandidate[]; page: number; size: number; total: number; pages: number }
+export interface OceanBookingLinkCandidateQuery {
+ warehouseId: string
+ channelCode: string
+ containerType: string
+ capacityCbm: number
+ maxWeightKg: number
+ page: number
+ size: number
+}
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
export interface OceanBookingReleaseRequest { expectedVersion: number }