From 57de615717000ffb040802bbe7131cbba47358f8 Mon Sep 17 00:00:00 2001
From: wujinliang <2318309077@qq.com>
Date: Tue, 25 Aug 2026 22:08:59 +0800
Subject: [PATCH 01/13] 1
---
package.json | 1 +
pnpm-lock.yaml | 3 +
src/main.ts | 1 +
.../finance/pages/FinanceCustomerArapView.vue | 66 ++++++++++++++++---
.../FinanceCustomerStatementsView.test.ts | 25 +++++++
.../pages/FinanceCustomerStatementsView.vue | 11 +++-
.../finance/pages/FinancePresentation.test.ts | 18 ++++-
.../unified-orders/api/unifiedOrders.test.ts | 9 +++
.../unified-orders/api/unifiedOrders.ts | 7 ++
.../pages/UnifiedOrdersView.requests.test.ts | 16 +++++
.../pages/UnifiedOrdersView.vue | 35 +++++++++-
src/shared/components/DataTable.test.ts | 18 ++++-
src/shared/components/DataTable.vue | 4 +-
13 files changed, 199 insertions(+), 15 deletions(-)
diff --git a/package.json b/package.json
index a7d055b2..c53005ad 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@chongchong/shared": "link:./shared",
"@lucide/vue": "^1.27.0",
"axios": "^1.18.1",
+ "dayjs": "^1.11.21",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"sortablejs": "^1.15.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f9855f2d..88a48763 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
axios:
specifier: ^1.18.1
version: 1.18.1(debug@4.4.3)
+ dayjs:
+ specifier: ^1.11.21
+ version: 1.11.21
element-plus:
specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
diff --git a/src/main.ts b/src/main.ts
index 4c269445..58bbef71 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,5 @@
import { computed, createApp } from 'vue'
+import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus'
import 'element-plus/es/components/message/style/css'
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue
index 03a73983..a6f28198 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.vue
+++ b/src/modules/finance/pages/FinanceCustomerArapView.vue
@@ -1,5 +1,5 @@
diff --git a/src/shared/components/DataTable.test.ts b/src/shared/components/DataTable.test.ts
index 792c264d..71877d40 100644
--- a/src/shared/components/DataTable.test.ts
+++ b/src/shared/components/DataTable.test.ts
@@ -6,8 +6,8 @@ import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const TableStub = defineComponent({
- props: { border: Boolean, stripe: Boolean },
- template: '
',
+ props: { border: Boolean, stripe: Boolean, maxHeight: [String, Number] },
+ template: '
',
methods: { doLayout() {} },
})
const ColumnStub = defineComponent({
@@ -93,6 +93,20 @@ describe('DataTable column preferences', () => {
expect(plainTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'false', 'data-stripe': 'false' })
})
+ it('keeps table bodies within the viewport by default and allows a custom height', () => {
+ const defaultTable = mountTable()
+ expect(defaultTable.get('.table-stub').attributes('data-max-height')).toBe('calc(100vh - 300px)')
+
+ const customTable = shallowMount(DataTable, {
+ props: { data: [], columns: configurableColumns, maxHeight: 420 },
+ global: {
+ stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
+ directives: { loading: {} },
+ },
+ })
+ expect(customTable.get('.table-stub').attributes('data-max-height')).toBe('420')
+ })
+
it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, {
props: {
diff --git a/src/shared/components/DataTable.vue b/src/shared/components/DataTable.vue
index c480ad32..193f35a9 100644
--- a/src/shared/components/DataTable.vue
+++ b/src/shared/components/DataTable.vue
@@ -62,6 +62,7 @@ const props = withDefaults(defineProps<{
emptyText?: string
total?: number
pageSizes?: number[]
+ maxHeight?: string | number
preferenceKey?: string
columnSettingsMode?: 'auto' | 'show' | 'hide'
border?: boolean
@@ -71,6 +72,7 @@ const props = withDefaults(defineProps<{
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
+ maxHeight: 'calc(100vh - 300px)',
columnSettingsMode: 'auto',
border: true,
stripe: true,
@@ -298,7 +300,7 @@ function setPage(value: number) {
-
+
Date: Wed, 26 Aug 2026 11:10:48 +0800
Subject: [PATCH 02/13] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 7 +-
.../api/platformContainerLoading.test.ts | 19 ++-
.../api/platformContainerLoading.ts | 10 +-
.../components/ContainerOverviewPanel.test.ts | 84 ++++++++++--
.../components/ContainerOverviewPanel.vue | 83 ++++++++----
.../pages/ContainerDetailView.test.ts | 98 ++++++++++----
.../pages/ContainerDetailView.vue | 53 ++++----
.../pages/ContainersView.test.ts | 19 +--
.../pages/ContainersView.vue | 124 +-----------------
.../components/PlatformTenantSelector.test.ts | 22 ++++
.../iam/components/PlatformTenantSelector.vue | 5 +-
.../pages/OutboundsView.vue | 2 +-
.../CarrierScheduleQueryPanel.test.ts | 6 +
.../components/CarrierScheduleQueryPanel.vue | 4 +-
14 files changed, 301 insertions(+), 235 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index b5a9937c..6a7a1e3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,6 +36,7 @@
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
+| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
@@ -46,9 +47,9 @@
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
-| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
+| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
-| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
+| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 |
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
@@ -65,6 +66,7 @@
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
+| 海外出库(范围外现存代码) | `modules/overseas-warehouse/pages/OutboundsView.vue`、`api/outbounds.ts`,当前路由和导航已有注册 | 当前四期契约仍明确排除海外仓出库与派送;不得因代码或菜单存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
@@ -79,6 +81,7 @@
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
+- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts
index e43aff2b..1cb86699 100644
--- a/src/modules/container-loading/api/platformContainerLoading.test.ts
+++ b/src/modules/container-loading/api/platformContainerLoading.test.ts
@@ -136,11 +136,18 @@ describe('platform container loading API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
})
- it('downloads PDA photos only from the backend-provided protected container path', async () => {
- vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
- await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download')
- expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' })
- expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path')
- expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
+ it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => {
+ vi.mocked(http.get).mockResolvedValue(new Blob(['photo']))
+ const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download'
+ await downloadContainerPdaPhoto(longPath)
+ await downloadContainerPdaPhoto('/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download')
+ expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
+ expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
+ expect(vi.mocked(http.get).mock.calls.every((call) => !String(call[0]).includes('/api/api/'))).toBe(true)
+ for (const path of [
+ '/api/platform/v1/files/loading-evidence/21/22/23', 'https://example.com/photo.jpg',
+ '//example.com/platform/v1/containers/7/pda/photos/23/download', '/platform/v1/containers/7/pda/photos/23/download?token=x',
+ '/platform/v1/containers/7/pda/photos/../23/download', '/api/api/platform/v1/containers/7/pda/photos/23/download',
+ ]) expect(() => downloadContainerPdaPhoto(path)).toThrow('invalid container photo path')
})
})
diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts
index 083b835a..b09c748b 100644
--- a/src/modules/container-loading/api/platformContainerLoading.ts
+++ b/src/modules/container-loading/api/platformContainerLoading.ts
@@ -165,9 +165,15 @@ export function downloadContainerDocument(containerId: string, documentType: Ext
return http.get(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
+function normalizeContainerPhotoDownloadPath(downloadPath: string) {
+ if (typeof downloadPath !== 'string' || !downloadPath.startsWith('/')) throw new Error('invalid container photo path')
+ const normalized = downloadPath.startsWith('/api/') ? downloadPath.slice(4) : downloadPath
+ if (!/^\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(normalized)) throw new Error('invalid container photo path')
+ return normalized
+}
+
export function downloadContainerPdaPhoto(downloadPath: string) {
- if (!/^\/api\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(downloadPath)) throw new Error('invalid container photo path')
- return http.get(downloadPath, { responseType: 'blob' })
+ return http.get(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' })
}
export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) {
diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts
index ea73c011..4d2be963 100644
--- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts
+++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts
@@ -1,8 +1,11 @@
-import { describe, expect, it } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
import type { ContainerOverview } from '../types'
+import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
+
+vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
@@ -15,8 +18,16 @@ const overview: ContainerOverview = {
}
describe('ContainerOverviewPanel', () => {
- it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => {
- const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo']))
+ Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') })
+ Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
+ })
+
+ it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ await flushPromises()
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
@@ -29,25 +40,72 @@ describe('ContainerOverviewPanel', () => {
})
it('emits stable document types and does not expose file ids in visible text', async () => {
- const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
await updateButton.trigger('click')
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
expect(wrapper.text()).not.toContain('9007199254740999')
})
- it('emits the backend-provided photo object from an accessible view control', async () => {
- const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
- await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click')
- expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' })
+ it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => {
+ const open = vi.spyOn(window, 'open').mockImplementation(() => null)
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download')
+ expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
+ await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click')
+ expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer')
+ wrapper.unmount()
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
})
- it('renders empty and forbidden/load failure states without exposing credentials', () => {
+ it('revokes old thumbnail URLs when the overview photos change', async () => {
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' }
+ await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } })
+ await flushPromises()
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
+ expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download')
+ wrapper.unmount()
+ })
+
+ it('isolates one thumbnail failure without blocking other photos', async () => {
+ const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' }
+ vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable'))
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ await flushPromises()
+ expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('照片加载失败')
+ wrapper.unmount()
+ })
+
+ it('maps PDA events to Chinese and hides long technical keys from the main timeline', () => {
+ const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76'
+ const timeline = [
+ { eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
+ { eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
+ { eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' },
+ ]
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ expect(wrapper.text()).toContain('领取 PDA 任务')
+ expect(wrapper.text()).toContain('提交拣货结果')
+ expect(wrapper.text()).toContain('完成装柜')
+ expect(wrapper.text()).not.toContain(longKey)
+ })
+
+ it('renders a compact empty photo state without exposing credentials', () => {
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
- const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } })
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('暂无可查看的有效照片')
- expect(wrapper.text()).toContain('当前账号无权访问该数据')
- expect(wrapper.text()).toContain('req-photo')
expect(wrapper.text()).not.toContain('Authorization')
})
+
+ it('keeps a completed customs document labelled as an attachment, not formal customs release', () => {
+ const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' }
+ const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
+ expect(wrapper.text()).toContain('国内出口报关附件')
+ expect(wrapper.text()).not.toContain('已完成报关')
+ expect(wrapper.text()).not.toContain('已放行')
+ })
})
diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue
index 93677067..b7a3aeb5 100644
--- a/src/modules/container-loading/components/ContainerOverviewPanel.vue
+++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue
@@ -1,43 +1,46 @@
@@ -64,15 +100,15 @@ function eventLabel(value: string) {
异常状态
{{ overview.exception.active ? '取消异常' : '标记异常' }}
货柜已标记异常{{ overview.exception.reason || '未填写异常原因' }}
{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}
-
+ 当前无货柜异常
业务资料
{{ overview.documents.length }} 份
-
+
- {{ item.label }}{{ documentsByType.get(item.type)?.fileName || '未上传' }}{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}
- 下载{{ documentsByType.get(item.type) ? '更新' : '上传' }}
+ {{ item.label }}{{ documentsByType.get(item.type)?.fileName || '未上传' }}{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '等待上传' }}
+ 下载{{ documentsByType.get(item.type) ? '更新' : '上传' }}
@@ -80,13 +116,13 @@ function eventLabel(value: string) {
- 任务明细{{ overview.pda.itemCount }}
已处理{{ overview.pda.completedItemCount }}
未解决{{ overview.pda.unresolvedItemCount }}
有效照片{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}
{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}
{{ evidenceTypeLabel(photo.evidenceType) }}{{ photo.fileName }}{{ formatTime(photo.uploadedAt) }}
查看
+ 任务明细{{ overview.pda.itemCount }}
已处理{{ overview.pda.completedItemCount }}
未解决{{ overview.pda.unresolvedItemCount }}
有效照片{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}
暂无可查看的有效照片
运输轨迹与操作日志
{{ overview.timeline.length }} 条
- {{ eventLabel(entry.eventType) }}{{ entry.detail || '无补充说明' }}
{{ entry.operatorName || '系统' }}
+ - {{ eventLabel(entry.eventType) }}{{ entry.operatorName || '系统' }}{{ eventSummary(entry) }}
@@ -98,23 +134,24 @@ function eventLabel(value: string) {
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 9172914b..59095641 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -1,40 +1,80 @@
-import { describe, expect, it } from 'vitest'
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
+import ElementPlus from 'element-plus'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ContainerDetailView from './ContainerDetailView.vue'
+import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
+import { uploadLoadingDeclaration } from '@/modules/loading-lists'
-const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
+const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() }))
+vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
+vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '' } }))
+vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
+vi.mock('../api/platformContainerLoading', () => ({
+ getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
+ uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
+ downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
+}))
+
+const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
+const overview = {
+ container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
+ loadingPlanId: '2092438843630391401', pda: null,
+ documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
+ exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
+}
+
+function mountView(customs = true) {
+ accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
+ return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
+}
+function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
describe('ContainerDetailView', () => {
- it('keeps identity, status and key facts in a compact overview', () => {
- expect(source).toContain('class="container-overview surface-panel"')
- expect(source).toContain('class="container-overview__header"')
- expect(source).toContain('class="container-overview__summary"')
- expect(source).toContain('grid-template-columns:repeat(5,minmax(0,1fr))')
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(getContainer).mockResolvedValue(detail)
+ vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
+ vi.mocked(getContainerClearance).mockResolvedValue(null)
+ vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
+ vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
+ vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
})
- it('keeps real document, exception and authorized photo operations', () => {
- expect(source).toContain('uploadContainerDocument(')
- expect(source).toContain('downloadContainerDocument(')
- expect(source).toContain('uploadContainerClearance(')
- expect(source).toContain('completeContainerClearance(')
- expect(source).toContain('markContainerException')
- expect(source).toContain('cancelContainerException')
- expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)')
+ it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
+ const wrapper = mountView(); await flushPromises()
+ await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
+ expect(wrapper.text()).toContain('国内出口报关附件')
+ const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
+ ;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
+ await (wrapper.vm as unknown as { saveDocument: () => Promise }).saveDocument()
+ expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
+ expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
+ wrapper.unmount()
})
- it('carries the existing read-only CarrierScheduleQuery by booking and container', () => {
- expect(source).toContain('CarrierScheduleQueryPanel')
- expect(source).toContain("from '@/modules/port-schedules'")
- expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"')
- expect(source).toContain(':container-booking-id="detail.id"')
+ it('shows a readable error when the container has no loading plan', async () => {
+ vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
+ const wrapper = mountView(); await flushPromises()
+ await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
+ expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
+ expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
})
- it('shows overseas unloading and empty-report facts without adding upload or PDA actions', () => {
- expect(source).toContain('detail.unloadingCompletedAt')
- expect(source).toContain('detail.unloadingPhotoUploadedAt')
- expect(source).toContain('detail.estimatedEmptyReportedAt')
- expect(source).toContain('detail.actualEmptyReportedAt')
- expect(source).not.toContain('uploadUnloadingPhoto')
- expect(source).not.toContain('submitEmptyReport')
+ it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
+ const wrapper = mountView(false); await flushPromises()
+ expect(documentArticle(wrapper, '国内出口报关附件').find('button').exists()).toBe(false)
+ expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
+ expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
+ expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
+ wrapper.unmount()
+ })
+
+ it('keeps destination clearance on its independent container clearance API', async () => {
+ const wrapper = mountView(); await flushPromises()
+ await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
+ expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
+ expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
+ wrapper.unmount()
})
})
diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue
index 16af6b11..74af5e49 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.vue
+++ b/src/modules/container-loading/pages/ContainerDetailView.vue
@@ -11,12 +11,12 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
+import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
completeContainerClearance,
downloadContainerDocument,
- downloadContainerPdaPhoto,
getContainer,
getContainerClearance,
getContainerOverview,
@@ -24,20 +24,22 @@ import {
uploadContainerClearance,
uploadContainerDocument,
} from '../api/platformContainerLoading'
-import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types'
+import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
+const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
const loading = ref(false)
const detail = ref(null)
const overview = ref(null)
const error = ref(null)
const overviewError = ref(null)
const documentOpen = ref(false)
-const documentType = ref<'DELIVERY_ORDER' | 'PICKUP_ORDER'>('DELIVERY_ORDER')
+type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
+const documentType = ref('DELIVERY_ORDER')
const documentFile = ref(null)
const documentSaving = ref(false)
const documentDownloading = ref(false)
@@ -52,11 +54,9 @@ const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref(null)
-const photoBusyId = ref(null)
-const photoError = ref(null)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
-const documentTitle = computed(() => documentType.value === 'DELIVERY_ORDER' ? 'DO 资料' : 'PU 资料')
+const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
@@ -74,18 +74,28 @@ async function load() {
}
function openDocument(type: string) {
- if (type === 'DESTINATION_CLEARANCE') { void openClearance(); return }
- if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) return
- documentType.value = type as 'DELIVERY_ORDER' | 'PICKUP_ORDER'
+ if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
+ if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
+ if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
+ documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentOpen.value = true
+ if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
+ documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
+ }
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
- if (!documentFile.value || !overview.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
+ if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null
try {
- await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`)
+ const expectedVersion = selectedDocument.value?.version || 0
+ if (documentType.value === 'CUSTOMS_DECLARATION') {
+ if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
+ await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
+ } else {
+ await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
+ }
overview.value = await getContainerOverview(containerId.value)
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) }
@@ -99,13 +109,6 @@ async function downloadDocument(item: ContainerOverviewDocument) {
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) }
finally { documentDownloading.value = false }
}
-async function previewPhoto(photo: ContainerPdaPhoto) {
- photoBusyId.value = photo.evidenceId; photoError.value = null
- try { const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
- catch (cause) { photoError.value = getApiErrorDetails(cause, '装柜照片加载失败') }
- finally { photoBusyId.value = null }
-}
-
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
async function saveException() {
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
@@ -128,10 +131,10 @@ async function openClearance() {
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
- if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择清关资料文件' }; return }
+ if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null
- try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('清关资料已上传') }
- catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关资料上传失败') }
+ try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('目的港清关资料已上传') }
+ catch (cause) { clearanceError.value = getApiErrorDetails(cause, '目的港清关资料上传失败') }
finally { clearanceSaving.value = false }
}
async function completeClearance() {
@@ -154,17 +157,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
运输与单证快照
- 真实订舱
- {{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}
- 异常状态
- {{ detail.exceptionActive ? '异常' : '正常' }}
- 目的仓
- {{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}
- 船公司
- {{ detail.shippingCompanyName || '—' }}
- 船名 / 航次
- {{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
- 拖车公司
- {{ detail.terminalTruckingCompany || '—' }}
- 预计开船
- {{ formatTime(detail.estimatedSailingAt) }}
- 预计到港
- {{ formatTime(detail.estimatedPortArrivalAt) }}
- 卸柜完成
- {{ formatTime(detail.unloadingCompletedAt) }}
- 卸柜照片上传
- {{ formatTime(detail.unloadingPhotoUploadedAt) }}
- 预计报空
- {{ formatTime(detail.estimatedEmptyReportedAt) }}
- 实际报空
- {{ formatTime(detail.actualEmptyReportedAt) }}
-
-
+
+
选择文件{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}
- 选择文件确认清关完成
+ 选择文件确认目的港清关完成
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
-
选择文件{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}
-
-
选择文件{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}
-
-
-
@@ -692,7 +577,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
From 8307510fc399e3cdbac51ace8d9d881396c6208d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 11:42:46 +0800
Subject: [PATCH 03/13] fix(admin): load picker warehouses from domestic master
---
src/modules/iam/api/platformIam.test.ts | 44 +++++++-----
src/modules/iam/api/platformIam.ts | 39 +++++++++--
.../iam/pages/InternalUsersView.test.ts | 68 +++++++++++++++++++
3 files changed, 127 insertions(+), 24 deletions(-)
diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts
index df42ce2f..ecaa74c0 100644
--- a/src/modules/iam/api/platformIam.test.ts
+++ b/src/modules/iam/api/platformIam.test.ts
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { overseasHttp } from '@/shared/api/overseasHttp'
import {
approveEnterpriseReview,
archivePlatformTenant,
@@ -51,10 +50,6 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: (response: { data: T }) => response.data,
}))
-vi.mock('@/shared/api/overseasHttp', () => ({
- overseasHttp: { get: vi.fn() },
-}))
-
const role: PlatformRoleResource = {
id: 4000002,
tenantId: 0,
@@ -123,7 +118,6 @@ describe('platform IAM roles', () => {
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.delete).mockReset()
- vi.mocked(overseasHttp.get).mockReset()
})
it('loads roles from the platform IAM prefix', async () => {
@@ -218,9 +212,13 @@ describe('platform IAM roles', () => {
})
it('maintains paginated platform users through the unified user contract', async () => {
- const userOptions = {
+ const userOptionsPayload = {
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
- roles: [{ id: '40001', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' }],
+ roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }],
+ }
+ const userOptions = {
+ ...userOptionsPayload,
+ roles: [{ ...userOptionsPayload.roles[0], roleCode: 'PLATFORM_OPERATOR' }],
}
const create = {
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
@@ -234,7 +232,11 @@ describe('platform IAM roles', () => {
vi.mocked(http.get).mockResolvedValueOnce({
code: '200', message: 'success', data: { ...userPage, page: '2', size: '10', total: '11', pages: '2' },
})
- .mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions })
+ .mockResolvedValueOnce({ code: '200', message: 'success', data: userOptionsPayload })
+ .mockResolvedValueOnce({
+ code: '200', message: 'success',
+ data: [{ ...role, id: '40001', roleCode: 'PLATFORM_OPERATOR' }],
+ })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
@@ -249,28 +251,36 @@ describe('platform IAM roles', () => {
params: { page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options')
+ expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/iam/roles')
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create)
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update)
expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/users/${managedUser.membershipId}/status`, { status: 'DISABLED' })
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
})
- it('loads only active shared receiving overseas warehouses through the overseas gateway', async () => {
- vi.mocked(overseasHttp.get).mockResolvedValue({
+ it('loads only active shared receiving overseas warehouses from domestic warehouse master data', async () => {
+ vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: {
records: [
- { id: '90071992547409931', customerId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
- { id: '2', customerId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
- { id: '3', customerId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' },
- ], page: 1, size: 200, total: 3, pages: 1,
+ { id: '90071992547409931', tenantId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
+ { id: '90071992547409932', tenantId: '0', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
+ { id: '2', tenantId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
+ { id: '3', tenantId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' },
+ { id: '4', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', countryCode: 'US', status: 'ACTIVE' },
+ { id: '5', tenantId: '0', warehouseCode: 'SEA-INACTIVE', warehouseName: '西雅图停用仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'INACTIVE' },
+ ], page: 1, size: 200, total: 6, pages: 1,
},
})
await expect(listSharedReceivingOverseasWarehouseOptions()).resolves.toEqual([
{ id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' },
+ { id: '90071992547409932', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', countryCode: 'US' },
])
- expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
- params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' },
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
+ params: {
+ tenantId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING',
+ warehouseType: 'OVERSEAS', status: 'ACTIVE',
+ },
})
})
diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts
index adb523ad..9689de43 100644
--- a/src/modules/iam/api/platformIam.ts
+++ b/src/modules/iam/api/platformIam.ts
@@ -1,5 +1,4 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import { overseasHttp } from '@/shared/api/overseasHttp'
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
export type { PlatformPermissionResource } from '@/shared/types/platformPermission'
@@ -277,6 +276,11 @@ export interface PlatformUserOptions {
roles: Array<{ id: number | string; tenantId: number | string; roleCode: string; name: string; status: string }>
}
+interface PlatformUserOptionsPayload {
+ tenants: PlatformUserOptions['tenants']
+ roles: Array>
+}
+
export interface SharedReceivingOverseasWarehouseOption {
id: string
warehouseCode: string
@@ -565,15 +569,27 @@ export async function listPlatformManagedUsers(query: PlatformManagedUserQuery)
}
}
-export function getPlatformUserOptions() {
- return getData('/platform/v1/iam/users/options')
+export async function getPlatformUserOptions(): Promise {
+ const [optionsResponse, rolesResponse] = await Promise.all([
+ http.get, ApiEnvelope>('/platform/v1/iam/users/options'),
+ http.get, ApiEnvelope>('/platform/v1/iam/roles'),
+ ])
+ const options = unwrapApiResponse(optionsResponse)
+ const roleCodes = new Map(unwrapApiResponse(rolesResponse).map((role) => [String(role.id), role.roleCode]))
+ return {
+ tenants: options.tenants,
+ roles: options.roles.map((role) => ({
+ ...role,
+ roleCode: roleCodes.get(String(role.id)) ?? '',
+ })),
+ }
}
export async function listSharedReceivingOverseasWarehouseOptions() {
interface WarehousePage {
records: Array<{
id: number | string
- customerId: number | string
+ tenantId: number | string
warehouseCode: string
warehouseName: string
warehouseType: string
@@ -581,12 +597,21 @@ export async function listSharedReceivingOverseasWarehouseOptions() {
status: string
}>
}
- const response = await overseasHttp.get, ApiEnvelope>(
+ const response = await http.get, ApiEnvelope>(
'/platform/v1/warehouse-admin',
- { params: { customerId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING', status: 'ACTIVE' } },
+ {
+ params: {
+ tenantId: '0',
+ page: 1,
+ size: 200,
+ capabilityCode: 'RECEIVING',
+ warehouseType: 'OVERSEAS',
+ status: 'ACTIVE',
+ },
+ },
)
return unwrapApiResponse(response).records
- .filter((warehouse) => String(warehouse.customerId) === '0'
+ .filter((warehouse) => String(warehouse.tenantId) === '0'
&& warehouse.warehouseType === 'OVERSEAS'
&& warehouse.status === 'ACTIVE')
.map((warehouse) => ({
diff --git a/src/modules/iam/pages/InternalUsersView.test.ts b/src/modules/iam/pages/InternalUsersView.test.ts
index c2f027ba..a7498766 100644
--- a/src/modules/iam/pages/InternalUsersView.test.ts
+++ b/src/modules/iam/pages/InternalUsersView.test.ts
@@ -220,6 +220,74 @@ describe('InternalUsersView platform internal user management', () => {
})
})
+ it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => {
+ vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
+ tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
+ roles: [
+ { id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
+ { id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
+ ],
+ })
+ const wrapper = await mountView()
+ const setupState = (wrapper.vm.$ as unknown as {
+ setupState: {
+ openCreate: () => void
+ form: { roleIds: string[]; overseasWarehouseId: string }
+ }
+ }).setupState
+ setupState.openCreate()
+ setupState.form.roleIds = ['4000005']
+ await flushPromises()
+ setupState.form.overseasWarehouseId = '90071992547409931'
+
+ setupState.form.roleIds = ['40']
+ await flushPromises()
+
+ expect(setupState.form.overseasWarehouseId).toBe('')
+ expect(document.body.textContent).not.toContain('实际海外收货仓')
+ })
+
+ it('submits the selected picker warehouse as an unchanged long-id string', async () => {
+ vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
+ tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
+ roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
+ })
+ const wrapper = await mountView()
+ const setupState = (wrapper.vm.$ as unknown as {
+ setupState: {
+ openCreate: () => void
+ saveUser: () => Promise
+ form: Record
+ }
+ }).setupState
+ setupState.openCreate()
+ Object.assign(setupState.form, {
+ username: 'overseas_picker_dev',
+ displayName: '海外仓操作员',
+ phone: '17512095155',
+ email: 'overseas_picker_dev@qq.com',
+ roleIds: ['4000005'],
+ overseasWarehouseId: '90071992547409931',
+ password: 'ChangeMe123!',
+ confirmPassword: 'ChangeMe123!',
+ status: 'ACTIVE',
+ })
+ await flushPromises()
+ await setupState.saveUser()
+
+ expect(createPlatformManagedUser).toHaveBeenCalledWith({
+ username: 'overseas_picker_dev',
+ displayName: '海外仓操作员',
+ phone: '17512095155',
+ email: 'overseas_picker_dev@qq.com',
+ tenantId: '0',
+ roleIds: ['4000005'],
+ overseasWarehouseId: '90071992547409931',
+ password: 'ChangeMe123!',
+ status: 'ACTIVE',
+ })
+ })
+
it('requests server-side pages and filters instead of slicing locally', async () => {
const wrapper = await mountView()
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
From e8c41bca2def65ada704bff51a8de4c7ad37db0e Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 12:04:50 +0800
Subject: [PATCH 04/13] feat(admin): confirm container departure from detail
---
.../pages/ContainerDetailView.test.ts | 85 +++++++++++++++-
.../pages/ContainerDetailView.vue | 70 ++++++++++++-
.../api/platformOutboundDispatch.test.ts | 97 ++++++++++++++++++-
.../api/platformOutboundDispatch.ts | 80 ++++++++++++++-
src/modules/outbound-dispatch/types.ts | 72 ++++++++++++++
5 files changed, 392 insertions(+), 12 deletions(-)
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 59095641..790c6a8f 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -1,15 +1,18 @@
-import ElementPlus from 'element-plus'
+import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
+import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
+import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
-const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() }))
+const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
-vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '' } }))
+vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '', mounted: () => { carrierMounts.value += 1 } } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
+vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
@@ -23,9 +26,20 @@ const overview = {
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
}
+const outboundDetail = {
+ aggregate: {
+ containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
+ loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
+ inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
+ gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
+ },
+ timeline: [],
+} as ContainerOutboundDispatchDetail
-function mountView(customs = true) {
- accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
+function mountView(customs = true, outbound = true) {
+ accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
+ || (customs && permission === 'platform:firstleg:customs:manage')
+ || (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
}
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
@@ -33,12 +47,15 @@ function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.fi
describe('ContainerDetailView', () => {
beforeEach(() => {
vi.clearAllMocks()
+ carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
+ vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
+ vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
})
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
@@ -77,4 +94,62 @@ describe('ContainerDetailView', () => {
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
})
+
+ it('loads and displays the whole-container gate without querying an inbound id', async () => {
+ const wrapper = mountView(); await flushPromises()
+ expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
+ expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
+ expect(wrapper.text()).toContain('国内出仓门禁')
+ expect(wrapper.text()).toContain('报关协同完成')
+ expect(wrapper.text()).toContain('IN-1')
+ expect(wrapper.text()).toContain('确认离仓')
+ wrapper.unmount()
+ })
+
+ it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
+ vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
+ const wrapper = mountView(); await flushPromises()
+ await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
+ expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
+ expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
+ expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
+ expect(getContainerOverview).toHaveBeenCalledTimes(2)
+ expect(carrierMounts.value).toBe(2)
+ expect(wrapper.text()).toContain('已确认离仓')
+ wrapper.unmount()
+ })
+
+ it('prevents a repeated confirmation wave while the first prompt is open', async () => {
+ vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
+ const wrapper = mountView(); await flushPromises()
+ const button = wrapper.find('.outbound-panel__actions button')
+ await button.trigger('click')
+ await button.trigger('click')
+ expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
+ expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('hides the outbound gate and never calls its API without view permission', async () => {
+ const wrapper = mountView(true, false); await flushPromises()
+ expect(wrapper.text()).not.toContain('国内出仓门禁')
+ expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
+ wrapper.unmount()
+ })
+
+ it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
+ vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
+ ...outboundDetail,
+ aggregate: {
+ ...outboundDetail.aggregate,
+ gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] },
+ },
+ } as never)
+ const wrapper = mountView(); await flushPromises()
+ expect(wrapper.text()).toContain('关联拖车已离仓')
+ expect(wrapper.text()).toContain('ARRIVED')
+ expect(wrapper.text()).toContain('DEPARTED或COMPLETED')
+ expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
+ wrapper.unmount()
+ })
})
diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue
index 74af5e49..796cda0d 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.vue
+++ b/src/modules/container-loading/pages/ContainerDetailView.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, FileUp } from '@lucide/vue'
-import { ElMessage, type UploadFile } from 'element-plus'
+import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -12,6 +12,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
+import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
+import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -32,6 +34,8 @@ const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
+const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
+const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const loading = ref(false)
const detail = ref(null)
const overview = ref(null)
@@ -54,11 +58,21 @@ const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref(null)
+const outboundLoading = ref(false)
+const outboundConfirming = ref(false)
+const outboundDetail = ref(null)
+const outboundError = ref(null)
+const carrierRefreshKey = ref(0)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
+const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) =>
+ (inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode }))))
+const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
+ && outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
+ && outboundDetail.value.aggregate.gate.passed)
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -69,10 +83,47 @@ async function load() {
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
+ if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
+async function loadOutboundDispatch() {
+ if (!containerId.value || !canViewOutboundDispatch.value) return
+ outboundLoading.value = true; outboundError.value = null
+ try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
+ catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
+ finally { outboundLoading.value = false }
+}
+
+async function confirmDeparture() {
+ const aggregate = outboundDetail.value?.aggregate
+ if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
+ outboundConfirming.value = true
+ try {
+ await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
+ } catch { outboundConfirming.value = false; return }
+ outboundError.value = null
+ try {
+ outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
+ expectedVersion: aggregate.version,
+ remark: '货柜详情确认国内出仓',
+ })
+ carrierRefreshKey.value += 1
+ ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
+ try { overview.value = await getContainerOverview(containerId.value) }
+ catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
+ } catch (cause) {
+ const details = getApiErrorDetails(cause, '确认国内出仓失败')
+ if (details.code === 'A0409') await loadOutboundDispatch()
+ outboundError.value = details
+ } finally { outboundConfirming.value = false }
+}
+
+function outboundStatusText(status?: string | null) {
+ return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—'
+}
+
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
@@ -159,7 +210,20 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
运输与单证快照
- 真实订舱
- {{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}
- 异常状态
- {{ detail.exceptionActive ? '异常' : '正常' }}
- 目的仓
- {{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}
- 船公司
- {{ detail.shippingCompanyName || '—' }}
- 船名 / 航次
- {{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
- 拖车公司
- {{ detail.terminalTruckingCompany || '—' }}
- 预计开船
- {{ formatTime(detail.estimatedSailingAt) }}
- 预计到港
- {{ formatTime(detail.estimatedPortArrivalAt) }}
- 卸柜完成
- {{ formatTime(detail.unloadingCompletedAt) }}
- 卸柜照片上传
- {{ formatTime(detail.unloadingPhotoUploadedAt) }}
- 预计报空
- {{ formatTime(detail.estimatedEmptyReportedAt) }}
- 实际报空
- {{ formatTime(detail.actualEmptyReportedAt) }}
-
+
+
+
+
+
+ 以下任一阻断项处理完成后,重新加载门禁即可确认离仓。
+
+
+ {{ row.passed ? '通过' : '阻断' }}{{ row.name }}{{ row.inboundCode }}{{ row.actual || '暂无事实' }}要求:{{ row.requirement || '—' }}
+
+
+
+
+
选择文件{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}
@@ -169,5 +233,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
index 2bfdc8fe..d0ede23c 100644
--- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
+++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.test.ts
@@ -1,5 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
-vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
-describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
+import {
+ changeOutboundDispatch,
+ checkContainerOutboundGate,
+ confirmContainerOutboundDispatch,
+ getContainerOutboundDispatch,
+ listOutboundDispatches,
+} from './platformOutboundDispatch'
+
+vi.mock('@/shared/api/http', async () => {
+ const actual = await vi.importActual('@/shared/api/http')
+ return { ...actual, http: { get: vi.fn(), post: vi.fn() } }
+})
+
+const gate = {
+ passed: true,
+ checkedAt: '2026-08-26T12:00:00',
+ inbounds: [{
+ inboundId: '2092438843630391501',
+ inboundCode: 'IN-1',
+ gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [] },
+ }],
+}
+const aggregate = {
+ containerBookingId: '2092438843630391297',
+ cabinetNo: 'CAB-1',
+ containerNo: 'C11111111',
+ loadingPlanId: '2092438843630391401',
+ loadingPlanStatus: 'COMPLETED',
+ oceanBookingId: '2092438843630391601',
+ truckAppointmentId: null,
+ dispatchStatus: 'READY_FOR_DISPATCH',
+ version: '4',
+ inbounds: [{
+ orderId: '2092438843630391701',
+ orderNo: 'O1',
+ inboundId: '2092438843630391501',
+ inboundCode: 'IN-1',
+ tenantId: '2092438843630391801',
+ customerId: '2092438843630391901',
+ inboundStatus: 'LOADED',
+ pickTaskId: null,
+ }],
+ gate,
+}
+
+describe('platformOutboundDispatch', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('normalizes aggregate ids', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] })
+ const result = await listOutboundDispatches({})
+ expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 })
+ })
+
+ it('posts legacy action with version and idempotency', async () => {
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } })
+ await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 })
+ expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 })
+ expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
+ })
+
+ it('gets exact container-level paths and normalizes all ids', async () => {
+ vi.mocked(http.get)
+ .mockResolvedValueOnce({ code: '200', data: { aggregate, timeline: [{ module: 'OUTBOUND', action: 'READY', operatorId: 9, occurredAt: '2026-08-26T12:00:00' }] } })
+ .mockResolvedValueOnce({ code: '200', data: gate })
+
+ const detail = await getContainerOutboundDispatch('2092438843630391297')
+ const resultGate = await checkContainerOutboundGate('2092438843630391297')
+
+ expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297')
+ expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/gate')
+ expect(detail.aggregate).toMatchObject({
+ containerBookingId: String(aggregate.containerBookingId),
+ loadingPlanId: String(aggregate.loadingPlanId),
+ oceanBookingId: String(aggregate.oceanBookingId),
+ version: 4,
+ inbounds: [{ orderId: String(aggregate.inbounds[0].orderId), inboundId: String(aggregate.inbounds[0].inboundId), tenantId: String(aggregate.inbounds[0].tenantId), customerId: String(aggregate.inbounds[0].customerId) }],
+ })
+ expect(detail.timeline[0]?.operatorId).toBe('9')
+ expect(resultGate.inbounds[0]?.inboundId).toBe(String(gate.inbounds[0].inboundId))
+ })
+
+ it('confirms once at container level without inboundId and includes idempotency', async () => {
+ vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate, timeline: [] } })
+ await confirmContainerOutboundDispatch('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
+
+ expect(http.post).toHaveBeenCalledWith(
+ '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/confirm',
+ { expectedVersion: 4, remark: '货柜详情确认国内出仓' },
+ { headers: { 'Idempotency-Key': expect.any(String) } },
+ )
+ expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
+ })
+})
diff --git a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
index b8719a68..8506de19 100644
--- a/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
+++ b/src/modules/outbound-dispatch/api/platformOutboundDispatch.ts
@@ -1,5 +1,16 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
-import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
+import type {
+ ContainerOutboundActionRequest,
+ ContainerOutboundAggregate,
+ ContainerOutboundDispatchDetail,
+ ContainerOutboundGateResult,
+ ContainerOutboundInboundBinding,
+ ContainerOutboundInboundGate,
+ OutboundDispatchDetail,
+ OutboundDispatchQuery,
+ OutboundDispatchResource,
+ OutboundGateResult,
+} from '../types'
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
@@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get, ApiEnvelope>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get, ApiEnvelope>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post, ApiEnvelope>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
+
+function optionalId(value: unknown): string | null {
+ return value == null ? null : String(value)
+}
+
+function inboundBinding(raw: ContainerOutboundInboundBinding): ContainerOutboundInboundBinding {
+ return {
+ ...raw,
+ orderId: String(raw.orderId),
+ inboundId: String(raw.inboundId),
+ tenantId: String(raw.tenantId),
+ customerId: optionalId(raw.customerId),
+ pickTaskId: optionalId(raw.pickTaskId),
+ }
+}
+
+function inboundGate(raw: ContainerOutboundInboundGate): ContainerOutboundInboundGate {
+ return { ...raw, inboundId: String(raw.inboundId), gate: { ...raw.gate, items: raw.gate?.items || [] } }
+}
+
+function containerGate(raw: ContainerOutboundGateResult): ContainerOutboundGateResult {
+ return { ...raw, inbounds: (raw.inbounds || []).map(inboundGate) }
+}
+
+function containerAggregate(raw: ContainerOutboundAggregate): ContainerOutboundAggregate {
+ return {
+ ...raw,
+ containerBookingId: String(raw.containerBookingId),
+ loadingPlanId: String(raw.loadingPlanId),
+ oceanBookingId: optionalId(raw.oceanBookingId),
+ truckAppointmentId: optionalId(raw.truckAppointmentId),
+ version: Number(raw.version ?? 0),
+ inbounds: (raw.inbounds || []).map(inboundBinding),
+ gate: containerGate(raw.gate),
+ }
+}
+
+function containerDetail(raw: ContainerOutboundDispatchDetail): ContainerOutboundDispatchDetail {
+ return {
+ ...raw,
+ aggregate: containerAggregate(raw.aggregate),
+ timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
+ }
+}
+
+export async function getContainerOutboundDispatch(containerBookingId: string) {
+ const response = await http.get, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}`,
+ )
+ return containerDetail(unwrapApiResponse(response))
+}
+
+export async function checkContainerOutboundGate(containerBookingId: string) {
+ const response = await http.get, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}/gate`,
+ )
+ return containerGate(unwrapApiResponse(response))
+}
+
+export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) {
+ const response = await http.post, ApiEnvelope>(
+ `${basePath}/${encodeURIComponent(containerBookingId)}/confirm`,
+ request,
+ { headers: { 'Idempotency-Key': idem() } },
+ )
+ return containerDetail(unwrapApiResponse(response))
+}
diff --git a/src/modules/outbound-dispatch/types.ts b/src/modules/outbound-dispatch/types.ts
index ff2de734..e38c1775 100644
--- a/src/modules/outbound-dispatch/types.ts
+++ b/src/modules/outbound-dispatch/types.ts
@@ -3,3 +3,75 @@ export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
+
+export interface ContainerOutboundGateItem {
+ code: string
+ name: string
+ passed: boolean
+ actual?: string | null
+ requirement?: string | null
+}
+
+export interface ContainerOutboundInboundGate {
+ inboundId: string
+ inboundCode: string
+ gate: {
+ passed: boolean
+ items: ContainerOutboundGateItem[]
+ checkedAt: string
+ }
+}
+
+export interface ContainerOutboundGateResult {
+ passed: boolean
+ inbounds: ContainerOutboundInboundGate[]
+ checkedAt: string
+}
+
+export interface ContainerOutboundInboundBinding {
+ orderId: string
+ orderNo: string
+ inboundId: string
+ inboundCode: string
+ tenantId: string
+ customerId?: string | null
+ customerName?: string | null
+ inboundStatus: string
+ pickTaskId?: string | null
+ pickTaskStatus?: string | null
+}
+
+export interface ContainerOutboundAggregate {
+ containerBookingId: string
+ cabinetNo: string
+ containerNo?: string | null
+ loadingPlanId: string
+ loadingPlanStatus: string
+ oceanBookingId?: string | null
+ oceanBookingStatus?: string | null
+ truckAppointmentId?: string | null
+ truckAppointmentStatus?: string | null
+ dispatchStatus: string
+ version: number
+ inbounds: ContainerOutboundInboundBinding[]
+ gate: ContainerOutboundGateResult
+}
+
+export interface ContainerOutboundDispatchDetail {
+ aggregate: ContainerOutboundAggregate
+ timeline: Array<{
+ module: string
+ action: string
+ fromStatus?: string | null
+ toStatus?: string | null
+ operatorId?: string | null
+ operatorName?: string | null
+ remark?: string | null
+ occurredAt: string
+ }>
+}
+
+export interface ContainerOutboundActionRequest {
+ expectedVersion: number
+ remark?: string
+}
From 4088e36f7432c3f2786b3d354ad31d64ce75e2de Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 12:36:01 +0800
Subject: [PATCH 05/13] fix(admin): compact container departure gates
---
.../pages/ContainerDetailView.test.ts | 104 ++++++++++++++++--
.../pages/ContainerDetailView.vue | 46 ++++++--
2 files changed, 133 insertions(+), 17 deletions(-)
diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts
index 790c6a8f..6289c2dd 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.test.ts
+++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts
@@ -29,7 +29,7 @@ const overview = {
const outboundDetail = {
aggregate: {
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
- loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
+ loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
},
@@ -100,8 +100,14 @@ describe('ContainerDetailView', () => {
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('国内出仓门禁')
- expect(wrapper.text()).toContain('报关协同完成')
- expect(wrapper.text()).toContain('IN-1')
+ expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
+ expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
+ expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
+ expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
+ await wrapper.find('.gate-summary button').trigger('click')
+ expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
+ expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
+ expect(wrapper.find('.gate-list').text()).toContain('IN-1')
expect(wrapper.text()).toContain('确认离仓')
wrapper.unmount()
})
@@ -142,14 +148,98 @@ describe('ContainerDetailView', () => {
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
- gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] },
+ gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' }] } }] },
},
} as never)
const wrapper = mountView(); await flushPromises()
- expect(wrapper.text()).toContain('关联拖车已离仓')
- expect(wrapper.text()).toContain('ARRIVED')
- expect(wrapper.text()).toContain('DEPARTED或COMPLETED')
+ expect(wrapper.text()).toContain('船期有效且未过期')
+ expect(wrapper.text()).toContain('EXPIRED')
+ expect(wrapper.text()).toContain('VALID')
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
wrapper.unmount()
})
+
+ it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
+ const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
+ const passed = { code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: true, actual: 'VALID', requirement: 'VALID' }
+ vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
+ ...outboundDetail,
+ aggregate: {
+ ...outboundDetail.aggregate,
+ gate: {
+ passed: false,
+ checkedAt: '2026-08-26T12:00:00',
+ inbounds: [
+ { inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
+ { inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
+ ],
+ },
+ },
+ } as never)
+ const wrapper = mountView(); await flushPromises()
+ expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
+ expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
+ expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
+ expect(wrapper.findAll('.gate-row')).toHaveLength(1)
+ expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
+ expect(wrapper.find('.gate-row').text()).toContain('IN-101')
+ expect(wrapper.find('.gate-row').text()).toContain('IN-102')
+ await wrapper.find('.gate-summary button').trigger('click')
+ expect(wrapper.findAll('.gate-row')).toHaveLength(2)
+ expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
+ wrapper.unmount()
+ })
+
+ it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
+ vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
+ ...outboundDetail,
+ aggregate: {
+ ...outboundDetail.aggregate,
+ gate: {
+ passed: false,
+ checkedAt: '2026-08-26T12:00:00',
+ inbounds: [
+ { inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
+ { inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
+ { inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
+ ],
+ },
+ },
+ } as never)
+ const wrapper = mountView(); await flushPromises()
+ expect(wrapper.findAll('.gate-row')).toHaveLength(2)
+ expect(wrapper.find('.gate-list').text()).toContain('MISSING')
+ expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
+ expect(wrapper.find('.gate-list').text()).toContain('PENDING')
+ expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
+ await wrapper.find('.gate-summary button').trigger('click')
+ expect(wrapper.findAll('.gate-row')).toHaveLength(3)
+ expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
+ wrapper.unmount()
+ })
+
+ it('keeps truck status as reference only and never counts the legacy truck gate as blocking', async () => {
+ vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
+ ...outboundDetail,
+ aggregate: {
+ ...outboundDetail.aggregate,
+ truckAppointmentStatus: 'ARRIVED',
+ gate: {
+ passed: false,
+ checkedAt: '2026-08-26T12:00:00',
+ inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
+ { code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
+ { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
+ ] } }],
+ },
+ },
+ } as never)
+ const wrapper = mountView(); await flushPromises()
+ expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
+ expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
+ expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
+ expect(wrapper.text()).not.toContain('关联拖车已离仓')
+ expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
+ wrapper.unmount()
+ })
})
diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue
index 796cda0d..58f81bf7 100644
--- a/src/modules/container-loading/pages/ContainerDetailView.vue
+++ b/src/modules/container-loading/pages/ContainerDetailView.vue
@@ -13,7 +13,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
-import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
+import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -62,17 +62,37 @@ const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref(null)
const outboundError = ref(null)
+const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
-const outboundGateRows = computed(() => (outboundDetail.value?.aggregate.gate.inbounds || []).flatMap((inbound) =>
- (inbound.gate.items || []).map((item) => ({ ...item, inboundId: inbound.inboundId, inboundCode: inbound.inboundCode }))))
+type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
+const outboundGateRows = computed(() => {
+ const grouped = new Map()
+ for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
+ for (const item of inbound.gate.items || []) {
+ if (item.code === 'TRUCK_DEPARTED') continue
+ const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
+ const existing = grouped.get(key)
+ if (existing) {
+ if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
+ } else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
+ }
+ }
+ return [...grouped.values()]
+})
+const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
+const outboundGateSummary = computed(() => ({
+ total: outboundGateRows.value.length,
+ passed: outboundGateRows.value.filter((row) => row.passed).length,
+ blocked: outboundGateRows.value.filter((row) => !row.passed).length,
+}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
- && outboundDetail.value.aggregate.gate.passed)
+ && outboundGateSummary.value.blocked === 0)
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -90,7 +110,7 @@ async function load() {
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
- outboundLoading.value = true; outboundError.value = null
+ outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
@@ -123,6 +143,9 @@ async function confirmDeparture() {
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record)[status || ''] || status || '—'
}
+function truckReferenceText(status?: string | null) {
+ return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[status || ''] || status || '未关联'
+}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
@@ -211,14 +234,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
-
+
- 以下任一阻断项处理完成后,重新加载门禁即可确认离仓。
+ 总门禁 {{ outboundGateSummary.total }}已通过 {{ outboundGateSummary.passed }}阻断 {{ outboundGateSummary.blocked }}拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}
+ 默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。
+ 全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。
-
- {{ row.passed ? '通过' : '阻断' }}{{ row.name }}{{ row.inboundCode }}{{ row.actual || '暂无事实' }}要求:{{ row.requirement || '—' }}
+
+ {{ row.passed ? '通过' : '阻断' }}{{ row.name }}{{ row.actual || '暂无事实' }}要求:{{ row.requirement || '—' }}
+ {{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码{{ code }}+{{ row.inboundCodes.length - 3 }}
@@ -233,5 +259,5 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
From 4a7a5ae5b66f7cfabe0d4b743d2982996604bc4a Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 12:46:46 +0800
Subject: [PATCH 06/13] feat(admin): complete customs from container detail
---
.../components/ContainerOverviewPanel.vue | 14 ++-
.../pages/ContainerDetailView.test.ts | 86 +++++++++++++++++--
.../pages/ContainerDetailView.vue | 36 +++++++-
3 files changed, 122 insertions(+), 14 deletions(-)
diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue
index b7a3aeb5..7a3d7f47 100644
--- a/src/modules/container-loading/components/ContainerOverviewPanel.vue
+++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue
@@ -1,6 +1,6 @@
-
-
+
+
-
- 尚无关联任务任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。
+
-
-
+
+
-
- - 查询任务
- {{ query.queryNo || query.id }}
- 跟踪标识
- {{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}
- - 任务操作状态
- {{ statusText }}
- 建立来源
- {{ triggerSourceText(query.triggerSource) }}
- - 最新实际节点
- {{ nodeText(query.latestActualNode) }}
- 实际发生时间
- {{ formatTime(query.latestActualAt) }}
- - 节点位置
- {{ nodeLocation(latestActualNodeFact) }}
- 观测时间
- {{ formatTime(latestObservation) }}
- - 船司 / 访问方式
- {{ query.carrierCode }} · {{ query.accessMode || '—' }}
- 最新来源
- {{ latestSource }}
- - 来源参考
- {{ sourceReference }}
- 来源更新时间
- {{ formatTime(query.sourceUpdatedAt) }}
- - 最近成功
- {{ formatTime(query.lastSuccessAt) }}
- 结果有效性
- {{ queryView?.expired ? '结果已过期' : '结果有效' }}
- - 结果过期时间
- {{ formatTime(query.expiresAt) }}
- 停止时间
- {{ formatTime(query.stoppedAt) }}
- - 最近错误码
- {{ query.lastErrorCode }}
-
{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}
-
-
标准节点历史
暂无标准节点
- {{ nodeTimeText(item) }}{{ nodeText(item.standardNode) }}{{ nodeLocation(item) }} · {{ item.providerCode }} · {{ item.sourceReference }} · 预计 {{ formatTime(item.estimatedAt) }}
-
异常事实
暂无异常事实
- {{ formatTime(item.observedAt) }}{{ exceptionText(item.exceptionCode) }} · {{ item.active ? '发生' : '已解除' }}{{ exceptionLocation(item) }} · {{ item.providerCode }} · {{ item.providerStatus }} · {{ item.sourceReference }}
-
变更历史
暂无变更历史
- {{ formatTime(item.createdAt) }}{{ historyText(item.action) }}{{ item.operatorName || '系统' }} · {{ item.reason }}
-
+ - {{ item.type }}{{ item.title }}{{ item.summary }}{{ item.source }}
+ 暂无货柜履约记录
+ 确认离仓后自动开始船节点跟踪
+ 查询任务摘要、异常与变更历史
- 查询任务
- {{ query.queryNo || query.id }}
- 跟踪标识
- {{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}
- 任务状态
- {{ statusText }}
- 建立来源
- {{ triggerSourceText(query.triggerSource) }}
- 最新实际节点
- {{ nodeText(query.latestActualNode) }}
- 实际发生时间
- {{ formatTime(query.latestActualAt) }}
- 最新来源
- {{ latestSource }}
- 来源参考
- {{ sourceReference }}
- 观测时间
- {{ formatTime(latestObservation) }}
- 最近成功
- {{ formatTime(query.lastSuccessAt) }}
- 结果有效性
- {{ queryView?.expired ? '结果已过期' : '结果有效' }}
- 最近错误码
- {{ query.lastErrorCode }}
任务变更历史
暂无变更历史
- {{ formatTime(item.createdAt) }}{{ historyText(item.action) }}{{ item.operatorName || '系统' }} · {{ item.reason }}
@@ -157,5 +180,5 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
From 627b9602d1d873346a32b1d392d1669b9e82e067 Mon Sep 17 00:00:00 2001
From: wujinliang <2318309077@qq.com>
Date: Wed, 26 Aug 2026 14:33:13 +0800
Subject: [PATCH 11/13] 1
---
.../pages/FinanceCustomerStatementsView.vue | 14 +++++++-------
.../finance/pages/FinancePresentation.test.ts | 8 +++++++-
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
index 00d6d5ec..4f68a5e8 100644
--- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue
+++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
@@ -99,18 +99,18 @@ const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
const fields = computed(() => [
- { key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [
+ { key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '请选择审核状态', options: [
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
{ label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
- { key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
+ { key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
- { key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
- { key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
- { key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
- { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
- { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
+ { key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
+ { key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
+ { key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '请选择结清状态', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
+ { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
+ { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts
index f3440c04..dc607ff4 100644
--- a/src/modules/finance/pages/FinancePresentation.test.ts
+++ b/src/modules/finance/pages/FinancePresentation.test.ts
@@ -67,13 +67,19 @@ describe('finance presentation governance', () => {
})
it('uses optional all-customer filters without a customer-selection blocker', () => {
- for (const source of [settlement, statements]) {
+ for (const source of [settlement]) {
expect(source).toContain("placeholder: '全部客户'")
expect(source).not.toContain('全客户查询暂不可用')
expect(source).not.toContain('empty-text="请选择客户查询')
}
expect(arap).toContain("placeholder: '请选择客户'")
expect(arap).not.toContain("placeholder: '全部客户'")
+ expect(statements).toContain("placeholder: '请选择客户'")
+ expect(statements).toContain("placeholder: '请选择审核状态'")
+ expect(statements).toContain("placeholder: '请选择结清状态'")
+ expect(statements).toContain("startPlaceholder: '请选择开始日期'")
+ expect(statements).toContain("endPlaceholder: '请选择结束日期'")
+ expect(statements).not.toContain("placeholder: '全部客户'")
})
it('uses active-customer name selectors across all formal finance pages', () => {
From 2563ad7be3a71c18cb424fdf0dd35c49fcae61f9 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 14:48:48 +0800
Subject: [PATCH 12/13] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/modules/suppliers/api/platformSuppliers.ts | 1 +
src/modules/suppliers/pages/SupplierDetailView.vue | 4 ++--
src/modules/suppliers/pages/SupplierFormView.vue | 13 +++++++++----
.../pages/SupplierRouteReuse.behavior.test.ts | 2 +-
src/modules/suppliers/pages/SuppliersView.test.ts | 10 ++++++++++
src/modules/suppliers/pages/SuppliersView.vue | 4 +++-
src/modules/suppliers/types.ts | 8 ++++++++
7 files changed, 34 insertions(+), 8 deletions(-)
diff --git a/src/modules/suppliers/api/platformSuppliers.ts b/src/modules/suppliers/api/platformSuppliers.ts
index 3661e8d9..ec361b31 100644
--- a/src/modules/suppliers/api/platformSuppliers.ts
+++ b/src/modules/suppliers/api/platformSuppliers.ts
@@ -9,6 +9,7 @@ function normalizeSupplier(raw: Supplier): Supplier {
return {
...raw,
id: String(raw.id), tenantId: String(raw.tenantId),
+ carrierCode: raw.carrierCode ?? null,
licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy),
version: Number(raw.version), deleted: Boolean(raw.deleted),
}
diff --git a/src/modules/suppliers/pages/SupplierDetailView.vue b/src/modules/suppliers/pages/SupplierDetailView.vue
index ef026031..f486e2d4 100644
--- a/src/modules/suppliers/pages/SupplierDetailView.vue
+++ b/src/modules/suppliers/pages/SupplierDetailView.vue
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
-import { categoryLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
+import { categoryLabels, formatCarrierCode, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:supplier:list'))
@@ -19,7 +19,7 @@ const supplierId = computed(() => typeof route.params.id === 'string' ? route.pa
const supplier = ref(null); const loading = ref(false); const error = ref(null); const licenseOpen = ref(false)
let loadSequence = 0
const items = computed(() => supplier.value ? [
- ['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['地区', supplier.value.region],
+ ['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['船司标准编码', formatCarrierCode(supplier.value.carrierCode)], ['地区', supplier.value.region],
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
] : [])
diff --git a/src/modules/suppliers/pages/SupplierFormView.vue b/src/modules/suppliers/pages/SupplierFormView.vue
index 8a4e0c9f..55aab772 100644
--- a/src/modules/suppliers/pages/SupplierFormView.vue
+++ b/src/modules/suppliers/pages/SupplierFormView.vue
@@ -13,7 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
-import { categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types'
+import { carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types'
const route = useRoute()
const router = useRouter()
@@ -26,10 +26,11 @@ const loading = ref(false)
const saving = ref(false)
const loadError = ref(null)
const submitError = ref(null)
-const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
+const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', carrierCode: null, region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
const form = reactive(initial())
const rules: FormRules = {
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
+ carrierCode: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && !value ? callback(new Error('请选择船司标准编码')) : callback(), trigger: 'change' }],
region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], status: [{ required: true, message: '请选择合作状态', trigger: 'change' }],
@@ -39,7 +40,9 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor',
function optional(value: string | null) { return value?.trim() || null }
function payload(): SaveSupplierRequest {
- return { ...form, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
+ const { carrierCode, ...fields } = form
+ const normalized = { ...fields, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
+ return form.category === 'BOOKING' ? { ...normalized, carrierCode } : normalized
}
let loadSequence = 0
function resetForm() { Object.assign(form, initial()) }
@@ -55,7 +58,7 @@ async function load() {
try {
const data = await getSupplier(requestedId)
if (sequence !== loadSequence || requestedId !== supplierId.value) return
- Object.assign(form, { name: data.name, category: data.category, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
+ Object.assign(form, { name: data.name, category: data.category, carrierCode: data.category === 'BOOKING' ? data.carrierCode : null, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
captureBaseline()
} catch (error) {
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
@@ -74,6 +77,7 @@ async function save() {
}
watch(supplierId, () => { void load() }, { immediate: true })
+watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCode = null })
@@ -89,6 +93,7 @@ watch(supplierId, () => { void load() }, { immediate: true })
+
diff --git a/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts b/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts
index 1fc424c7..2612b678 100644
--- a/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts
+++ b/src/modules/suppliers/pages/SupplierRouteReuse.behavior.test.ts
@@ -21,7 +21,7 @@ vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWor
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() }))
function supplier(id: string, name: string): Supplier {
- return { id, tenantId: '0', name, category: 'BOOKING', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
+ return { id, tenantId: '0', name, category: 'BOOKING', carrierCode: 'MATSON', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
}
function deferred
() {
diff --git a/src/modules/suppliers/pages/SuppliersView.test.ts b/src/modules/suppliers/pages/SuppliersView.test.ts
index 01599581..750ede91 100644
--- a/src/modules/suppliers/pages/SuppliersView.test.ts
+++ b/src/modules/suppliers/pages/SuppliersView.test.ts
@@ -48,6 +48,16 @@ describe('supplier formal pages', () => {
expect(detailSource).toContain(' {
+ expect(formSource).toContain('v-if="form.category === \'BOOKING\'"')
+ expect(formSource).toContain('v-for="option in carrierCodeOptions"')
+ expect(formSource).not.toContain('v-model="form.carrierCode" maxlength')
+ expect(formSource).toContain("if (category !== 'BOOKING') form.carrierCode = null")
+ expect(formSource).toContain("return form.category === 'BOOKING' ? { ...normalized, carrierCode } : normalized")
+ expect(source).toContain("label: '船司标准编码'")
+ expect(detailSource).toContain("['船司标准编码', formatCarrierCode(supplier.value.carrierCode)]")
+ })
+
it('reloads detail data when the reused route id changes and protects the newer request', () => {
expect(detailSource).toContain("watch(supplierId, () => { void load() }, { immediate: true })")
expect(detailSource).toContain('const requestedId = supplierId.value')
diff --git a/src/modules/suppliers/pages/SuppliersView.vue b/src/modules/suppliers/pages/SuppliersView.vue
index 8a40a7b8..5f3672c8 100644
--- a/src/modules/suppliers/pages/SuppliersView.vue
+++ b/src/modules/suppliers/pages/SuppliersView.vue
@@ -14,7 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
-import { categoryLabels, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
+import { categoryLabels, formatCarrierCode, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
const router = useRouter()
const access = useAccessPreviewStore()
@@ -45,6 +45,7 @@ const columns = computed(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
+ { key: 'carrierCode', label: '船司标准编码', minWidth: 160, slotName: 'carrierCode' },
{ key: 'region', label: '地区', minWidth: 110 },
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
{ key: 'contactName', label: '联系人', minWidth: 110 },
@@ -148,6 +149,7 @@ onMounted(load)
{{ row.name }}
{{ categoryLabels[row.category as SupplierCategory] }}
+ {{ formatCarrierCode(supplierRow(row).carrierCode) }}
查看
{{ settlementLabels[supplierRow(row).settlementPeriod] }}
diff --git a/src/modules/suppliers/types.ts b/src/modules/suppliers/types.ts
index 807d4923..68198f16 100644
--- a/src/modules/suppliers/types.ts
+++ b/src/modules/suppliers/types.ts
@@ -1,12 +1,14 @@
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
+export type CarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
export interface Supplier {
id: string
tenantId: string
name: string
category: SupplierCategory
+ carrierCode?: CarrierCode | null
region: string
legalRepresentative: string
contactName: string
@@ -48,6 +50,7 @@ export interface SupplierQuery {
export interface SaveSupplierRequest {
name: string
category: SupplierCategory
+ carrierCode?: CarrierCode | null
region: string
legalRepresentative: string
contactName: string
@@ -62,6 +65,11 @@ export interface SaveSupplierRequest {
}
export const categoryLabels: Record = { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' }
+export const carrierCodeLabels: Record = {
+ MATSON: '美森', SML: 'SML', ZIM: '以星', COSCO: '中远', EMC: '长荣', HEDE: '合德', OTHER: '其他',
+}
+export const carrierCodeOptions = (Object.entries(carrierCodeLabels) as [CarrierCode, string][]).map(([value, name]) => ({ value, label: `${name} (${value})` }))
+export function formatCarrierCode(code: CarrierCode | null | undefined) { return code ? `${carrierCodeLabels[code]} (${code})` : '—' }
export const statusLabels: Record = { ACTIVE: '合作中', INACTIVE: '停用' }
export const settlementLabels: Record = {
MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结',
From b907881740deb82ac530ad3a676fec6ad07eb1cb Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 15:41:08 +0800
Subject: [PATCH 13/13] fix(admin): prevent duplicate actual carrier nodes
---
...CarrierScheduleQueryPanel.behavior.test.ts | 41 +++++++++++++++++++
.../CarrierScheduleQueryPanel.test.ts | 8 ++++
.../components/CarrierScheduleQueryPanel.vue | 35 ++++++++++++++--
3 files changed, 81 insertions(+), 3 deletions(-)
diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts
index e9970523..1865e974 100644
--- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts
+++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.behavior.test.ts
@@ -132,6 +132,47 @@ describe('CarrierScheduleQueryPanel loading states', () => {
expect(wrapper.text()).toContain('人工补录标准节点')
})
+ it('shows only the latest duplicate actual node in the primary trajectory and keeps the older fact expandable', async () => {
+ const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null }
+ const older = { id: 'pickup-1', providerCode: 'MANUAL', providerEventId: 'pickup-old', providerNodeCode: 'PICKED_UP', standardNode: 'PICKED_UP' as const, nodeSequence: 90, observedAt: '2026-09-09T10:05:00', estimatedAt: null, actualAt: '2026-09-09T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles', sourceReference: '人工提柜-旧', rawPayloadHash: '1'.repeat(64), createdAt: '2026-09-09T10:06:00' }
+ const latest = { ...older, id: 'pickup-2', providerEventId: 'pickup-latest', observedAt: '2026-09-24T10:05:00', actualAt: '2026-09-24T10:00:00', sourceReference: '人工提柜-最新', rawPayloadHash: '2'.repeat(64), createdAt: '2026-09-24T10:06:00' }
+ vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
+ vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [older, latest], exceptions: [], histories: [] })
+ const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
+ await flushPromises()
+
+ const primaryPickupRows = wrapper.findAll('.fulfillment-timeline li').filter((row) => row.text().includes('已提柜'))
+ expect(primaryPickupRows).toHaveLength(1)
+ expect(primaryPickupRows[0]!.text()).toContain('2026-09-24 10:00')
+ expect(primaryPickupRows[0]!.text()).not.toContain('2026-09-09 10:00')
+ expect(wrapper.find('.duplicate-node-history').text()).toContain('历史重复实际节点(1)')
+ expect(wrapper.find('.duplicate-node-history').text()).toContain('2026-09-09 10:00')
+ expect(wrapper.find('.duplicate-node-history').text()).toContain('人工提柜-旧')
+ })
+
+ it('disables same and earlier actual nodes and rejects an invalid actual submission defensively', async () => {
+ const task = { ...queryView.query, latestActualNode: 'PICKED_UP' as const, latestActualAt: '2026-09-24T10:00:00', resultPortScheduleId: null }
+ vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: task, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
+ vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: task, expired: false }, nodes: [], exceptions: [], histories: [] })
+ const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus, pinia] } })
+ await flushPromises()
+
+ await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
+ const pickers = wrapper.findAllComponents({ name: 'ElDatePicker' })
+ pickers[1]!.vm.$emit('update:modelValue', '2026-09-25T10:00:00')
+ await flushPromises()
+ const options = wrapper.findAllComponents({ name: 'ElOption' })
+ expect(options.find((option) => option.props('value') === 'DEPARTED_WAREHOUSE')!.props('disabled')).toBe(true)
+ expect(options.find((option) => option.props('value') === 'PICKED_UP')!.props('disabled')).toBe(true)
+ expect(options.find((option) => option.props('value') === 'EMPTY_RETURNED')!.props('disabled')).toBe(false)
+
+ wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'PICKED_UP')
+ wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '重复提柜事实')
+ await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
+ expect(wrapper.text()).toContain('实际补录只能选择严格晚于当前实际进度的节点')
+ expect(appendCarrierScheduleManualNode).not.toHaveBeenCalled()
+ })
+
it('hides the manual action without the frozen permission while retaining fulfillment facts', async () => {
useAuthStore().permissions = []
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts
index e7594136..80c88f84 100644
--- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts
+++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.test.ts
@@ -57,6 +57,14 @@ describe('CarrierScheduleQueryPanel', () => {
expect(source).toContain('item.actualAt')
})
+ it('blocks repeated actual progress and folds older immutable facts out of the primary trajectory', () => {
+ expect(source).toContain('isManualActualNodeAllowed')
+ expect(source).toContain(':disabled="Boolean(manualForm.actualAt) && !isManualActualNodeAllowed(node)"')
+ expect(source).toContain('primaryNodeFacts')
+ expect(source).toContain('supersededActualNodes')
+ expect(source).toContain('历史重复实际节点')
+ })
+
it('uses the frozen manual-node permission and keeps exceptions separate', () => {
expect(source).toContain("access.can('platform:firstleg:port-schedule-query:manual')")
expect(source).toContain('appendCarrierScheduleManualNode')
diff --git a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
index 130a6803..4e61f504 100644
--- a/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
+++ b/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
@@ -52,6 +52,30 @@ const sourceReference = computed(() => latestActualNodeFact.value?.sourceReferen
const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null)
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
const standardNodes: CarrierScheduleStandardNode[] = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']
+const standardNodeSequence = new Map(standardNodes.map((node, index) => [node, (index + 1) * 10]))
+const latestActualNodeSequence = computed(() => query.value?.latestActualNode ? standardNodeSequence.get(query.value.latestActualNode) || 0 : 0)
+
+function compareActualFacts(left: CarrierScheduleNodeFact, right: CarrierScheduleNodeFact) {
+ for (const field of ['actualAt', 'observedAt', 'createdAt'] as const) {
+ const compared = String(left[field] || '').localeCompare(String(right[field] || ''))
+ if (compared) return compared
+ }
+ return left.id.localeCompare(right.id)
+}
+const latestActualFactsByNode = computed(() => {
+ const result = new Map()
+ for (const item of nodes.value) {
+ if (!item.actualAt) continue
+ const current = result.get(item.standardNode)
+ if (!current || compareActualFacts(item, current) > 0) result.set(item.standardNode, item)
+ }
+ return result
+})
+const supersededActualNodes = computed(() => nodes.value
+ .filter((item) => item.actualAt && latestActualFactsByNode.value.get(item.standardNode)?.id !== item.id)
+ .sort((left, right) => compareActualFacts(right, left)))
+const primaryNodeFacts = computed(() => nodes.value.filter((item) => !item.actualAt || latestActualFactsByNode.value.get(item.standardNode)?.id === item.id))
+function isManualActualNodeAllowed(node: CarrierScheduleStandardNode) { return (standardNodeSequence.get(node) || 0) > latestActualNodeSequence.value }
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record)[value] || value }
@@ -75,7 +99,7 @@ const fulfillmentEvents = computed(() => {
values.push({ key: `warehouse-${item.eventType}-${item.occurredAt}-${index}`, time: item.occurredAt, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(item.eventType), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true })
}
const nodeKeys = new Set()
- for (const item of nodes.value) {
+ for (const item of primaryNodeFacts.value) {
const actual = Boolean(item.actualAt)
const time = item.actualAt || item.estimatedAt || item.observedAt
const key = JSON.stringify([item.standardNode, item.actualAt || null, item.estimatedAt || null, item.locationCode || null, item.locationName || null, item.providerCode, item.sourceReference || null])
@@ -111,6 +135,7 @@ async function submitManualNode() {
manualValidationError.value = ''
if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return }
+ if (manualForm.actualAt && !isManualActualNodeAllowed(manualForm.standardNode)) { manualValidationError.value = '实际补录只能选择严格晚于当前实际进度的节点'; return }
if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return }
const base = { expectedVersion: query.value.version, standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() }
const request: CarrierScheduleManualNodeRequest = manualForm.actualAt
@@ -147,6 +172,9 @@ async function load() {
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } })
+watch(() => manualForm.actualAt, (actualAt) => {
+ if (actualAt && manualForm.standardNode && !isManualActualNodeAllowed(manualForm.standardNode)) manualForm.standardNode = ''
+})
@@ -163,14 +191,15 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
- {{ item.type }}{{ item.title }}{{ item.summary }}{{ item.source }}
暂无货柜履约记录
确认离仓后自动开始船节点跟踪
- 查询任务摘要、异常与变更历史
- 查询任务
- {{ query.queryNo || query.id }}
- 跟踪标识
- {{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}
- 任务状态
- {{ statusText }}
- 建立来源
- {{ triggerSourceText(query.triggerSource) }}
- 最新实际节点
- {{ nodeText(query.latestActualNode) }}
- 实际发生时间
- {{ formatTime(query.latestActualAt) }}
- 最新来源
- {{ latestSource }}
- 来源参考
- {{ sourceReference }}
- 观测时间
- {{ formatTime(latestObservation) }}
- 最近成功
- {{ formatTime(query.lastSuccessAt) }}
- 结果有效性
- {{ queryView?.expired ? '结果已过期' : '结果有效' }}
- 最近错误码
- {{ query.lastErrorCode }}
任务变更历史
暂无变更历史
- {{ formatTime(item.createdAt) }}{{ historyText(item.action) }}{{ item.operatorName || '系统' }} · {{ item.reason }}
+ 历史重复实际节点({{ supersededActualNodes.length }})
- {{ nodeTimeText(item) }}{{ nodeText(item.standardNode) }}{{ nodeLocation(item) }} · {{ item.providerCode }} · {{ item.sourceReference }}
+ 查询任务摘要、异常与变更历史
- 查询任务
- {{ query.queryNo || query.id }}
- 跟踪标识
- {{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}
- 任务状态
- {{ statusText }}
- 建立来源
- {{ triggerSourceText(query.triggerSource) }}
- 最新实际节点
- {{ nodeText(query.latestActualNode) }}
- 实际发生时间
- {{ formatTime(query.latestActualAt) }}
- 最新来源
- {{ latestSource }}
- 来源参考
- {{ sourceReference }}
- 观测时间
- {{ formatTime(latestObservation) }}
- 最近成功
- {{ formatTime(query.lastSuccessAt) }}
- 结果有效性
- {{ queryView?.expired ? '结果已过期' : '结果有效' }}
- 最近错误码
- {{ query.lastErrorCode }}
任务变更历史
暂无变更历史
- {{ formatTime(item.createdAt) }}{{ historyText(item.action) }}{{ item.operatorName || '系统' }} · {{ item.reason }}
-
+