From 4dec54feef953fffc2aaf05bf1ded3ca49ed9fb0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 19:02:34 +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/router/index.test.ts | 15 ++--- src/core/navigation/adminNavigation.test.ts | 7 +-- src/core/navigation/adminNavigation.ts | 1 - .../components/SmartLoadingDialog.vue | 30 +++++++++ .../pages/ContainersView.test.ts | 6 +- .../pages/GlobalSmartLoadingView.test.ts | 38 ++++++++++-- .../pages/GlobalSmartLoadingView.vue | 61 +++++++++++++++++-- .../pages/SmartLoadingWorkbenchView.test.ts | 4 +- src/modules/container-loading/routes.ts | 6 -- src/modules/container-loading/types.ts | 4 ++ .../pages/OceanBookingDetailView.test.ts | 33 ++++++++-- .../pages/OceanBookingDetailView.vue | 51 ++++++++++++---- .../pages/UnifiedOrdersView.test.ts | 7 +++ .../pages/UnifiedOrdersView.vue | 6 ++ 14 files changed, 215 insertions(+), 54 deletions(-) create mode 100644 src/modules/container-loading/components/SmartLoadingDialog.vue diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index d432cb1c..6e444b7e 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -50,16 +50,11 @@ describe('admin router access metadata', () => { expect(route?.meta.permission).toBe('platform:express:list') }) - it('registers the global smart-loading route before the single-container deep link', () => { - const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench') - const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || [] - const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench') - const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench') - - expect(route?.path).toBe('/container-loading/smart-loading') - expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list') - expect(globalIndex).toBeGreaterThanOrEqual(0) - expect(detailIndex).toBeGreaterThan(globalIndex) + it('keeps smart loading as an order dialog while retaining the single-container deep link', () => { + expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false) + const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench') + expect(detail?.path).toBe('/container-loading/:containerId/smart-loading') + expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list') }) it('registers the retained phase-three service center routes with stable permissions', () => { diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index cd36db8d..7b21bc1f 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -45,7 +45,6 @@ describe('adminNavigation', () => { '订舱列表', '拖车列表', '货柜列表', - '智能装柜', '预装清单', '装柜清单', ]) @@ -91,7 +90,7 @@ describe('adminNavigation', () => { expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine'])) expect(paths).toContain('/master-data/fee-items') expect(paths).toContain('/container-loading') - expect(paths).toContain('/container-loading/smart-loading') + expect(paths).not.toContain('/container-loading/smart-loading') expect(paths).toContain('/loading-plans') expect(paths).toContain('/loading-lists') expect(paths).not.toContain('/port-schedules') @@ -192,10 +191,9 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard']) }) - it('shows each formal container workflow entry only with its matching permission', () => { + it('shows container list and downstream lists without a standalone smart-loading menu', () => { const filtered = filterAdminNavigation(adminNavigation, new Set([ 'platform:firstleg:container:list', - 'platform:firstleg:smart-loading:list', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list', ])) @@ -203,7 +201,6 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', '/container-loading', - '/container-loading/smart-loading', '/loading-plans', '/loading-lists', ]) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index b0416ea2..919c88e0 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -102,7 +102,6 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, { key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' }, - { key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' }, { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, ], diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue new file mode 100644 index 00000000..9542f7fb --- /dev/null +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 75a1b934..c75213be 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -35,8 +35,8 @@ describe('ContainersView', () => { vi.mocked(listAllPlatformWarehouses).mockResolvedValue([]) vi.mocked(listPlatformPartners).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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', 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: 68, 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: '义乌仓', containerType: '40HQ', 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: 68, 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', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', 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: 68, 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: '40HQ', 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: 68, maxWeightKg: 28000, 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: [] }) }) @@ -96,7 +96,7 @@ describe('ContainersView', () => { it('routes each plan status to its owning workflow without reopening pushed drafts', async () => { const base = { - id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', version: 0, + id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index b0c4adfb..c7354bcf 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -41,7 +41,7 @@ vi.mock('../api/platformContainerLoading', () => ({ const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, @@ -59,7 +59,7 @@ describe('GlobalSmartLoadingView', () => { vi.clearAllMocks() vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) }) it('loads real containers, candidates and the selected plan into the three-column workbench', async () => { @@ -69,13 +69,43 @@ describe('GlobalSmartLoadingView', () => { expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 }) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId) - expect(wrapper.text()).toContain('候选订单与货物') + expect(wrapper.text()).toContain('候选入库单与货物') + expect(wrapper.text()).toContain('美森正班海卡包税') expect(wrapper.text()).toContain('ORD-2') expect(wrapper.text()).toContain('CB-1 装柜方案') expect(wrapper.text()).toContain('LP-99') expect(wrapper.text()).toContain('ORD-1') }) + it('groups containers by channel and reloads candidates when the channel changes', async () => { + const other = { + ...container, + id: '9007199254740998', + planId: '9007199254740999', + cabinetNo: 'CB-2', + channelCode: 'HDHK', + channelName: '合德海卡包税', + } + vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 }) + vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId })) + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.findAll('.container-option')).toHaveLength(1) + expect(wrapper.text()).toContain('CB-1') + expect(wrapper.text()).not.toContain('CB-2') + + const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税')) + expect(tab).toBeDefined() + await tab!.trigger('click') + await flushPromises() + + expect(wrapper.findAll('.container-option')).toHaveLength(1) + expect(wrapper.text()).toContain('CB-2') + expect(wrapper.text()).not.toContain('CB-1') + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId) + }) + it('filters candidates by an active name-only customer selector and accountId', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") @@ -113,7 +143,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('税率') expect(wrapper.text()).not.toContain('是否退税') - expect(wrapper.text()).not.toContain('美森正班') + expect(wrapper.text()).not.toContain('税率') }) it('prefers an editable container on first load so pushed plans do not show a false editing error', async () => { diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 3f7cd966..3082135c 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -31,12 +31,15 @@ import type { SmartLoadingPlan, } from '../types' +const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) + const access = useAccessPreviewStore() const router = useRouter() const containers = ref([]) const customers = ref([]) const customersLoading = ref(false) const selectedContainerId = ref('') +const activeChannelCode = ref('') const plan = ref(null) const candidates = ref([]) const loading = ref(false) @@ -59,6 +62,21 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const canPush = computed(() => access.can('platform:firstleg:smart-loading:push')) const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null) +const channelTabs = computed(() => { + const channels = new Map() + for (const item of containers.value) { + if (item.planStatus === 'COMPLETED' || !item.channelCode) continue + const current = channels.get(item.channelCode) + if (current) current.containerCount += 1 + else channels.set(item.channelCode, { + code: item.channelCode, + name: item.channelName || item.channelCode, + containerCount: 1, + }) + } + return [...channels.values()] +}) +const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null) const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING') const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null) const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId))) @@ -88,6 +106,7 @@ const filteredContainers = computed(() => { const status = String(appliedQuery.value.status || '') return containers.value.filter((item) => { if (item.planStatus === 'COMPLETED') return false + if (!activeChannelCode.value || item.channelCode !== activeChannelCode.value) return false if (status && item.planStatus !== status) return false if (!keyword) return true return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName] @@ -215,6 +234,9 @@ async function loadAll(preserved: Record = {}) { ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 }))) : [] containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records) + if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) { + activeChannelCode.value = channelTabs.value[0]?.code || '' + } if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId() await loadSelectedData(preserved) } catch (error) { @@ -241,6 +263,13 @@ async function selectContainer(containerId: string) { mutationError.value = null await loadSelectedData() } +async function selectChannel(channelCode: string) { + if (activeChannelCode.value === channelCode) return + activeChannelCode.value = channelCode + selectedContainerId.value = preferredContainerId() + mutationError.value = null + await loadSelectedData() +} async function applyFilters(model: QueryModel) { appliedQuery.value = { ...model } if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) { @@ -347,8 +376,23 @@ onMounted(() => { void load(); void loadCustomers() }) - + diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 20263988..9203a031 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -18,7 +18,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', - warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, + warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000, @@ -32,7 +32,7 @@ describe('SmartLoadingWorkbenchView', () => { vi.clearAllMocks() vi.mocked(getContainer).mockResolvedValue(container) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] }) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }]) }) it('restores the real draft and explains candidate capacity', async () => { diff --git a/src/modules/container-loading/routes.ts b/src/modules/container-loading/routes.ts index 43107c92..5c06b759 100644 --- a/src/modules/container-loading/routes.ts +++ b/src/modules/container-loading/routes.ts @@ -7,12 +7,6 @@ export const containerLoadingRoutes = [ component: () => import('./pages/ContainersView.vue'), meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' }, }, - { - path: 'container-loading/smart-loading', - name: 'global-smart-loading-workbench', - component: () => import('./pages/GlobalSmartLoadingView.vue'), - meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' }, - }, { path: 'container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index b5b0dc74..8c2d4cc4 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -8,6 +8,8 @@ export interface ContainerBookingResource { cabinetNo: string warehouseId: string warehouseName: string | null + channelCode: string | null + channelName: string | null billOfLadingNo: string | null vesselName: string | null voyageNo: string | null @@ -188,6 +190,8 @@ export interface SmartLoadingCandidate { orderNo: string | null inboundCode: string | null warehouseId: string + channelCode: string | null + channelName: string | null destinationWarehouseId: string | null destinationWarehouseCode: string | null destinationWarehouseName: string | null diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index 970b393f..c9a9d9a1 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -39,11 +39,24 @@ describe('OceanBookingDetailView interaction contract', () => { it('provides guidance for every free-text booking field', () => { for (const placeholder of [ '请输入提单号(可选)', '请输入船名', '请输入航次', '例如:CNNGB', '例如:宁波港', - '例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '例如:CY-CY', - '例如:PREPAID', '请输入订舱备注(可选)', + '例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '请输入订舱备注(可选)', ]) expect(source).toContain(`placeholder="${placeholder}"`) }) + it('uses compact choices and keeps capacity snapshots behind one specification control', () => { + expect(source).toContain("const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR']") + expect(source).toContain("const freightTermOptions = ['PREPAID', 'COLLECT']") + expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']") + expect(source).toContain(' { expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })") expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'") @@ -78,7 +91,7 @@ describe('OceanBookingDetailView interaction contract', () => { expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)') expect(source).toContain('') expect(source).toContain('.container-table-wrap{overflow-x:auto') - expect(source).toContain('.container-table td :deep(.el-input),.container-table td :deep(.el-input-number){width:100%;min-width:0}') + expect(source).toContain('.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}') expect(source).toContain(':controls="false" placeholder="请输入数量"') expect(source).not.toContain('class="container-row"') }) @@ -95,11 +108,11 @@ describe('OceanBookingDetailView interaction contract', () => { expect(source).toContain('aria-label="航线船期"') expect(source).toContain('aria-label="条款与柜量"') expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))') - expect(source).toContain('.booking-group__body--fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}') + expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}') expect(source).toContain('class="booking-field--wide" label="交货地点"') expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}') - expect(source).toContain('@media(max-width:520px){.booking-group__body--fields{grid-template-columns:1fr}') - expect(source).toContain('min-width:580px') + expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}') + expect(source).toContain('min-width:520px') }) it('keeps the three real booking parties in their own three-column section', () => { @@ -108,4 +121,12 @@ describe('OceanBookingDetailView interaction contract', () => { expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }") expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))') }) + + it('shows client-side validation failures instead of rendering an empty error alert', () => { + expect(source).toContain(':title="actionError.message"') + expect(source).toContain("actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' }") + expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }") + expect(source).toContain('规格(必填)') + expect(source).toContain('placeholder="单位名称(必填)"') + }) }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 2d62dfb3..ec6c1253 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -2,7 +2,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { useRoute, useRouter } from 'vue-router' -import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue' +import { ArrowLeft, Plus, Save, SlidersHorizontal, Trash2 } from '@lucide/vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails } from '@/shared/api/http' import LoadErrorState from '@/shared/components/LoadErrorState.vue' @@ -41,6 +41,13 @@ const partyTypes = [ { value: 'CONSIGNEE', label: '收货人' }, { value: 'NOTIFY_PARTY', label: '通知人' }, ] as const +const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR'] +const freightTermOptions = ['PREPAID', 'COLLECT'] +const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC'] +const containerKindOptions = [ + { value: 'FCL', label: '整箱 FCL' }, + { value: 'LCL', label: '拼箱 LCL' }, +] const actionLabels: Record = { submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', cancel: '取消订舱', 'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败', @@ -209,7 +216,11 @@ async function load() { } async function save() { if (!canSave.value) return - if (!await formRef.value?.validate().catch(() => false)) return + actionError.value = null + if (!await formRef.value?.validate().catch(() => false)) { + actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' } + return + } if (form.data.parties.some((party) => !party.partyName.trim())) { actionError.value = { message: '发货人、收货人和通知人名称均为必填项' } return @@ -312,6 +323,10 @@ function validateContainerRequirements() { } return true } +function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) { + if (!(Number(container.capacityCbm) > 0) || !(Number(container.maxWeightKg) > 0)) return '设置规格' + return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg` +} function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) } function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) } @@ -332,7 +347,7 @@ onMounted(() => { void load(); void loadOptions() })