From d7911ad09d963834f3a38e0a589c7ea41ab5e746 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 8 Oct 2026 11:16:58 +0800 Subject: [PATCH] fix(admin): restore manifest menu and overseas container access --- AGENTS.md | 2 +- src/core/navigation/adminNavigation.test.ts | 11 +++- src/core/navigation/adminNavigation.ts | 3 +- .../pages/ContainerDetailView.test.ts | 6 +- .../pages/ContainerWorkbenchView.test.ts | 33 +++++++++++ .../pages/ContainerWorkbenchView.vue | 24 ++++++++ .../pages/ContainersView.vue | 5 +- .../pages/DomesticContainersView.vue | 4 +- .../pages/OverseasContainersView.vue | 4 +- src/modules/container-loading/routes.ts | 2 +- .../LoadingListsView.behavior.test.ts | 50 +++++++++++++++++ .../components/LoadingListsView.test.ts | 8 +-- .../components/LoadingListsView.vue | 56 +++++++++++++++++-- src/modules/loading-lists/routes.test.ts | 3 +- src/modules/loading-lists/routes.ts | 6 ++ .../pages/LastMileOrdersView.vue | 2 +- src/styles/adminDensity.test.ts | 6 +- 17 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 src/modules/container-loading/pages/ContainerWorkbenchView.test.ts create mode 100644 src/modules/container-loading/pages/ContainerWorkbenchView.vue create mode 100644 src/modules/loading-lists/components/LoadingListsView.behavior.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3ccd446f..26e35755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | +| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,列表下方展开只读明细并提供打印;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index edb2b452..51914352 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,6 +8,10 @@ import { } from './adminNavigation' describe('adminNavigation', () => { + it('offers the common container menu to overseas-only operators', () => { + const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list'])) + expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic']) + }) it('matches the latest prototype sidebar using only implemented menu entries', () => { // 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56). const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : [])) @@ -16,7 +20,7 @@ describe('adminNavigation', () => { { title: '首页数据看板', children: [] }, { title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] }, { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, - { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] }, + { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, @@ -26,7 +30,7 @@ describe('adminNavigation', () => { { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) expect(visible).toHaveLength(11) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(47) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(48) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) @@ -47,7 +51,7 @@ describe('adminNavigation', () => { expect(paths).toContain('/container-loading/overseas') expect(paths).not.toContain('/container-loading/smart-loading') expect(paths).toContain('/loading-plans') - expect(paths).not.toContain('/loading-lists') + expect(paths).toContain('/loading-lists') expect(paths).not.toContain('/port-schedules') expect(paths).not.toContain('/customs') expect(paths).not.toContain('/outbound-dispatches') @@ -202,6 +206,7 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', '/container-loading/domestic', + '/loading-lists', '/loading-plans', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 486394b5..b7e114d3 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -94,7 +94,8 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { 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: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' }, + { key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] }, + { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true }, ], diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index a5a6ef3e..9d3c153f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -39,11 +39,11 @@ describe('ContainerDetailView customs integration', () => { expect(source).toContain(':sections="[\'pda\']"') }) - it('keeps the confirmed loading list inside the domestic container detail only', () => { + it('keeps the container detail entry alongside the standalone manifest', () => { expect(routeSource).toContain("name: 'domestic-container-detail'") expect(routeSource).toContain("name: 'overseas-container-detail'") - expect(loadingRouteSource).not.toContain("name: 'loading-lists'") - expect(navigationSource).not.toContain("title: '装柜清单'") + expect(loadingRouteSource).toContain("name: 'loading-lists'") + expect(navigationSource).toContain("title: '装柜清单'") expect(source).toContain(' ({ permissions: new Set(), push: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => mocks.permissions.has(permission) }) })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) })) +vi.mock('./ContainersView.vue', () => ({ default: { props: ['workbench'], template: '
{{ workbench }}
' } })) + +function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') { + return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '' } } } }) +} + +describe('container stage entry permissions', () => { + beforeEach(() => { mocks.permissions.clear(); mocks.push.mockReset() }) + it('opens overseas operations for an overseas-only user entering the common menu', () => { + mocks.permissions.add('platform:firstleg:terminal-drayage:list') + const wrapper = render() + expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS') + expect(wrapper.text()).not.toContain('国内装柜') + }) + it('preserves a direct overseas visit and allows switching back to domestic', async () => { + mocks.permissions.add('platform:firstleg:terminal-drayage:list') + mocks.permissions.add('platform:firstleg:container:list') + const wrapper = render('OVERSEAS') + expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS') + await wrapper.findAll('button')[0]!.trigger('click') + expect(mocks.push).toHaveBeenCalledWith({ name: 'domestic-container-workbench' }) + }) + it('does not mount data consumers without either list permission', () => { + expect(render().find('[data-testid="workbench"]').exists()).toBe(false) + }) +}) diff --git a/src/modules/container-loading/pages/ContainerWorkbenchView.vue b/src/modules/container-loading/pages/ContainerWorkbenchView.vue new file mode 100644 index 00000000..6da4c4be --- /dev/null +++ b/src/modules/container-loading/pages/ContainerWorkbenchView.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 5474de40..4a1bd40c 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -2,7 +2,7 @@ import '@/styles/adminDensity.css' import { computed, onMounted, reactive, ref, watch } from 'vue' import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' @@ -46,6 +46,7 @@ type ContainerForm = ContainerSaveRequest & Pick(), { workbench: 'DOMESTIC' }) const router = useRouter() +const route = useRoute() const access = useAccessPreviewStore() const rows = ref([]) const customers = ref([]) @@ -67,7 +68,7 @@ const page = ref(1) const pageSize = ref(20) const total = ref(0) const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE') -const filters = ref({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] }) +const filters = ref({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] }) const applied = ref({ ...filters.value }) const detail = ref(null) const editorOpen = ref(false) diff --git a/src/modules/container-loading/pages/DomesticContainersView.vue b/src/modules/container-loading/pages/DomesticContainersView.vue index e0529579..9fa9ff79 100644 --- a/src/modules/container-loading/pages/DomesticContainersView.vue +++ b/src/modules/container-loading/pages/DomesticContainersView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/container-loading/pages/OverseasContainersView.vue b/src/modules/container-loading/pages/OverseasContainersView.vue index dab93335..9e39c80b 100644 --- a/src/modules/container-loading/pages/OverseasContainersView.vue +++ b/src/modules/container-loading/pages/OverseasContainersView.vue @@ -1,5 +1,5 @@ - + diff --git a/src/modules/container-loading/routes.ts b/src/modules/container-loading/routes.ts index 079103a4..db4b3526 100644 --- a/src/modules/container-loading/routes.ts +++ b/src/modules/container-loading/routes.ts @@ -5,7 +5,7 @@ export const containerLoadingRoutes = [ path: 'container-loading/domestic', name: 'domestic-container-workbench', component: () => import('./pages/DomesticContainersView.vue'), - meta: { showPageHeading: false, title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' }, + meta: { showPageHeading: false, title: '货柜列表', description: '管理国内装柜、海外清关提柜与到仓', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] }, }, { path: 'container-loading/domestic/:containerId', diff --git a/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts b/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts new file mode 100644 index 00000000..bbb4592d --- /dev/null +++ b/src/modules/loading-lists/components/LoadingListsView.behavior.test.ts @@ -0,0 +1,50 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LoadingListsView from './LoadingListsView.vue' + +const mocks = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn(), can: vi.fn(), push: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: mocks.push, replace: vi.fn() }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) })) +vi.mock('@/modules/container-loading', () => ({ ContainerSimulationCreate: { template: '
' } })) +vi.mock('@/modules/iam', () => ({ listPlatformTenants: async () => ({ records: [] }) })) +vi.mock('../api/platformLoadingLists', () => ({ listConfirmedLoadingLists: mocks.list, listPreloadPlans: mocks.list, getConfirmedLoadingList: mocks.get })) +vi.mock('@/shared/api/http', () => ({ getApiErrorDetails: (cause: Error) => ({ message: cause.message }) })) +vi.mock('./LoadingListDetailDrawer.vue', () => ({ default: { props: ['detail', 'error'], template: '
{{ detail?.summary.loadingListNo }}{{ error?.message }}
' } })) + +const rows = [{ planId: '1', warehouseId: '11' }, { planId: '2', warehouseId: '22' }] +function render() { + return mount(LoadingListsView, { props: { mode: 'confirmed' }, global: { stubs: { + AdminPage: { template: '
' }, + ListPageLayout: { template: '
' }, + DataTable: { props: ['data'], template: '
' }, + ElButton: { template: '' }, + } } }) +} +describe('confirmed manifest inline detail', () => { + beforeEach(() => { vi.clearAllMocks(); mocks.can.mockReturnValue(true); mocks.list.mockResolvedValue({ records: rows, total: 2 }) }) + it('ignores an older detail response when another manifest is selected', async () => { + let resolveFirst!: (value: unknown) => void + mocks.get.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })) + .mockResolvedValueOnce({ summary: { status: 'COMPLETED', planId: '2', loadingListNo: 'LIST-2' } }) + const wrapper = render(); await flushPromises() + await wrapper.findAll('button')[0]!.trigger('click') + await wrapper.findAll('button')[2]!.trigger('click'); await flushPromises() + resolveFirst({ summary: { status: 'COMPLETED', planId: '1', loadingListNo: 'LIST-1' } }); await flushPromises() + expect(wrapper.get('[data-testid="detail"]').text()).toBe('LIST-2') + expect(mocks.get).toHaveBeenNthCalledWith(2, '2', '22') + expect(mocks.push).not.toHaveBeenCalled() + }) + it('does not print when loading fails', async () => { + const print = vi.spyOn(window, 'print').mockImplementation(() => {}) + mocks.get.mockRejectedValueOnce(new Error('清单不可用')) + const wrapper = render(); await flushPromises() + await wrapper.findAll('button')[1]!.trigger('click'); await flushPromises() + expect(wrapper.get('[data-testid="detail"]').text()).toContain('清单不可用') + expect(print).not.toHaveBeenCalled(); print.mockRestore() + }) + it('does not fetch manifests without list permission', async () => { + mocks.can.mockReturnValue(false) + render(); await flushPromises() + expect(mocks.list).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index 2de1242c..d5b494e1 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -59,8 +59,8 @@ describe('LoadingListsView', () => { it('keeps preload routes while completed lists move into domestic container detail', () => { expect(source).toContain("name: 'preload-plan-detail'") - expect(source).toContain("name: 'loading-list-detail'") - expect(source).not.toContain('confirmed-inline-detail') + expect(source).toContain('loadConfirmedDetail') + expect(source).toContain('confirmed-inline-detail') expect(preloadDetailSource).toContain('返回列表') expect(preloadDetailSource).toContain('router.back()') expect(confirmedDetailSource).toContain('返回列表') @@ -111,7 +111,7 @@ describe('LoadingListsView', () => { expect(source).toContain('useRoute()') expect(source).toContain('route.query.planId') expect(source).toContain('route.query.warehouseId') - expect(source).toContain("router.replace({ name: 'loading-list-detail'") + expect(source).toContain('await loadConfirmedDetail(planId, warehouseId)') expect(confirmedDetailSource).toContain("result.summary.status !== 'COMPLETED'") expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH') expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())') @@ -139,7 +139,7 @@ describe('LoadingListsView', () => { expect(source).toContain(":class=\"{ 'density-action': isPreload }\"") expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"') expect(source).not.toContain(' import '@/styles/adminDensity.css' -import { computed, onMounted, reactive, ref, watch } from 'vue' +import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' +import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue' import { useRoute, useRouter } from 'vue-router' import { ContainerSimulationCreate } from '@/modules/container-loading' @@ -18,6 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { loadingListStatusLabel } from '../status' import { listConfirmedLoadingLists, + getConfirmedLoadingList, + type LoadingDetail, listPreloadPlans, type ConfirmedSummary, type LoadingListMode, @@ -40,6 +43,45 @@ const customers = ref([]) const customersLoading = ref(false) const query = reactive({ customerId: '', keyword: '', createdRange: [] }) const handledDeepLink = ref('') +const selectedPlan = ref<{ planId: string; warehouseId: string } | null>(null) +const confirmedDetail = ref | null>(null) +const detailLoading = ref(false) +const detailError = ref(null) +let detailSequence = 0 + +function closeConfirmedDetail() { + ++detailSequence + selectedPlan.value = null + confirmedDetail.value = null + detailError.value = null + detailLoading.value = false +} + +async function loadConfirmedDetail(planId: string, warehouseId: string) { + const sequence = ++detailSequence + selectedPlan.value = { planId, warehouseId } + confirmedDetail.value = null + detailError.value = null + detailLoading.value = true + try { + if (!warehouseId) throw new Error('该清单缺少所属仓库信息,无法查询详情') + const result = await getConfirmedLoadingList(planId, warehouseId) + if (sequence !== detailSequence) return + if (result.summary.status !== 'COMPLETED') throw new Error('该计划尚未形成正式装柜清单') + confirmedDetail.value = result + } catch (cause) { + if (sequence === detailSequence) detailError.value = getApiErrorDetails(cause, '装柜清单明细加载失败') + } finally { + if (sequence === detailSequence) detailLoading.value = false + } +} + +async function printConfirmed(row: LoadingListSummary) { + await loadConfirmedDetail(String(row.planId), String(row.warehouseId || '')) + if (!confirmedDetail.value || confirmedDetail.value.summary.planId !== String(row.planId)) return + await nextTick() + window.print() +} const isPreload = computed(() => props.mode === 'preload') const title = computed(() => isPreload.value ? '预装清单' : '装柜清单') @@ -69,7 +111,7 @@ const columns = computed(() => isPreload.value ? [ { key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 90, align: 'right', slotName: 'grossWeight' }, { key: 'createdAt', label: '创建时间', minWidth: 140, slotName: 'createdAt' }, ] : [ - { key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false }, + { key: 'actions', label: '操作', fixed: 'left', width: 150, slotName: 'actions', hideable: false, movable: false }, { key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 }, { key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 }, @@ -138,7 +180,7 @@ async function openDeepLinkedDetail() { await router.replace({ name: 'preload-plan-detail', params: { planId }, query: { warehouseId } }) return } - await router.replace({ name: 'loading-list-detail', params: { planId }, query: { warehouseId } }) + await loadConfirmedDetail(planId, warehouseId) } function onSimulationCreated() { @@ -158,7 +200,7 @@ function openDetail(rowValue: object) { void router.push({ name: 'preload-plan-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } }) return } - void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } }) + void loadConfirmedDetail(String(row.planId), String(row.warehouseId || '')) } async function loadCustomers() { @@ -223,6 +265,7 @@ onMounted(() => { @@ -233,6 +276,10 @@ onMounted(() => { +
+

装柜清单明细 {{ confirmedDetail?.summary.loadingListNo || '' }}

+ +
@@ -240,6 +287,7 @@ onMounted(() => {