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/package.json b/package.json index c2c1c799..c5494404 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,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 0ac17ad6..a0f43874 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, ElMessageBox } from 'element-plus' import 'element-plus/es/components/message/style/css' diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index e43aff2b..d27daf98 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -3,7 +3,7 @@ import { http } from '@/shared/api/http' import { adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem, - getContainerClearance, uploadContainerClearance, completeContainerClearance, + getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking, @@ -136,11 +136,26 @@ 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') + }) + + it('downloads destination clearance through its exact protected endpoint with string ids', async () => { + const blob = new Blob(['clearance']) + vi.mocked(http.get).mockResolvedValueOnce(blob) + const result = await downloadContainerClearance('2092438843630391297', '2092438843630393502') + expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/2092438843630391297/clearance/document/2092438843630393502/download', { responseType: 'blob' }) + expect(result).toBe(blob) }) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 083b835a..c1aa5393 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) { @@ -200,6 +206,10 @@ export async function getContainerClearance(containerId: string) { return document(unwrapApiResponse(response)) } +export function downloadContainerClearance(containerId: string, fileId: string) { + return http.get(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' }) +} + export async function uploadContainerClearance(containerId: string, file: File, expectedVersion: number, operationReason?: string | null) { const body = new FormData() body.append('file', file) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index ea73c011..4767d2ce 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 作业进度') @@ -25,29 +36,133 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER') expect(wrapper.text()).toContain('一项待复核') expect(wrapper.text()).toContain('码头查验') - expect(wrapper.text()).toContain('更新预计到港时间') + expect(wrapper.text()).not.toContain('更新预计到港时间') }) 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('renders four compact document table rows with the operation column first', () => { + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text()) + expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态']) + expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4) + expect(wrapper.findAll('.document-item')).toHaveLength(0) + expect(wrapper.find('.document-table-wrap').exists()).toBe(true) + const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))! + expect(deliveryRow.text()).toContain('do.pdf') + expect(deliveryRow.text()).toContain('操作员') + expect(deliveryRow.text()).toContain('2026-08-13 10:00') + expect(deliveryRow.text()).toContain('已上传') + expect(deliveryRow.find('.document-file-name').attributes('title')).toBe('do.pdf') }) - it('renders empty and forbidden/load failure states without exposing credentials', () => { + it('renders one compact normal exception section with a single-line empty history', () => { + const normal = { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [] } + const wrapper = mount(ContainerOverviewPanel, { props: { overview: normal, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + const section = wrapper.find('.exception-management') + expect(section.text()).toContain('异常管理') + expect(section.text()).toContain('正常') + expect(section.text()).toContain('标记异常') + expect(section.text()).toContain('暂无异常处理记录') + expect(section.find('.el-empty').exists()).toBe(false) + expect(wrapper.findAll('[aria-label="异常管理"]')).toHaveLength(1) + }) + + it('keeps current exception summary and multiple history rows together without mixing transport logs', () => { + const histories = [ + ...overview.exceptionHistory, + { id: '15', action: 'CANCEL', reason: '查验解除', operatorId: '16', operatorName: '复核员', occurredAt: '2026-08-13T11:30:00' }, + ] + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exceptionHistory: histories }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + const section = wrapper.find('.exception-management') + expect(section.text()).toContain('异常') + expect(section.text()).toContain('码头查验') + expect(section.text()).toContain('操作员 · 2026-08-13 11:00') + expect(section.findAll('.exception-history li')).toHaveLength(2) + expect(section.text()).toContain('取消异常') + expect(section.text()).toContain('复核员') + expect(section.text()).toContain('查验解除') + expect(section.text()).not.toContain('更新预计到港时间') + expect(wrapper.find('[aria-label="运输轨迹和操作日志"]').exists()).toBe(false) + }) + + it('emits the unchanged manage action for mark and cancel buttons', async () => { + let wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await wrapper.find('.exception-management button').trigger('click') + expect(wrapper.emitted('manageException')).toHaveLength(1) + wrapper.unmount() + wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await wrapper.find('.exception-management button').trigger('click') + expect(wrapper.emitted('manageException')).toHaveLength(1) + expect(wrapper.find('.exception-management button').text()).toContain('取消异常') + wrapper.unmount() + }) + + 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('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('delegates warehouse events to the unified fulfillment timeline without exposing technical keys here', () => { + 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()).not.toContain('领取 PDA 任务') + expect(wrapper.text()).not.toContain('提交拣货结果') + expect(wrapper.text()).not.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..5f54dd0c 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,43 +1,74 @@ diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 9172914b..29e2c4b2 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -1,40 +1,427 @@ -import { describe, expect, it } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +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 { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' +import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' +import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' +import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' +import { ApiResponseError } from '@/shared/api/http' -const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') +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: '
', mounted: () => { carrierMounts.value += 1 } } })) +vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: 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(), + downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), + downloadContainerClearance: 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: [], +} +const outboundDetail = { + aggregate: { + containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111', + 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' }] } }] }, + }, + timeline: [], +} as ContainerOutboundDispatchDetail + +function mountView(customs = true, outbound = true) { + accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' + || (customs && permission === 'platform:firstleg:customs:manage') + || permission === 'platform:firstleg:loading-list:list' + || (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-table tbody tr').find((item) => item.text().includes(label))! } +function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! } 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() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + carrierMounts.value = 0 + vi.mocked(getContainer).mockResolvedValue(detail) + vi.mocked(getContainerOverview).mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockResolvedValue(null) + vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never) + vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never) + vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs'])) + vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance'])) + vi.mocked(getLoadingDeclaration).mockResolvedValue(null) + 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('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 documentButton(wrapper, '国内出口报关附件', '更新附件').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 documentButton(wrapper, '国内出口报关附件', '更新附件').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, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载']) + 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() + }) + + it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises() + expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502') + expect(downloadContainerDocument).not.toHaveBeenCalled() + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(click).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).toHaveBeenCalled() + wrapper.unmount() + }) + + it('keeps customs download after completion while hiding update and completion actions', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never) + const wrapper = mountView(); await flushPromises() + const row = documentArticle(wrapper, '国内出口报关附件') + expect(row.findAll('button').map((button) => button.text())).toEqual(['下载']) + expect(row.text()).toContain('已完成') + wrapper.unmount() + }) + + it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable')) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises() + const file = new File(['customs-new'], 'customs-new.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(); await flushPromises() + const row = documentArticle(wrapper, '国内出口报关附件') + expect(row.text()).toContain('customs-new.pdf') + expect(row.text()).toContain('操作员') + expect(row.text()).toContain('2026-08-26 12:00') + expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果') + expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件') + wrapper.unmount() + }) + + it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => { + const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 } + const withoutCustoms = { ...overview, documents: [] } + vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never) + vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never) + vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409')) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises() + const file = new File(['next'], 'next.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(); await flushPromises() + expect(uploadLoadingDeclaration).toHaveBeenCalledOnce() + expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件') + expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002') + expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf') + expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员') + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).toContain('request-upload-409') + wrapper.unmount() + }) + + it('uses the independent destination document version and immediately replaces the table row after update', async () => { + const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 } + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' }) + ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) + await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料') + expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf') + wrapper.unmount() + }) + + it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => { + const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true } + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const wrapper = mountView(); await flushPromises() + const row = documentArticle(wrapper, '目的港清关资料') + expect(row.findAll('button').map((button) => button.text())).toEqual(['下载']) + await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises() + expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502') + expect(downloadLoadingDeclaration).not.toHaveBeenCalled() + expect(downloadContainerDocument).not.toHaveBeenCalled() + expect(click).toHaveBeenCalledOnce() + wrapper.unmount() + }) + + it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => { + const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 } + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never) + vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409')) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) + ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) + await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + expect(uploadContainerClearance).toHaveBeenCalledOnce() + expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf') + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).toContain('request-clearance-409') + wrapper.unmount() + }) + + it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => { + const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never) + const wrapper = mountView(); await flushPromises() + const customs = documentArticle(wrapper, '国内出口报关附件') + expect(customs.text()).toContain('附件已上传,待确认报关完成') + expect(customs.text()).toContain('更新附件') + await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises() + expect(completeLoadingDeclaration).toHaveBeenCalledOnce() + expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成') + expect(uploadLoadingDeclaration).not.toHaveBeenCalled() + expect(getContainerOverview).toHaveBeenCalledTimes(2) + expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2) + expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成') + expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined() + wrapper.unmount() + }) + + it('shows customs completion only for an existing unfinished attachment with manage permission', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never) + let wrapper = mountView(); await flushPromises() + expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成') + expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined() + wrapper.unmount() + + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never) + wrapper = mountView(); await flushPromises() + expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined() + wrapper.unmount() + + wrapper = mountView(false); await flushPromises() + expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined() + wrapper.unmount() + }) + + it('shows a readable completion error without a loading plan and does not call the API', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成') + expect(completeLoadingDeclaration).not.toHaveBeenCalled() + expect(ElMessageBox.confirm).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => { + let releasePrompt!: () => void + vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise((resolve) => { releasePrompt = resolve }) as never) + const wrapper = mountView(); await flushPromises() + const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成') + await button.trigger('click'); await button.trigger('click') + expect(ElMessageBox.confirm).toHaveBeenCalledOnce() + releasePrompt(); await flushPromises() + vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409')) + vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never) + await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises() + expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).toContain('request-customs-409') + expect(getContainerOverview).toHaveBeenCalledTimes(3) + expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3) + 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.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() + }) + + 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: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] }, + }, + } as never) + const wrapper = mountView(); await flushPromises() + expect(wrapper.text()).toContain('报关协同完成') + expect(wrapper.text()).toContain('MISSING') + expect(wrapper.text()).toContain('COMPLETED') + 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: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' } + 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 and sailing schedule as reference only and never counts legacy reference gates 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: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' }, + { 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.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 16af6b11..8f2386d3 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' @@ -11,12 +11,15 @@ 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 { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' +import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' +import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import { cancelContainerException, completeContainerClearance, + downloadContainerClearance, downloadContainerDocument, - downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, @@ -24,24 +27,32 @@ 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 canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list')) +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) 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) const documentError = ref(null) +const customsCompleting = ref(false) +const customsActionError = ref(null) +const documentRefreshWarning = ref(null) const clearanceOpen = ref(false) const clearanceDocument = ref(null) const clearanceFile = ref(null) @@ -52,13 +63,78 @@ const exceptionOpen = ref(false) const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) -const photoBusyId = ref(null) -const photoError = ref(null) +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(() => 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 ? '取消货柜异常' : '标记货柜异常') +type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] } +const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE']) +const outboundGateRows = computed(() => { + const grouped = new Map() + for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) { + for (const item of inbound.gate.items || []) { + if (referenceOnlyGateCodes.has(item.code)) 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' + && outboundGateSummary.value.blocked === 0) + +function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument { + return { + id: document.id || fallback?.id || document.fileId, + documentType, fileId: document.fileId, fileName: document.fileName, fileHash: document.fileHash, + uploadedBy: document.uploadedBy, uploadedByName: document.uploadedByName, uploadedAt: document.uploadedAt, + version: document.version, completed: Boolean(document.completedAt), + } +} +function replaceOverviewDocument(document: ContainerOverviewDocument) { + if (!overview.value) return + overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] } +} +async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') { + let refreshCause: unknown + let refreshed: ContainerOverview | null = null + try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause } + let current = refreshed?.documents.find((item) => item.documentType === documentType) || null + if (!current) { + try { + const business = documentType === 'CUSTOMS_DECLARATION' + ? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null + : await getContainerClearance(containerId.value) + if (business) { + if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business + current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType)) + } + } catch (cause) { if (!refreshCause) refreshCause = cause } + } + if (refreshed) overview.value = current && !refreshed.documents.some((item) => item.documentType === documentType) + ? { ...refreshed, documents: [...refreshed.documents, current] } + : refreshed + else if (current) replaceOverviewDocument(current) + else if (refreshCause) throw refreshCause + return current +} 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,43 +145,150 @@ 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; showPassedGates.value = false + 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 truckReferenceText(status?: string | null) { + return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联' +} + 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 } - documentSaving.value = true; documentError.value = null + if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } + documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null try { - await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`) - overview.value = await getContainerOverview(containerId.value) + const expectedVersion = selectedDocument.value?.version || 0 + if (documentType.value === 'CUSTOMS_DECLARATION') { + if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件') + const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件') + if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息') + const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value) + replaceOverviewDocument(optimistic) + try { + const refreshed = await getContainerOverview(containerId.value) + if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed + else { + overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] } + documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' } + } + } + catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') } + } 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}上传失败`) } + } catch (cause) { + const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) + if (details.code === 'A0409') { + if (documentType.value === 'CUSTOMS_DECLARATION') { + try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ } + } else { + try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ } + } + } + documentError.value = details + } finally { documentSaving.value = false } } +async function refreshCustomsFacts() { + overview.value = await getContainerOverview(containerId.value) + await loadOutboundDispatch() +} +async function completeCustomsDeclaration(document: ContainerOverviewDocument) { + if (!canManageCustoms.value || customsCompleting.value || document.completed) return + if (!overview.value?.loadingPlanId || !detail.value) { + customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' } + return + } + customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null + try { + await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' }) + } catch { customsCompleting.value = false; return } + try { + await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成') + try { await refreshCustomsFacts() } + catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') } + ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验') + } catch (cause) { + const details = getApiErrorDetails(cause, '确认报关完成失败') + if (details.code === 'A0409') { + try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ } + } + customsActionError.value = details + } finally { customsCompleting.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 (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return + if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return + if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) { + customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' } + return + } documentDownloading.value = true - try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') } - catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) } + try { + const blob = item.documentType === 'CUSTOMS_DECLARATION' + ? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId) + : item.documentType === 'DESTINATION_CLEARANCE' + ? await downloadContainerClearance(containerId.value, item.fileId) + : await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId) + await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始') + } catch (cause) { + const details = getApiErrorDetails(cause, '资料下载失败') + if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details + if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details + ElMessage.error(details.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,16 +311,31 @@ 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 { + const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料') + if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息') + clearanceDocument.value = uploaded + replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE'))) + try { + const refreshed = await getContainerOverview(containerId.value) + overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] } + } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') } + clearanceFile.value = null; ElMessage.success('目的港清关资料已上传') + } catch (cause) { + const details = getApiErrorDetails(cause, '目的港清关资料上传失败') + if (details.code === 'A0409') { + try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ } + } + clearanceError.value = details + } finally { clearanceSaving.value = false } } async function completeClearance() { if (!clearanceDocument.value) return clearanceSaving.value = true; clearanceError.value = null - try { clearanceDocument.value = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } + try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') } finally { clearanceSaving.value = false } } @@ -154,17 +352,35 @@ 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 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: '现结', diff --git a/src/modules/unified-orders/api/unifiedOrders.test.ts b/src/modules/unified-orders/api/unifiedOrders.test.ts index 459c2045..bc41dd28 100644 --- a/src/modules/unified-orders/api/unifiedOrders.test.ts +++ b/src/modules/unified-orders/api/unifiedOrders.test.ts @@ -5,6 +5,7 @@ import { batchApprovePlatformUnifiedOrders, addPlatformUnifiedOrderNote, getPlatformUnifiedOrder, + downloadPlatformProductImage, listPlatformUnifiedOrders, listPlatformUnifiedOrderFeeDetails, } from './unifiedOrders' @@ -90,6 +91,14 @@ describe('unified orders API contract', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } }) }) + it('downloads a tenant-scoped product image for platform order details', async () => { + const image = new Blob(['image'], { type: 'image/png' }) + vi.mocked(http.get).mockResolvedValue(image) + + await expect(downloadPlatformProductImage('208/1', '90001')).resolves.toBe(image) + expect(http.get).toHaveBeenCalledWith('/platform/v1/files/product-images/208%2F1/90001', { responseType: 'blob' }) + }) + it('uses the explicit first-leg approval boundary for pending review', async () => { vi.mocked(http.post).mockResolvedValue({ data: null }) diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts index 8e184c75..1ff047a0 100644 --- a/src/modules/unified-orders/api/unifiedOrders.ts +++ b/src/modules/unified-orders/api/unifiedOrders.ts @@ -395,6 +395,13 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) return { ...result, summary: normalizeRow(result.summary) } } +export function downloadPlatformProductImage(tenantId: string, fileId: string) { + return http.get( + `/platform/v1/files/product-images/${encodeURIComponent(tenantId)}/${encodeURIComponent(fileId)}`, + { responseType: 'blob' }, + ) +} + export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) { return http.post, ApiEnvelope>( `/platform/v1/orders/first-leg/${orderId}/approve`, diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts index 23617953..d5870506 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts @@ -114,4 +114,20 @@ describe('UnifiedOrdersView list request coordination', () => { expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4) expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 })) }) + + it('does not turn a cleared full-container filter into false', async () => { + const wrapper = mountView() + await flushPromises() + const toolbar = wrapper.getComponent({ name: 'QueryToolbar' }) + const selected = { ...toolbar.props('modelValue'), fullContainer: 'true' } + toolbar.vm.$emit('update:modelValue', selected) + toolbar.vm.$emit('search') + await flushPromises() + expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: true })) + + toolbar.vm.$emit('update:modelValue', { ...selected, fullContainer: undefined }) + toolbar.vm.$emit('search') + await flushPromises() + expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: undefined })) + }) }) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index a4563730..63ec9403 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -5,6 +5,7 @@ import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, Hi import { useRoute, useRouter } from 'vue-router' import { getPlatformUnifiedOrder, + downloadPlatformProductImage, approvePlatformUnifiedOrder, batchApprovePlatformUnifiedOrders, addPlatformUnifiedOrderNote, @@ -137,6 +138,7 @@ const measurementPreviewError = ref(null) const measurementPreviewUrl = ref('') const measurementPreviewTitle = ref('测量图片') const measurementCollapsed = reactive({ customer: false, outer: false, actual: false }) +const cargoImagePreviewUrls = new Set() const measurementDialogOpen = ref(false) const smartLoadingOpen = ref(false) const measurementOrder = ref(null) @@ -296,8 +298,10 @@ function trimmed(value: string) { return value.trim() || undefined } -function optionalBoolean(value: string) { - return value === '' ? undefined : value === 'true' +function optionalBoolean(value: string | null | undefined) { + if (value === 'true') return true + if (value === 'false') return false + return undefined } function selectStatus(status: UnifiedOrderMainStatus | '') { @@ -449,6 +453,29 @@ function cargoImage(cargo: Record) { return typeof value === 'string' && value ? value : '' } +function releaseCargoImagePreviews() { + cargoImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url)) + cargoImagePreviewUrls.clear() +} + +async function loadCargoImagePreviews(resource: UnifiedOrderDetailResource, serial: number) { + const tenantId = String(resource.summary.tenantId || '') + if (!tenantId) return + await Promise.all((resource.order?.cargoes ?? []).map(async (cargo) => { + const fileId = cargo.productImageFileId + if (fileId === null || fileId === undefined || fileId === '') return + try { + const blob = await downloadPlatformProductImage(tenantId, String(fileId)) + if (serial !== detailRequestSerial || detailRouteKey.value !== `${tenantId}:${orderId.value}`) return + const url = URL.createObjectURL(blob) + cargo.imageUrl = url + cargoImagePreviewUrls.add(url) + } catch { + // Historical files may have been removed; keep the remaining cargo fields visible. + } + })) +} + function feeReviewLabel(value: string | null | undefined) { return unifiedOrderReviewStatusLabel(value) } @@ -823,6 +850,7 @@ function runMoreAction(command: string | number | object, row: UnifiedOrderResou } function closeDetail() { + releaseCargoImagePreviews() detail.value = null detailError.value = null void router.push({ name: 'unified-orders' }) @@ -845,12 +873,14 @@ async function loadDetail(force = false) { measurementLoadedKey.value = '' measurementPreviewOpen.value = false releaseMeasurementPreview() + releaseCargoImagePreviews() detailLoading.value = true detailError.value = null try { const response = await getPlatformUnifiedOrder(tenantId, orderId.value) if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return detail.value = response + await loadCargoImagePreviews(response, serial) } catch (error) { if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return detail.value = null @@ -886,6 +916,7 @@ onMounted(() => { onBeforeUnmount(() => { measurementRequestSerial += 1 releaseMeasurementPreview() + releaseCargoImagePreviews() }) diff --git a/src/shared/components/DataTable.test.ts b/src/shared/components/DataTable.test.ts index 6f285ecd..96ef7460 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({ @@ -114,6 +114,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 f063b0c8..510ed917 100644 --- a/src/shared/components/DataTable.vue +++ b/src/shared/components/DataTable.vue @@ -69,6 +69,7 @@ const props = withDefaults(defineProps<{ emptyText?: string total?: number | string pageSizes?: number[] + maxHeight?: string | number preferenceKey?: string columnSettingsMode?: 'auto' | 'show' | 'hide' border?: boolean @@ -78,6 +79,7 @@ const props = withDefaults(defineProps<{ emptyText: '暂无数据', total: undefined, pageSizes: () => [10, 20, 50, 100], + maxHeight: 'calc(100vh - 300px)', columnSettingsMode: 'auto', border: true, stripe: true, @@ -302,7 +304,7 @@ function setPage(value: number) {
- +