From 57de615717000ffb040802bbe7131cbba47358f8 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Tue, 25 Aug 2026 22:08:59 +0800 Subject: [PATCH 01/13] 1 --- package.json | 1 + pnpm-lock.yaml | 3 + src/main.ts | 1 + .../finance/pages/FinanceCustomerArapView.vue | 66 ++++++++++++++++--- .../FinanceCustomerStatementsView.test.ts | 25 +++++++ .../pages/FinanceCustomerStatementsView.vue | 11 +++- .../finance/pages/FinancePresentation.test.ts | 18 ++++- .../unified-orders/api/unifiedOrders.test.ts | 9 +++ .../unified-orders/api/unifiedOrders.ts | 7 ++ .../pages/UnifiedOrdersView.requests.test.ts | 16 +++++ .../pages/UnifiedOrdersView.vue | 35 +++++++++- src/shared/components/DataTable.test.ts | 18 ++++- src/shared/components/DataTable.vue | 4 +- 13 files changed, 199 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a7d055b2..c53005ad 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@chongchong/shared": "link:./shared", "@lucide/vue": "^1.27.0", "axios": "^1.18.1", + "dayjs": "^1.11.21", "element-plus": "^2.14.3", "pinia": "^4.0.2", "sortablejs": "^1.15.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9855f2d..88a48763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: axios: specifier: ^1.18.1 version: 1.18.1(debug@4.4.3) + dayjs: + specifier: ^1.11.21 + version: 1.11.21 element-plus: specifier: ^2.14.3 version: 2.14.3(vue@3.5.40(typescript@5.9.3)) diff --git a/src/main.ts b/src/main.ts index 4c269445..58bbef71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { computed, createApp } from 'vue' +import 'dayjs/locale/zh-cn' import { createPinia } from 'pinia' import { ElMessage } from 'element-plus' import 'element-plus/es/components/message/style/css' diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index 03a73983..a6f28198 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -1,5 +1,5 @@ diff --git a/src/shared/components/DataTable.test.ts b/src/shared/components/DataTable.test.ts index 792c264d..71877d40 100644 --- a/src/shared/components/DataTable.test.ts +++ b/src/shared/components/DataTable.test.ts @@ -6,8 +6,8 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue' import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' const TableStub = defineComponent({ - props: { border: Boolean, stripe: Boolean }, - template: '
', + props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] }, + template: '
', methods: { doLayout() {} }, }) const ColumnStub = defineComponent({ @@ -93,6 +93,20 @@ describe('DataTable column preferences', () => { expect(plainTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'false', 'data-stripe': 'false' }) }) + it('keeps table bodies within the viewport by default and allows a custom height', () => { + const defaultTable = mountTable() + expect(defaultTable.get('.table-stub').attributes('data-max-height')).toBe('calc(100vh - 300px)') + + const customTable = shallowMount(DataTable, { + props: { data: [], columns: configurableColumns, maxHeight: 420 }, + global: { + stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub }, + directives: { loading: {} }, + }, + }) + expect(customTable.get('.table-stub').attributes('data-max-height')).toBe('420') + }) + it('uses key as the data property for ordinary columns only', () => { const wrapper = shallowMount(DataTable, { props: { diff --git a/src/shared/components/DataTable.vue b/src/shared/components/DataTable.vue index c480ad32..193f35a9 100644 --- a/src/shared/components/DataTable.vue +++ b/src/shared/components/DataTable.vue @@ -62,6 +62,7 @@ const props = withDefaults(defineProps<{ emptyText?: string total?: number pageSizes?: number[] + maxHeight?: string | number preferenceKey?: string columnSettingsMode?: 'auto' | 'show' | 'hide' border?: boolean @@ -71,6 +72,7 @@ const props = withDefaults(defineProps<{ emptyText: '暂无数据', total: undefined, pageSizes: () => [10, 20, 50, 100], + maxHeight: 'calc(100vh - 300px)', columnSettingsMode: 'auto', border: true, stripe: true, @@ -298,7 +300,7 @@ function setPage(value: number) { - + Date: Wed, 26 Aug 2026 11:10:48 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- .../api/platformContainerLoading.test.ts | 19 ++- .../api/platformContainerLoading.ts | 10 +- .../components/ContainerOverviewPanel.test.ts | 84 ++++++++++-- .../components/ContainerOverviewPanel.vue | 83 ++++++++---- .../pages/ContainerDetailView.test.ts | 98 ++++++++++---- .../pages/ContainerDetailView.vue | 53 ++++---- .../pages/ContainersView.test.ts | 19 +-- .../pages/ContainersView.vue | 124 +----------------- .../components/PlatformTenantSelector.test.ts | 22 ++++ .../iam/components/PlatformTenantSelector.vue | 5 +- .../pages/OutboundsView.vue | 2 +- .../CarrierScheduleQueryPanel.test.ts | 6 + .../components/CarrierScheduleQueryPanel.vue | 4 +- 14 files changed, 301 insertions(+), 235 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b5a9937c..6a7a1e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 业务 | Admin 入口 | 联查 | |---|---|---| | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | @@ -46,9 +47,9 @@ | 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | -| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` | +| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | -| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 | +| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 | | 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | | 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 | @@ -65,6 +66,7 @@ | 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | +| 海外出库(范围外现存代码) | `modules/overseas-warehouse/pages/OutboundsView.vue`、`api/outbounds.ts`,当前路由和导航已有注册 | 当前四期契约仍明确排除海外仓出库与派送;不得因代码或菜单存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | | 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` | | 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | @@ -79,6 +81,7 @@ - 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 - 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 +- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 - 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 - 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index e43aff2b..1cb86699 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -136,11 +136,18 @@ describe('platform container loading API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' }) }) - it('downloads PDA photos only from the backend-provided protected container path', async () => { - vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo'])) - await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download') - expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' }) - expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path') - expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path') + it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob(['photo'])) + const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download' + await downloadContainerPdaPhoto(longPath) + await downloadContainerPdaPhoto('/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' }) + expect(vi.mocked(http.get).mock.calls.every((call) => !String(call[0]).includes('/api/api/'))).toBe(true) + for (const path of [ + '/api/platform/v1/files/loading-evidence/21/22/23', 'https://example.com/photo.jpg', + '//example.com/platform/v1/containers/7/pda/photos/23/download', '/platform/v1/containers/7/pda/photos/23/download?token=x', + '/platform/v1/containers/7/pda/photos/../23/download', '/api/api/platform/v1/containers/7/pda/photos/23/download', + ]) expect(() => downloadContainerPdaPhoto(path)).toThrow('invalid container photo path') }) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 083b835a..b09c748b 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -165,9 +165,15 @@ export function downloadContainerDocument(containerId: string, documentType: Ext return http.get(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' }) } +function normalizeContainerPhotoDownloadPath(downloadPath: string) { + if (typeof downloadPath !== 'string' || !downloadPath.startsWith('/')) throw new Error('invalid container photo path') + const normalized = downloadPath.startsWith('/api/') ? downloadPath.slice(4) : downloadPath + if (!/^\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(normalized)) throw new Error('invalid container photo path') + return normalized +} + export function downloadContainerPdaPhoto(downloadPath: string) { - if (!/^\/api\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(downloadPath)) throw new Error('invalid container photo path') - return http.get(downloadPath, { responseType: 'blob' }) + return http.get(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' }) } export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) { diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index ea73c011..4d2be963 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import ContainerOverviewPanel from './ContainerOverviewPanel.vue' import type { ContainerOverview } from '../types' +import { downloadContainerPdaPhoto } from '../api/platformContainerLoading' + +vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() })) const overview: ContainerOverview = { container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 }, @@ -15,8 +18,16 @@ const overview: ContainerOverview = { } describe('ContainerOverviewPanel', () => { - it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo'])) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + }) + + it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => { + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() expect(wrapper.text()).toContain('DO 资料') expect(wrapper.text()).toContain('do.pdf') expect(wrapper.text()).toContain('PDA 作业进度') @@ -29,25 +40,72 @@ describe('ContainerOverviewPanel', () => { }) it('emits stable document types and does not expose file ids in visible text', async () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))! await updateButton.trigger('click') expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER']) expect(wrapper.text()).not.toContain('9007199254740999') }) - it('emits the backend-provided photo object from an accessible view control', async () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) - await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click') - expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' }) + it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download') + expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true) + await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click') + expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer') + wrapper.unmount() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo') }) - it('renders empty and forbidden/load failure states without exposing credentials', () => { + it('revokes old thumbnail URLs when the overview photos change', async () => { + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' } + await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } }) + await flushPromises() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo') + expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download') + wrapper.unmount() + }) + + it('isolates one thumbnail failure without blocking other photos', async () => { + const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' } + vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable')) + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true) + expect(wrapper.text()).toContain('照片加载失败') + wrapper.unmount() + }) + + it('maps PDA events to Chinese and hides long technical keys from the main timeline', () => { + const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76' + const timeline = [ + { eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' }, + { eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' }, + { eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' }, + ] + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + expect(wrapper.text()).toContain('领取 PDA 任务') + expect(wrapper.text()).toContain('提交拣货结果') + expect(wrapper.text()).toContain('完成装柜') + expect(wrapper.text()).not.toContain(longKey) + }) + + it('renders a compact empty photo state without exposing credentials', () => { const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null } - const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } }) expect(wrapper.text()).toContain('暂无可查看的有效照片') - expect(wrapper.text()).toContain('当前账号无权访问该数据') - expect(wrapper.text()).toContain('req-photo') expect(wrapper.text()).not.toContain('Authorization') }) + + it('keeps a completed customs document labelled as an attachment, not formal customs release', () => { + const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' } + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + expect(wrapper.text()).toContain('国内出口报关附件') + expect(wrapper.text()).not.toContain('已完成报关') + expect(wrapper.text()).not.toContain('已放行') + }) }) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 93677067..b7a3aeb5 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,43 +1,46 @@ diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 9172914b..59095641 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -1,40 +1,80 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import ElementPlus from 'element-plus' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ContainerDetailView from './ContainerDetailView.vue' +import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' +import { uploadLoadingDeclaration } from '@/modules/loading-lists' -const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') +const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) +vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) +vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() })) +vi.mock('../api/platformContainerLoading', () => ({ + getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), + uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), + downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), +})) + +const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never +const overview = { + container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 }, + loadingPlanId: '2092438843630391401', pda: null, + documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }], + exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [], +} + +function mountView(customs = true) { + accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage')) + return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } }) +} +function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! } describe('ContainerDetailView', () => { - it('keeps identity, status and key facts in a compact overview', () => { - expect(source).toContain('class="container-overview surface-panel"') - expect(source).toContain('class="container-overview__header"') - expect(source).toContain('class="container-overview__summary"') - expect(source).toContain('grid-template-columns:repeat(5,minmax(0,1fr))') + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getContainer).mockResolvedValue(detail) + vi.mocked(getContainerOverview).mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockResolvedValue(null) + vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never) + vi.mocked(uploadContainerDocument).mockResolvedValue({} as never) + vi.mocked(uploadContainerClearance).mockResolvedValue({} as never) }) - it('keeps real document, exception and authorized photo operations', () => { - expect(source).toContain('uploadContainerDocument(') - expect(source).toContain('downloadContainerDocument(') - expect(source).toContain('uploadContainerClearance(') - expect(source).toContain('completeContainerClearance(') - expect(source).toContain('markContainerException') - expect(source).toContain('cancelContainerException') - expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)') + it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => { + const wrapper = mountView(); await flushPromises() + await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('国内出口报关附件') + const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' }) + ;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file }) + await (wrapper.vm as unknown as { saveDocument: () => Promise }).saveDocument() + expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件') + expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled() + wrapper.unmount() }) - it('carries the existing read-only CarrierScheduleQuery by booking and container', () => { - expect(source).toContain('CarrierScheduleQueryPanel') - expect(source).toContain("from '@/modules/port-schedules'") - expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"') - expect(source).toContain(':container-booking-id="detail.id"') + it('shows a readable error when the container has no loading plan', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never) + const wrapper = mountView(); await flushPromises() + await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件') + expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount() }) - it('shows overseas unloading and empty-report facts without adding upload or PDA actions', () => { - expect(source).toContain('detail.unloadingCompletedAt') - expect(source).toContain('detail.unloadingPhotoUploadedAt') - expect(source).toContain('detail.estimatedEmptyReportedAt') - expect(source).toContain('detail.actualEmptyReportedAt') - expect(source).not.toContain('uploadUnloadingPhoto') - expect(source).not.toContain('submitEmptyReport') + it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => { + const wrapper = mountView(false); await flushPromises() + expect(documentArticle(wrapper, '国内出口报关附件').find('button').exists()).toBe(false) + expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false) + expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true) + expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true) + wrapper.unmount() + }) + + it('keeps destination clearance on its independent container clearance API', async () => { + const wrapper = mountView(); await flushPromises() + await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises() + expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297') + expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled() + wrapper.unmount() }) }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 16af6b11..74af5e49 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -11,12 +11,12 @@ import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' +import { uploadLoadingDeclaration } from '@/modules/loading-lists' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, completeContainerClearance, downloadContainerDocument, - downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, @@ -24,20 +24,22 @@ import { uploadContainerClearance, uploadContainerDocument, } from '../api/platformContainerLoading' -import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types' +import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types' const route = useRoute() const router = useRouter() const access = useAccessPreviewStore() const containerId = computed(() => String(route.params.containerId || '')) const canEdit = computed(() => access.can('platform:firstleg:container:edit')) +const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage')) const loading = ref(false) const detail = ref(null) const overview = ref(null) const error = ref(null) const overviewError = ref(null) const documentOpen = ref(false) -const documentType = ref<'DELIVERY_ORDER' | 'PICKUP_ORDER'>('DELIVERY_ORDER') +type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION' +const documentType = ref('DELIVERY_ORDER') const documentFile = ref(null) const documentSaving = ref(false) const documentDownloading = ref(false) @@ -52,11 +54,9 @@ const exceptionOpen = ref(false) const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) -const photoBusyId = ref(null) -const photoError = ref(null) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) -const documentTitle = computed(() => documentType.value === 'DELIVERY_ORDER' ? 'DO 资料' : 'PU 资料') +const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value]) const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') @@ -74,18 +74,28 @@ async function load() { } function openDocument(type: string) { - if (type === 'DESTINATION_CLEARANCE') { void openClearance(); return } - if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) return - documentType.value = type as 'DELIVERY_ORDER' | 'PICKUP_ORDER' + if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return } + if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return + if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return + documentType.value = type as UploadDocumentType documentFile.value = null; documentError.value = null; documentOpen.value = true + if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) { + documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' } + } } function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null } function removeDocumentFile() { documentFile.value = null } async function saveDocument() { - if (!documentFile.value || !overview.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } + if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } documentSaving.value = true; documentError.value = null try { - await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`) + const expectedVersion = selectedDocument.value?.version || 0 + if (documentType.value === 'CUSTOMS_DECLARATION') { + if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件') + await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件') + } else { + await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`) + } overview.value = await getContainerOverview(containerId.value) documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`) } catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) } @@ -99,13 +109,6 @@ async function downloadDocument(item: ContainerOverviewDocument) { catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) } finally { documentDownloading.value = false } } -async function previewPhoto(photo: ContainerPdaPhoto) { - photoBusyId.value = photo.evidenceId; photoError.value = null - try { const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } - catch (cause) { photoError.value = getApiErrorDetails(cause, '装柜照片加载失败') } - finally { photoBusyId.value = null } -} - function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true } async function saveException() { if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return } @@ -128,10 +131,10 @@ async function openClearance() { function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null } function removeClearanceFile() { clearanceFile.value = null } async function uploadClearance() { - if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择清关资料文件' }; return } + if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } clearanceSaving.value = true; clearanceError.value = null - try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('清关资料已上传') } - catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关资料上传失败') } + try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('目的港清关资料已上传') } + catch (cause) { clearanceError.value = getApiErrorDetails(cause, '目的港清关资料上传失败') } finally { clearanceSaving.value = false } } async function completeClearance() { @@ -154,17 +157,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op 选择文件 - + diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 1c066838..b2742151 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -213,7 +213,8 @@ describe('ContainersView', () => { expect(detail).toContain(' { @@ -237,15 +238,15 @@ describe('ContainersView', () => { expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') }) - it('uploads clearance documents from a file picker without exposing file ids', () => { + it('keeps document uploads on the routed detail page without duplicate list implementations', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - expect(source).toContain(' { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index f1b070f4..f6c865e9 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -2,14 +2,13 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue' -import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus' +import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' 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 { 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' import AppDialog from '@/shared/components/AppDialog.vue' @@ -19,9 +18,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' -import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' -import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' +import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading' +import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } type ContainerForm = ContainerSaveRequest & Pick & { id: string; version: number } @@ -47,31 +45,7 @@ const pageSize = ref(20) const total = ref(0) const filters = ref({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] }) const applied = ref({ ...filters.value }) -const detailOpen = ref(false) -const detailLoading = ref(false) -const detailError = ref(null) const detail = ref(null) -const detailTarget = ref(null) -const clearanceDocument = ref(null) -const clearanceLoading = ref(false) -const clearanceActionLoading = ref(false) -const clearanceError = ref(null) -const clearanceFile = ref(null) -const clearanceDialogOpen = ref(false) -const overview = ref(null) -const overviewError = ref(null) -const documentDialogOpen = ref(false) -const documentType = ref('DELIVERY_ORDER') -const documentFile = ref(null) -const documentSaving = ref(false) -const documentError = ref(null) -const documentDownloading = ref(false) -const photoBusyId = ref(null) -const photoError = ref(null) -const exceptionDialogOpen = ref(false) -const exceptionReason = ref('') -const exceptionSaving = ref(false) -const exceptionError = ref(null) const editorOpen = ref(false) const editorError = ref(null) const saving = ref(false) @@ -123,9 +97,6 @@ const canCustomsManage = computed(() => access.can('platform:firstleg:customs:ma const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const editorTitle = computed(() => form.id ? '编辑' : '新增') -const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) -const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value]) -const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING') const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER'))) const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING')) @@ -175,7 +146,7 @@ const columns: DataTableColumn[] = [ { key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' }, { key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' }, { key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) }, - { key: 'customsDeclarationDocumentStatus', label: '报关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) }, + { key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) }, { key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) }, { key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, @@ -465,86 +436,6 @@ function handleMoreAction(command: string, source: object) { if (command === 'overview') { openDetail(row); return } if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } }) } -function openDocumentDialog(type: string) { - if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) { - if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value) - return - } - documentType.value = type as ContainerDocumentType - documentFile.value = null - documentError.value = null - documentDialogOpen.value = true -} -function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null } -function removeDocumentFile() { documentFile.value = null } -async function saveDocument() { - if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return } - documentSaving.value = true; documentError.value = null - try { - const version = selectedOverviewDocument.value?.version || 0 - if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料') - else if (documentType.value === 'CUSTOMS_DECLARATION') { - if (!overview.value.loadingPlanId) throw new Error('missing loading plan') - await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料') - } else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`) - overview.value = await getContainerOverview(detailTarget.value.id) - documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`) - } catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.value}上传失败`) } - finally { documentSaving.value = false } -} -async function saveBlob(blob: Blob, fileName: string) { - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click() - URL.revokeObjectURL(url) -} -async function downloadDocument(item: ContainerOverviewDocument) { - if (!detailTarget.value) return - documentDownloading.value = true - if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return - try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') } - catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').message) } - finally { documentDownloading.value = false } -} -async function previewPhoto(photo: ContainerPdaPhoto) { - photoBusyId.value = photo.evidenceId; photoError.value = null - try { - const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)) - window.open(url, '_blank', 'noopener,noreferrer') - window.setTimeout(() => URL.revokeObjectURL(url), 60_000) - } catch (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') } - finally { photoBusyId.value = null } -} -function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true } -async function saveException() { - if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return } - exceptionSaving.value = true; exceptionError.value = null - try { - const action = overview.value.exception.active ? cancelContainerException : markContainerException - await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim()) - overview.value = await getContainerOverview(detailTarget.value.id) - exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消') - } catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') } - finally { exceptionSaving.value = false } -} -async function openClearance(source: object) { - const row = source as ContainerBookingResource - detailTarget.value = row - clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true - try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false } -} -function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null } -function removeClearanceFile() { clearanceFile.value = null } -async function uploadClearance() { - if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return } - clearanceActionLoading.value = true; clearanceError.value = null - try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false } -} -async function completeClearance() { - if (!detailTarget.value || !clearanceDocument.value) return - clearanceActionLoading.value = true; clearanceError.value = null - try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false } -} async function openEdit(source: object) { const row = source as ContainerBookingResource editorError.value = null @@ -637,12 +528,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin - 选择文件 - - 选择文件 - - - @@ -692,7 +577,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin From 8307510fc399e3cdbac51ace8d9d881396c6208d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:42:46 +0800 Subject: [PATCH 03/13] fix(admin): load picker warehouses from domestic master --- src/modules/iam/api/platformIam.test.ts | 44 +++++++----- src/modules/iam/api/platformIam.ts | 39 +++++++++-- .../iam/pages/InternalUsersView.test.ts | 68 +++++++++++++++++++ 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts index df42ce2f..ecaa74c0 100644 --- a/src/modules/iam/api/platformIam.test.ts +++ b/src/modules/iam/api/platformIam.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { overseasHttp } from '@/shared/api/overseasHttp' import { approveEnterpriseReview, archivePlatformTenant, @@ -51,10 +50,6 @@ vi.mock('@/shared/api/http', () => ({ unwrapApiResponse: (response: { data: T }) => response.data, })) -vi.mock('@/shared/api/overseasHttp', () => ({ - overseasHttp: { get: vi.fn() }, -})) - const role: PlatformRoleResource = { id: 4000002, tenantId: 0, @@ -123,7 +118,6 @@ describe('platform IAM roles', () => { vi.mocked(http.post).mockReset() vi.mocked(http.put).mockReset() vi.mocked(http.delete).mockReset() - vi.mocked(overseasHttp.get).mockReset() }) it('loads roles from the platform IAM prefix', async () => { @@ -218,9 +212,13 @@ describe('platform IAM roles', () => { }) it('maintains paginated platform users through the unified user contract', async () => { - const userOptions = { + const userOptionsPayload = { tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }], - roles: [{ id: '40001', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' }], + roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }], + } + const userOptions = { + ...userOptionsPayload, + roles: [{ ...userOptionsPayload.roles[0], roleCode: 'PLATFORM_OPERATOR' }], } const create = { username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com', @@ -234,7 +232,11 @@ describe('platform IAM roles', () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: { ...userPage, page: '2', size: '10', total: '11', pages: '2' }, }) - .mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: userOptionsPayload }) + .mockResolvedValueOnce({ + code: '200', message: 'success', + data: [{ ...role, id: '40001', roleCode: 'PLATFORM_OPERATOR' }], + }) vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser }) vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser }) @@ -249,28 +251,36 @@ describe('platform IAM roles', () => { params: { page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' }, }) expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options') + expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/iam/roles') expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create) expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update) expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/users/${managedUser.membershipId}/status`, { status: 'DISABLED' }) expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' }) }) - it('loads only active shared receiving overseas warehouses through the overseas gateway', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ + it('loads only active shared receiving overseas warehouses from domestic warehouse master data', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [ - { id: '90071992547409931', customerId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' }, - { id: '2', customerId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' }, - { id: '3', customerId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' }, - ], page: 1, size: 200, total: 3, pages: 1, + { id: '90071992547409931', tenantId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' }, + { id: '90071992547409932', tenantId: '0', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' }, + { id: '2', tenantId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' }, + { id: '3', tenantId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' }, + { id: '4', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', countryCode: 'US', status: 'ACTIVE' }, + { id: '5', tenantId: '0', warehouseCode: 'SEA-INACTIVE', warehouseName: '西雅图停用仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'INACTIVE' }, + ], page: 1, size: 200, total: 6, pages: 1, }, }) await expect(listSharedReceivingOverseasWarehouseOptions()).resolves.toEqual([ { id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' }, + { id: '90071992547409932', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', countryCode: 'US' }, ]) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', { - params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' }, + expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', { + params: { + tenantId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', + warehouseType: 'OVERSEAS', status: 'ACTIVE', + }, }) }) diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index adb523ad..9689de43 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -1,5 +1,4 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import { overseasHttp } from '@/shared/api/overseasHttp' import type { PlatformPermissionResource } from '@/shared/types/platformPermission' export type { PlatformPermissionResource } from '@/shared/types/platformPermission' @@ -277,6 +276,11 @@ export interface PlatformUserOptions { roles: Array<{ id: number | string; tenantId: number | string; roleCode: string; name: string; status: string }> } +interface PlatformUserOptionsPayload { + tenants: PlatformUserOptions['tenants'] + roles: Array> +} + export interface SharedReceivingOverseasWarehouseOption { id: string warehouseCode: string @@ -565,15 +569,27 @@ export async function listPlatformManagedUsers(query: PlatformManagedUserQuery) } } -export function getPlatformUserOptions() { - return getData('/platform/v1/iam/users/options') +export async function getPlatformUserOptions(): Promise { + const [optionsResponse, rolesResponse] = await Promise.all([ + http.get, ApiEnvelope>('/platform/v1/iam/users/options'), + http.get, ApiEnvelope>('/platform/v1/iam/roles'), + ]) + const options = unwrapApiResponse(optionsResponse) + const roleCodes = new Map(unwrapApiResponse(rolesResponse).map((role) => [String(role.id), role.roleCode])) + return { + tenants: options.tenants, + roles: options.roles.map((role) => ({ + ...role, + roleCode: roleCodes.get(String(role.id)) ?? '', + })), + } } export async function listSharedReceivingOverseasWarehouseOptions() { interface WarehousePage { records: Array<{ id: number | string - customerId: number | string + tenantId: number | string warehouseCode: string warehouseName: string warehouseType: string @@ -581,12 +597,21 @@ export async function listSharedReceivingOverseasWarehouseOptions() { status: string }> } - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( '/platform/v1/warehouse-admin', - { params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' } }, + { + params: { + tenantId: '0', + page: 1, + size: 200, + capabilityCode: 'RECEIVING', + warehouseType: 'OVERSEAS', + status: 'ACTIVE', + }, + }, ) return unwrapApiResponse(response).records - .filter((warehouse) => String(warehouse.customerId) === '0' + .filter((warehouse) => String(warehouse.tenantId) === '0' && warehouse.warehouseType === 'OVERSEAS' && warehouse.status === 'ACTIVE') .map((warehouse) => ({ diff --git a/src/modules/iam/pages/InternalUsersView.test.ts b/src/modules/iam/pages/InternalUsersView.test.ts index c2f027ba..a7498766 100644 --- a/src/modules/iam/pages/InternalUsersView.test.ts +++ b/src/modules/iam/pages/InternalUsersView.test.ts @@ -220,6 +220,74 @@ describe('InternalUsersView platform internal user management', () => { }) }) + it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => { + vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({ + tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }], + roles: [ + { id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' }, + { id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }, + ], + }) + const wrapper = await mountView() + const setupState = (wrapper.vm.$ as unknown as { + setupState: { + openCreate: () => void + form: { roleIds: string[]; overseasWarehouseId: string } + } + }).setupState + setupState.openCreate() + setupState.form.roleIds = ['4000005'] + await flushPromises() + setupState.form.overseasWarehouseId = '90071992547409931' + + setupState.form.roleIds = ['40'] + await flushPromises() + + expect(setupState.form.overseasWarehouseId).toBe('') + expect(document.body.textContent).not.toContain('实际海外收货仓') + }) + + it('submits the selected picker warehouse as an unchanged long-id string', async () => { + vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({ + tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }], + roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }], + }) + const wrapper = await mountView() + const setupState = (wrapper.vm.$ as unknown as { + setupState: { + openCreate: () => void + saveUser: () => Promise + form: Record + } + }).setupState + setupState.openCreate() + Object.assign(setupState.form, { + username: 'overseas_picker_dev', + displayName: '海外仓操作员', + phone: '17512095155', + email: 'overseas_picker_dev@qq.com', + roleIds: ['4000005'], + overseasWarehouseId: '90071992547409931', + password: 'ChangeMe123!', + confirmPassword: 'ChangeMe123!', + status: 'ACTIVE', + }) + await flushPromises() + await setupState.saveUser() + + expect(createPlatformManagedUser).toHaveBeenCalledWith({ + username: 'overseas_picker_dev', + displayName: '海外仓操作员', + phone: '17512095155', + email: 'overseas_picker_dev@qq.com', + tenantId: '0', + roleIds: ['4000005'], + overseasWarehouseId: '90071992547409931', + password: 'ChangeMe123!', + status: 'ACTIVE', + }) + }) + it('requests server-side pages and filters instead of slicing locally', async () => { const wrapper = await mountView() await wrapper.find('button[aria-label="第 2 页"]').trigger('click') From e8c41bca2def65ada704bff51a8de4c7ad37db0e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:04:50 +0800 Subject: [PATCH 04/13] feat(admin): confirm container departure from detail --- .../pages/ContainerDetailView.test.ts | 85 +++++++++++++++- .../pages/ContainerDetailView.vue | 70 ++++++++++++- .../api/platformOutboundDispatch.test.ts | 97 ++++++++++++++++++- .../api/platformOutboundDispatch.ts | 80 ++++++++++++++- src/modules/outbound-dispatch/types.ts | 72 ++++++++++++++ 5 files changed, 392 insertions(+), 12 deletions(-) diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 59095641..790c6a8f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -1,15 +1,18 @@ -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessageBox } from 'element-plus' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' import { uploadLoadingDeclaration } from '@/modules/loading-lists' +import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' +import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' -const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() })) +const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } })) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) -vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) +vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
', mounted: () => { carrierMounts.value += 1 } } })) vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() })) +vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), @@ -23,9 +26,20 @@ const overview = { documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [], } +const outboundDetail = { + aggregate: { + containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111', + loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4, + inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }], + gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] }, + }, + timeline: [], +} as ContainerOutboundDispatchDetail -function mountView(customs = true) { - accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage')) +function mountView(customs = true, outbound = true) { + accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' + || (customs && permission === 'platform:firstleg:customs:manage') + || (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission))) return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } }) } function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! } @@ -33,12 +47,15 @@ function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.fi describe('ContainerDetailView', () => { beforeEach(() => { vi.clearAllMocks() + carrierMounts.value = 0 vi.mocked(getContainer).mockResolvedValue(detail) vi.mocked(getContainerOverview).mockResolvedValue(overview as never) vi.mocked(getContainerClearance).mockResolvedValue(null) vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never) vi.mocked(uploadContainerDocument).mockResolvedValue({} as never) vi.mocked(uploadContainerClearance).mockResolvedValue({} as never) + vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail) + vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never) }) it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => { @@ -77,4 +94,62 @@ describe('ContainerDetailView', () => { expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled() wrapper.unmount() }) + + it('loads and displays the whole-container gate without querying an inbound id', async () => { + const wrapper = mountView(); await flushPromises() + expect(getContainerOutboundDispatch).toHaveBeenCalledOnce() + expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297') + expect(wrapper.text()).toContain('国内出仓门禁') + expect(wrapper.text()).toContain('报关协同完成') + expect(wrapper.text()).toContain('IN-1') + expect(wrapper.text()).toContain('确认离仓') + wrapper.unmount() + }) + + it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never) + const wrapper = mountView(); await flushPromises() + await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises() + expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce() + expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' }) + expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId') + expect(getContainerOverview).toHaveBeenCalledTimes(2) + expect(carrierMounts.value).toBe(2) + expect(wrapper.text()).toContain('已确认离仓') + wrapper.unmount() + }) + + it('prevents a repeated confirmation wave while the first prompt is open', async () => { + vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never) + const wrapper = mountView(); await flushPromises() + const button = wrapper.find('.outbound-panel__actions button') + await button.trigger('click') + await button.trigger('click') + expect(ElMessageBox.confirm).toHaveBeenCalledOnce() + expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('hides the outbound gate and never calls its API without view permission', async () => { + const wrapper = mountView(true, false); await flushPromises() + expect(wrapper.text()).not.toContain('国内出仓门禁') + expect(getContainerOutboundDispatch).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => { + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.text()).toContain('关联拖车已离仓') + expect(wrapper.text()).toContain('ARRIVED') + expect(wrapper.text()).toContain('DEPARTED或COMPLETED') + expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false) + wrapper.unmount() + }) }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 74af5e49..796cda0d 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, FileUp } from '@lucide/vue' -import { ElMessage, type UploadFile } from 'element-plus' +import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' @@ -12,6 +12,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' import { uploadLoadingDeclaration } from '@/modules/loading-lists' +import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' +import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, @@ -32,6 +34,8 @@ const access = useAccessPreviewStore() const containerId = computed(() => String(route.params.containerId || '')) const canEdit = computed(() => access.can('platform:firstleg:container:edit')) const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage')) +const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view')) +const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm')) const loading = ref(false) const detail = ref(null) const overview = ref(null) @@ -54,11 +58,21 @@ const exceptionOpen = ref(false) const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) +const outboundLoading = ref(false) +const outboundConfirming = ref(false) +const outboundDetail = ref(null) +const outboundError = ref(null) +const carrierRefreshKey = ref(0) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value]) const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') +const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) => + (inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode })))) +const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value + && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH' + && outboundDetail.value.aggregate.gate.passed) function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' } function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' } @@ -69,10 +83,47 @@ async function load() { try { const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)]) detail.value = resource; overview.value = aggregate + if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch() } catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') } finally { loading.value = false } } +async function loadOutboundDispatch() { + if (!containerId.value || !canViewOutboundDispatch.value) return + outboundLoading.value = true; outboundError.value = null + try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) } + catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') } + finally { outboundLoading.value = false } +} + +async function confirmDeparture() { + const aggregate = outboundDetail.value?.aggregate + if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return + outboundConfirming.value = true + try { + await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' }) + } catch { outboundConfirming.value = false; return } + outboundError.value = null + try { + outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, { + expectedVersion: aggregate.version, + remark: '货柜详情确认国内出仓', + }) + carrierRefreshKey.value += 1 + ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新') + try { overview.value = await getContainerOverview(containerId.value) } + catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') } + } catch (cause) { + const details = getApiErrorDetails(cause, '确认国内出仓失败') + if (details.code === 'A0409') await loadOutboundDispatch() + outboundError.value = details + } finally { outboundConfirming.value = false } +} + +function outboundStatusText(status?: string | null) { + return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—' +} + function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return } if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return @@ -159,7 +210,20 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op

运输与单证快照

真实订舱
{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}
异常状态
{{ detail.exceptionActive ? '异常' : '正常' }}
目的仓
{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}
船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
拖车公司
{{ detail.terminalTruckingCompany || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
卸柜完成
{{ formatTime(detail.unloadingCompletedAt) }}
卸柜照片上传
{{ formatTime(detail.unloadingPhotoUploadedAt) }}
预计报空
{{ formatTime(detail.estimatedEmptyReportedAt) }}
实际报空
{{ formatTime(detail.actualEmptyReportedAt) }}
-
+
+

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundDetail.aggregate.gate.passed ? '门禁通过' : '门禁阻断' }}确认离仓
+ +
+ +
+
选择文件 @@ -169,5 +233,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts index 2bfdc8fe..d0ede23c 100644 --- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts +++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts @@ -1,5 +1,96 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch' -vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } }) -describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) }) +import { + changeOutboundDispatch, + checkContainerOutboundGate, + confirmContainerOutboundDispatch, + getContainerOutboundDispatch, + listOutboundDispatches, +} from './platformOutboundDispatch' + +vi.mock('@/shared/api/http', async () => { + const actual = await vi.importActual('@/shared/api/http') + return { ...actual, http: { get: vi.fn(), post: vi.fn() } } +}) + +const gate = { + passed: true, + checkedAt: '2026-08-26T12:00:00', + inbounds: [{ + inboundId: '2092438843630391501', + inboundCode: 'IN-1', + gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [] }, + }], +} +const aggregate = { + containerBookingId: '2092438843630391297', + cabinetNo: 'CAB-1', + containerNo: 'C11111111', + loadingPlanId: '2092438843630391401', + loadingPlanStatus: 'COMPLETED', + oceanBookingId: '2092438843630391601', + truckAppointmentId: null, + dispatchStatus: 'READY_FOR_DISPATCH', + version: '4', + inbounds: [{ + orderId: '2092438843630391701', + orderNo: 'O1', + inboundId: '2092438843630391501', + inboundCode: 'IN-1', + tenantId: '2092438843630391801', + customerId: '2092438843630391901', + inboundStatus: 'LOADED', + pickTaskId: null, + }], + gate, +} + +describe('platformOutboundDispatch', () => { + beforeEach(() => vi.clearAllMocks()) + + it('normalizes aggregate ids', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }) + const result = await listOutboundDispatches({}) + expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) + }) + + it('posts legacy action with version and idempotency', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }) + await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }) + expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }) + expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) + }) + + it('gets exact container-level paths and normalizes all ids', async () => { + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', data: { aggregate, timeline: [{ module: 'OUTBOUND', action: 'READY', operatorId: 9, occurredAt: '2026-08-26T12:00:00' }] } }) + .mockResolvedValueOnce({ code: '200', data: gate }) + + const detail = await getContainerOutboundDispatch('2092438843630391297') + const resultGate = await checkContainerOutboundGate('2092438843630391297') + + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297') + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/gate') + expect(detail.aggregate).toMatchObject({ + containerBookingId: String(aggregate.containerBookingId), + loadingPlanId: String(aggregate.loadingPlanId), + oceanBookingId: String(aggregate.oceanBookingId), + version: 4, + inbounds: [{ orderId: String(aggregate.inbounds[0].orderId), inboundId: String(aggregate.inbounds[0].inboundId), tenantId: String(aggregate.inbounds[0].tenantId), customerId: String(aggregate.inbounds[0].customerId) }], + }) + expect(detail.timeline[0]?.operatorId).toBe('9') + expect(resultGate.inbounds[0]?.inboundId).toBe(String(gate.inbounds[0].inboundId)) + }) + + it('confirms once at container level without inboundId and includes idempotency', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate, timeline: [] } }) + await confirmContainerOutboundDispatch('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' }) + + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/confirm', + { expectedVersion: 4, remark: '货柜详情确认国内出仓' }, + { headers: { 'Idempotency-Key': expect.any(String) } }, + ) + expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundId') + }) +}) diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts index b8719a68..8506de19 100644 --- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts +++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts @@ -1,5 +1,16 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types' +import type { + ContainerOutboundActionRequest, + ContainerOutboundAggregate, + ContainerOutboundDispatchDetail, + ContainerOutboundGateResult, + ContainerOutboundInboundBinding, + ContainerOutboundInboundGate, + OutboundDispatchDetail, + OutboundDispatchQuery, + OutboundDispatchResource, + OutboundGateResult, +} from '../types' const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value } function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` } @@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get, ApiEnvelope>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } } export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get, ApiEnvelope>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) } export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post, ApiEnvelope>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } } + +function optionalId(value: unknown): string | null { + return value == null ? null : String(value) +} + +function inboundBinding(raw: ContainerOutboundInboundBinding): ContainerOutboundInboundBinding { + return { + ...raw, + orderId: String(raw.orderId), + inboundId: String(raw.inboundId), + tenantId: String(raw.tenantId), + customerId: optionalId(raw.customerId), + pickTaskId: optionalId(raw.pickTaskId), + } +} + +function inboundGate(raw: ContainerOutboundInboundGate): ContainerOutboundInboundGate { + return { ...raw, inboundId: String(raw.inboundId), gate: { ...raw.gate, items: raw.gate?.items || [] } } +} + +function containerGate(raw: ContainerOutboundGateResult): ContainerOutboundGateResult { + return { ...raw, inbounds: (raw.inbounds || []).map(inboundGate) } +} + +function containerAggregate(raw: ContainerOutboundAggregate): ContainerOutboundAggregate { + return { + ...raw, + containerBookingId: String(raw.containerBookingId), + loadingPlanId: String(raw.loadingPlanId), + oceanBookingId: optionalId(raw.oceanBookingId), + truckAppointmentId: optionalId(raw.truckAppointmentId), + version: Number(raw.version ?? 0), + inbounds: (raw.inbounds || []).map(inboundBinding), + gate: containerGate(raw.gate), + } +} + +function containerDetail(raw: ContainerOutboundDispatchDetail): ContainerOutboundDispatchDetail { + return { + ...raw, + aggregate: containerAggregate(raw.aggregate), + timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })), + } +} + +export async function getContainerOutboundDispatch(containerBookingId: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${encodeURIComponent(containerBookingId)}`, + ) + return containerDetail(unwrapApiResponse(response)) +} + +export async function checkContainerOutboundGate(containerBookingId: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${encodeURIComponent(containerBookingId)}/gate`, + ) + return containerGate(unwrapApiResponse(response)) +} + +export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(containerBookingId)}/confirm`, + request, + { headers: { 'Idempotency-Key': idem() } }, + ) + return containerDetail(unwrapApiResponse(response)) +} diff --git a/src/modules/outbound-dispatch/types.ts b/src/modules/outbound-dispatch/types.ts index ff2de734..e38c1775 100644 --- a/src/modules/outbound-dispatch/types.ts +++ b/src/modules/outbound-dispatch/types.ts @@ -3,3 +3,75 @@ export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[] export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null } export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> } export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string } + +export interface ContainerOutboundGateItem { + code: string + name: string + passed: boolean + actual?: string | null + requirement?: string | null +} + +export interface ContainerOutboundInboundGate { + inboundId: string + inboundCode: string + gate: { + passed: boolean + items: ContainerOutboundGateItem[] + checkedAt: string + } +} + +export interface ContainerOutboundGateResult { + passed: boolean + inbounds: ContainerOutboundInboundGate[] + checkedAt: string +} + +export interface ContainerOutboundInboundBinding { + orderId: string + orderNo: string + inboundId: string + inboundCode: string + tenantId: string + customerId?: string | null + customerName?: string | null + inboundStatus: string + pickTaskId?: string | null + pickTaskStatus?: string | null +} + +export interface ContainerOutboundAggregate { + containerBookingId: string + cabinetNo: string + containerNo?: string | null + loadingPlanId: string + loadingPlanStatus: string + oceanBookingId?: string | null + oceanBookingStatus?: string | null + truckAppointmentId?: string | null + truckAppointmentStatus?: string | null + dispatchStatus: string + version: number + inbounds: ContainerOutboundInboundBinding[] + gate: ContainerOutboundGateResult +} + +export interface ContainerOutboundDispatchDetail { + aggregate: ContainerOutboundAggregate + timeline: Array<{ + module: string + action: string + fromStatus?: string | null + toStatus?: string | null + operatorId?: string | null + operatorName?: string | null + remark?: string | null + occurredAt: string + }> +} + +export interface ContainerOutboundActionRequest { + expectedVersion: number + remark?: string +} From 4088e36f7432c3f2786b3d354ad31d64ce75e2de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:36:01 +0800 Subject: [PATCH 05/13] fix(admin): compact container departure gates --- .../pages/ContainerDetailView.test.ts | 104 ++++++++++++++++-- .../pages/ContainerDetailView.vue | 46 ++++++-- 2 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 790c6a8f..6289c2dd 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -29,7 +29,7 @@ const overview = { const outboundDetail = { aggregate: { containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111', - loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4, + loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4, inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }], gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] }, }, @@ -100,8 +100,14 @@ describe('ContainerDetailView', () => { expect(getContainerOutboundDispatch).toHaveBeenCalledOnce() expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297') expect(wrapper.text()).toContain('国内出仓门禁') - expect(wrapper.text()).toContain('报关协同完成') - expect(wrapper.text()).toContain('IN-1') + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1') + expect(wrapper.find('.gate-summary').text()).toContain('已通过 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 0') + expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.find('.gate-list').text()).toContain('报关协同完成') + expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码') + expect(wrapper.find('.gate-list').text()).toContain('IN-1') expect(wrapper.text()).toContain('确认离仓') wrapper.unmount() }) @@ -142,14 +148,98 @@ describe('ContainerDetailView', () => { ...outboundDetail, aggregate: { ...outboundDetail.aggregate, - gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] }, + gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' }] } }] }, }, } as never) const wrapper = mountView(); await flushPromises() - expect(wrapper.text()).toContain('关联拖车已离仓') - expect(wrapper.text()).toContain('ARRIVED') - expect(wrapper.text()).toContain('DEPARTED或COMPLETED') + expect(wrapper.text()).toContain('船期有效且未过期') + expect(wrapper.text()).toContain('EXPIRED') + expect(wrapper.text()).toContain('VALID') expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false) wrapper.unmount() }) + + it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => { + const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' } + const passed = { code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: true, actual: 'VALID', requirement: 'VALID' } + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [ + { inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } }, + { inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } }, + ], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2') + expect(wrapper.find('.gate-summary').text()).toContain('已通过 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 1') + expect(wrapper.findAll('.gate-row')).toHaveLength(1) + expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码') + expect(wrapper.find('.gate-row').text()).toContain('IN-101') + expect(wrapper.find('.gate-row').text()).toContain('IN-102') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.findAll('.gate-row')).toHaveLength(2) + expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码') + wrapper.unmount() + }) + + it('keeps different facts for the same gate code separate and labels the affected inbound', async () => { + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [ + { inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }, + { inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } }, + { inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }, + ], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.findAll('.gate-row')).toHaveLength(2) + expect(wrapper.find('.gate-list').text()).toContain('MISSING') + expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING') + expect(wrapper.find('.gate-list').text()).toContain('PENDING') + expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING') + await wrapper.find('.gate-summary button').trigger('click') + expect(wrapper.findAll('.gate-row')).toHaveLength(3) + expect(wrapper.find('.gate-list').text()).toContain('IN-DONE') + wrapper.unmount() + }) + + it('keeps truck status as reference only and never counts the legacy truck gate as blocking', async () => { + vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({ + ...outboundDetail, + aggregate: { + ...outboundDetail.aggregate, + truckAppointmentStatus: 'ARRIVED', + gate: { + passed: false, + checkedAt: '2026-08-26T12:00:00', + inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [ + { code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }, + { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }, + ] } }], + }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1') + expect(wrapper.find('.gate-summary').text()).toContain('阻断 0') + expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓') + expect(wrapper.text()).not.toContain('关联拖车已离仓') + expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓') + wrapper.unmount() + }) }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 796cda0d..58f81bf7 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -13,7 +13,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' import { uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' -import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' +import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, @@ -62,17 +62,37 @@ const outboundLoading = ref(false) const outboundConfirming = ref(false) const outboundDetail = ref(null) const outboundError = ref(null) +const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value]) const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情') const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常') -const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) => - (inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode })))) +type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] } +const outboundGateRows = computed(() => { + const grouped = new Map() + for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) { + for (const item of inbound.gate.items || []) { + if (item.code === 'TRUCK_DEPARTED') continue + const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name]) + const existing = grouped.get(key) + if (existing) { + if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode) + } else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] }) + } + } + return [...grouped.values()] +}) +const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed)) +const outboundGateSummary = computed(() => ({ + total: outboundGateRows.value.length, + passed: outboundGateRows.value.filter((row) => row.passed).length, + blocked: outboundGateRows.value.filter((row) => !row.passed).length, +})) const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH' - && outboundDetail.value.aggregate.gate.passed) + && outboundGateSummary.value.blocked === 0) function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' } function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' } @@ -90,7 +110,7 @@ async function load() { async function loadOutboundDispatch() { if (!containerId.value || !canViewOutboundDispatch.value) return - outboundLoading.value = true; outboundError.value = null + outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) } catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') } finally { outboundLoading.value = false } @@ -123,6 +143,9 @@ async function confirmDeparture() { function outboundStatusText(status?: string | null) { return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—' } +function truckReferenceText(status?: string | null) { + return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联' +} function openDocument(type: string) { if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return } @@ -211,14 +234,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
-

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundDetail.aggregate.gate.passed ? '门禁通过' : '门禁阻断' }}确认离仓
+

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}确认离仓
@@ -233,5 +259,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op From 4a7a5ae5b66f7cfabe0d4b743d2982996604bc4a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:46:46 +0800 Subject: [PATCH 06/13] feat(admin): complete customs from container detail --- .../components/ContainerOverviewPanel.vue | 14 ++- .../pages/ContainerDetailView.test.ts | 86 +++++++++++++++++-- .../pages/ContainerDetailView.vue | 36 +++++++- 3 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index b7a3aeb5..7a3d7f47 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,6 +1,6 @@ From 627b9602d1d873346a32b1d392d1669b9e82e067 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Wed, 26 Aug 2026 14:33:13 +0800 Subject: [PATCH 11/13] 1 --- .../pages/FinanceCustomerStatementsView.vue | 14 +++++++------- .../finance/pages/FinancePresentation.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 00d6d5ec..4f68a5e8 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -99,18 +99,18 @@ const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce(( const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '') const isDetailRoute = computed(() => Boolean(detailRouteId.value)) const fields = computed(() => [ - { key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [ + { key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '请选择审核状态', options: [ { label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' }, ] }, { key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' }, - { key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }, + { key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }, { key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } }, - { key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, - { key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, - { key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, - { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, - { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, + { key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, + { key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, + { key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '请选择结清状态', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, + { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, + { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' }, diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts index f3440c04..dc607ff4 100644 --- a/src/modules/finance/pages/FinancePresentation.test.ts +++ b/src/modules/finance/pages/FinancePresentation.test.ts @@ -67,13 +67,19 @@ describe('finance presentation governance', () => { }) it('uses optional all-customer filters without a customer-selection blocker', () => { - for (const source of [settlement, statements]) { + for (const source of [settlement]) { expect(source).toContain("placeholder: '全部客户'") expect(source).not.toContain('全客户查询暂不可用') expect(source).not.toContain('empty-text="请选择客户查询') } expect(arap).toContain("placeholder: '请选择客户'") expect(arap).not.toContain("placeholder: '全部客户'") + expect(statements).toContain("placeholder: '请选择客户'") + expect(statements).toContain("placeholder: '请选择审核状态'") + expect(statements).toContain("placeholder: '请选择结清状态'") + expect(statements).toContain("startPlaceholder: '请选择开始日期'") + expect(statements).toContain("endPlaceholder: '请选择结束日期'") + expect(statements).not.toContain("placeholder: '全部客户'") }) it('uses active-customer name selectors across all formal finance pages', () => { From 2563ad7be3a71c18cb424fdf0dd35c49fcae61f9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 14:48:48 +0800 Subject: [PATCH 12/13] =?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/modules/suppliers/api/platformSuppliers.ts | 1 + src/modules/suppliers/pages/SupplierDetailView.vue | 4 ++-- src/modules/suppliers/pages/SupplierFormView.vue | 13 +++++++++---- .../pages/SupplierRouteReuse.behavior.test.ts | 2 +- src/modules/suppliers/pages/SuppliersView.test.ts | 10 ++++++++++ src/modules/suppliers/pages/SuppliersView.vue | 4 +++- src/modules/suppliers/types.ts | 8 ++++++++ 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/modules/suppliers/api/platformSuppliers.ts b/src/modules/suppliers/api/platformSuppliers.ts index 3661e8d9..ec361b31 100644 --- a/src/modules/suppliers/api/platformSuppliers.ts +++ b/src/modules/suppliers/api/platformSuppliers.ts @@ -9,6 +9,7 @@ function normalizeSupplier(raw: Supplier): Supplier { return { ...raw, id: String(raw.id), tenantId: String(raw.tenantId), + carrierCode: raw.carrierCode ?? null, licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy), version: Number(raw.version), deleted: Boolean(raw.deleted), } diff --git a/src/modules/suppliers/pages/SupplierDetailView.vue b/src/modules/suppliers/pages/SupplierDetailView.vue index ef026031..f486e2d4 100644 --- a/src/modules/suppliers/pages/SupplierDetailView.vue +++ b/src/modules/suppliers/pages/SupplierDetailView.vue @@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue' import { getSupplier } from '@/modules/suppliers/api/platformSuppliers' -import { categoryLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types' +import { categoryLabels, formatCarrierCode, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types' const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore() const canView = computed(() => access.can('platform:supplier:list')) @@ -19,7 +19,7 @@ const supplierId = computed(() => typeof route.params.id === 'string' ? route.pa const supplier = ref(null); const loading = ref(false); const error = ref(null); const licenseOpen = ref(false) let loadSequence = 0 const items = computed(() => supplier.value ? [ - ['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['地区', supplier.value.region], + ['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['船司标准编码', formatCarrierCode(supplier.value.carrierCode)], ['地区', supplier.value.region], ['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'], ['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'], ] : []) diff --git a/src/modules/suppliers/pages/SupplierFormView.vue b/src/modules/suppliers/pages/SupplierFormView.vue index 8a4e0c9f..55aab772 100644 --- a/src/modules/suppliers/pages/SupplierFormView.vue +++ b/src/modules/suppliers/pages/SupplierFormView.vue @@ -13,7 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers' -import { categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types' +import { carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types' const route = useRoute() const router = useRouter() @@ -26,10 +26,11 @@ const loading = ref(false) const saving = ref(false) const loadError = ref(null) const submitError = ref(null) -const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }) +const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', carrierCode: null, region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }) const form = reactive(initial()) const rules: FormRules = { name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }], + carrierCode: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && !value ? callback(new Error('请选择船司标准编码')) : callback(), trigger: 'change' }], region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }], contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }], email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], status: [{ required: true, message: '请选择合作状态', trigger: 'change' }], @@ -39,7 +40,9 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor', function optional(value: string | null) { return value?.trim() || null } function payload(): SaveSupplierRequest { - return { ...form, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) } + const { carrierCode, ...fields } = form + const normalized = { ...fields, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) } + return form.category === 'BOOKING' ? { ...normalized, carrierCode } : normalized } let loadSequence = 0 function resetForm() { Object.assign(form, initial()) } @@ -55,7 +58,7 @@ async function load() { try { const data = await getSupplier(requestedId) if (sequence !== loadSequence || requestedId !== supplierId.value) return - Object.assign(form, { name: data.name, category: data.category, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark }) + Object.assign(form, { name: data.name, category: data.category, carrierCode: data.category === 'BOOKING' ? data.carrierCode : null, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark }) captureBaseline() } catch (error) { if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败') @@ -74,6 +77,7 @@ async function save() { } watch(supplierId, () => { void load() }, { immediate: true }) +watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCode = null }) + diff --git a/src/modules/suppliers/types.ts b/src/modules/suppliers/types.ts index 807d4923..68198f16 100644 --- a/src/modules/suppliers/types.ts +++ b/src/modules/suppliers/types.ts @@ -1,12 +1,14 @@ export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE' export type SupplierStatus = 'ACTIVE' | 'INACTIVE' export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE' +export type CarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER' export interface Supplier { id: string tenantId: string name: string category: SupplierCategory + carrierCode?: CarrierCode | null region: string legalRepresentative: string contactName: string @@ -48,6 +50,7 @@ export interface SupplierQuery { export interface SaveSupplierRequest { name: string category: SupplierCategory + carrierCode?: CarrierCode | null region: string legalRepresentative: string contactName: string @@ -62,6 +65,11 @@ export interface SaveSupplierRequest { } export const categoryLabels: Record = { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' } +export const carrierCodeLabels: Record = { + MATSON: '美森', SML: 'SML', ZIM: '以星', COSCO: '中远', EMC: '长荣', HEDE: '合德', OTHER: '其他', +} +export const carrierCodeOptions = (Object.entries(carrierCodeLabels) as [CarrierCode, string][]).map(([value, name]) => ({ value, label: `${name} (${value})` })) +export function formatCarrierCode(code: CarrierCode | null | undefined) { return code ? `${carrierCodeLabels[code]} (${code})` : '—' } export const statusLabels: Record = { ACTIVE: '合作中', INACTIVE: '停用' } export const settlementLabels: Record = { MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结', From b907881740deb82ac530ad3a676fec6ad07eb1cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 15:41:08 +0800 Subject: [PATCH 13/13] fix(admin): prevent duplicate actual carrier nodes --- ...CarrierScheduleQueryPanel.behavior.test.ts | 41 +++++++++++++++++++ .../CarrierScheduleQueryPanel.test.ts | 8 ++++ .../components/CarrierScheduleQueryPanel.vue | 35 ++++++++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts index e9970523..1865e974 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts @@ -132,6 +132,47 @@ describe('CarrierScheduleQueryPanel loading states', () => { expect(wrapper.text()).toContain('人工补录标准节点') }) + it('shows only the latest duplicate actual node in the primary trajectory and keeps the older fact expandable', async () => { + const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null } + const older = { id: 'pickup-1', providerCode: 'MANUAL', providerEventId: 'pickup-old', providerNodeCode: 'PICKED_UP', standardNode: 'PICKED_UP' as const, nodeSequence: 90, observedAt: '2026-09-09T10:05:00', estimatedAt: null, actualAt: '2026-09-09T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: '人工提柜-旧', rawPayloadHash: '1'.repeat(64), createdAt: '2026-09-09T10:06:00' } + const latest = { ...older, id: 'pickup-2', providerEventId: 'pickup-latest', observedAt: '2026-09-24T10:05:00', actualAt: '2026-09-24T10:00:00', sourceReference: '人工提柜-最新', rawPayloadHash: '2'.repeat(64), createdAt: '2026-09-24T10:06:00' } + vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [older, latest], exceptions: [], histories: [] }) + const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } }) + await flushPromises() + + const primaryPickupRows = wrapper.findAll('.fulfillment-timeline li').filter((row) => row.text().includes('已提柜')) + expect(primaryPickupRows).toHaveLength(1) + expect(primaryPickupRows[0]!.text()).toContain('2026-09-24 10:00') + expect(primaryPickupRows[0]!.text()).not.toContain('2026-09-09 10:00') + expect(wrapper.find('.duplicate-node-history').text()).toContain('历史重复实际节点(1)') + expect(wrapper.find('.duplicate-node-history').text()).toContain('2026-09-09 10:00') + expect(wrapper.find('.duplicate-node-history').text()).toContain('人工提柜-旧') + }) + + it('disables same and earlier actual nodes and rejects an invalid actual submission defensively', async () => { + const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null } + vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [], exceptions: [], histories: [] }) + const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click') + const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' }) + pickers[1]!.vm.$emit('update:modelValue', '2026-09-25T10:00:00') + await flushPromises() + const options = wrapper.findAllComponents({ name: 'ElOption' }) + expect(options.find((option) => option.props('value') === 'DEPARTED_WAREHOUSE')!.props('disabled')).toBe(true) + expect(options.find((option) => option.props('value') === 'PICKED_UP')!.props('disabled')).toBe(true) + expect(options.find((option) => option.props('value') === 'EMPTY_RETURNED')!.props('disabled')).toBe(false) + + wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'PICKED_UP') + wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '重复提柜事实') + await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(wrapper.text()).toContain('实际补录只能选择严格晚于当前实际进度的节点') + expect(appendCarrierScheduleManualNode).not.toHaveBeenCalled() + }) + it('hides the manual action without the frozen permission while retaining fulfillment facts', async () => { useAuthStore().permissions = [] vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 }) diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts index e7594136..80c88f84 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts @@ -57,6 +57,14 @@ describe('CarrierScheduleQueryPanel', () => { expect(source).toContain('item.actualAt') }) + it('blocks repeated actual progress and folds older immutable facts out of the primary trajectory', () => { + expect(source).toContain('isManualActualNodeAllowed') + expect(source).toContain(':disabled="Boolean(manualForm.actualAt) && !isManualActualNodeAllowed(node)"') + expect(source).toContain('primaryNodeFacts') + expect(source).toContain('supersededActualNodes') + expect(source).toContain('历史重复实际节点') + }) + it('uses the frozen manual-node permission and keeps exceptions separate', () => { expect(source).toContain("access.can('platform:firstleg:port-schedule-query:manual')") expect(source).toContain('appendCarrierScheduleManualNode') diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue index 130a6803..4e61f504 100644 --- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue +++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue @@ -52,6 +52,30 @@ const sourceReference = computed(() => latestActualNodeFact.value?.sourceReferen const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null) const histories = computed(() => [...(detail.value?.histories || [])].reverse()) const standardNodes: CarrierScheduleStandardNode[] = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED'] +const standardNodeSequence = new Map(standardNodes.map((node, index) => [node, (index + 1) * 10])) +const latestActualNodeSequence = computed(() => query.value?.latestActualNode ? standardNodeSequence.get(query.value.latestActualNode) || 0 : 0) + +function compareActualFacts(left: CarrierScheduleNodeFact, right: CarrierScheduleNodeFact) { + for (const field of ['actualAt', 'observedAt', 'createdAt'] as const) { + const compared = String(left[field] || '').localeCompare(String(right[field] || '')) + if (compared) return compared + } + return left.id.localeCompare(right.id) +} +const latestActualFactsByNode = computed(() => { + const result = new Map() + for (const item of nodes.value) { + if (!item.actualAt) continue + const current = result.get(item.standardNode) + if (!current || compareActualFacts(item, current) > 0) result.set(item.standardNode, item) + } + return result +}) +const supersededActualNodes = computed(() => nodes.value + .filter((item) => item.actualAt && latestActualFactsByNode.value.get(item.standardNode)?.id !== item.id) + .sort((left, right) => compareActualFacts(right, left))) +const primaryNodeFacts = computed(() => nodes.value.filter((item) => !item.actualAt || latestActualFactsByNode.value.get(item.standardNode)?.id === item.id)) +function isManualActualNodeAllowed(node: CarrierScheduleStandardNode) { return (standardNodeSequence.get(node) || 0) > latestActualNodeSequence.value } function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record)[value] || value } @@ -75,7 +99,7 @@ const fulfillmentEvents = computed(() => { values.push({ key: `warehouse-${item.eventType}-${item.occurredAt}-${index}`, time: item.occurredAt, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(item.eventType), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true }) } const nodeKeys = new Set() - for (const item of nodes.value) { + for (const item of primaryNodeFacts.value) { const actual = Boolean(item.actualAt) const time = item.actualAt || item.estimatedAt || item.observedAt const key = JSON.stringify([item.standardNode, item.actualAt || null, item.estimatedAt || null, item.locationCode || null, item.locationName || null, item.providerCode, item.sourceReference || null]) @@ -111,6 +135,7 @@ async function submitManualNode() { manualValidationError.value = '' if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return } if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return } + if (manualForm.actualAt && !isManualActualNodeAllowed(manualForm.standardNode)) { manualValidationError.value = '实际补录只能选择严格晚于当前实际进度的节点'; return } if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return } const base = { expectedVersion: query.value.version, standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() } const request: CarrierScheduleManualNodeRequest = manualForm.actualAt @@ -147,6 +172,9 @@ async function load() { watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true }) watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } }) +watch(() => manualForm.actualAt, (actualAt) => { + if (actualAt && manualForm.standardNode && !isManualActualNodeAllowed(manualForm.standardNode)) manualForm.standardNode = '' +}) - +