Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev

This commit is contained in:
2026-09-01 10:18:48 +08:00
221 changed files with 6764 additions and 5701 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
})
+1 -1
View File
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
expect(wrapper.text()).not.toContain('我的消息')
expect(wrapper.text()).toContain('我的消息')
const notificationTrigger = wrapper.get('.notification-trigger')
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
+4 -2
View File
@@ -37,7 +37,8 @@ const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:task:list')
access.can('platform:notification:view')
|| access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
@@ -177,6 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
</el-dropdown-menu></template>
@@ -193,7 +195,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<component
:is="Component"
v-if="renderedRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
+1
View File
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
it('registers message-center routes with the frozen IAM permissions', () => {
const expected = [
['notification-inbox', '/notifications', 'platform:notification:view'],
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
]
+29 -1
View File
@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -89,6 +89,8 @@ describe('admin auth session', () => {
mocks.refreshHandler = null
})
afterEach(() => vi.unstubAllGlobals())
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
@@ -203,6 +205,32 @@ describe('admin auth session', () => {
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
})
it('reuses a session rotated by another tab without refreshing it again', async () => {
const previousToken = token({ token_version: 1 })
const rotatedToken = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previousToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
localStorage.setItem('cc-admin-token', rotatedToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
return callback()
}),
},
})
const auth = useAuthStore()
await expect(auth.refreshSession()).resolves.toBeUndefined()
expect(mocks.post).not.toHaveBeenCalled()
expect(auth.token).toBe(rotatedToken)
expect(auth.refreshToken).toBe('refresh-rotated')
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
})
it('does not let an in-flight refresh restore a session after logout', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
+3 -8
View File
@@ -203,14 +203,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
observedRefreshToken,
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
reuseCurrentSession: async () => {
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: storedRefreshToken },
)
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
applyStoredSession()
ensureCurrentGeneration(expectedGeneration)
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
},
refresh: async (currentRefreshToken) => {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
+22 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
afterEach(() => vi.unstubAllGlobals())
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
const reuseCurrentSession = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
currentRefreshToken = 'refresh-new'
return callback()
}),
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
it('performs the refresh when the token has not changed', async () => {
const refresh = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
})
await expect(coordinateCrossTabRefresh({
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
expect(refresh).toHaveBeenCalledWith('refresh-old')
})
it('aborts a refresh lock that cannot be acquired', async () => {
vi.stubGlobal('navigator', {
locks: {
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
})),
},
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: vi.fn(),
refresh: vi.fn(),
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
})
})
+20 -1
View File
@@ -1,11 +1,21 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
throw error
}
}
return navigator.locks.request(options.lockName, run)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
+3 -2
View File
@@ -32,7 +32,9 @@ describe('adminNavigation', () => {
'订单列表',
'订单测量',
'测量数据审核',
'预装清单',
'库位管理',
'托盘管理',
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
@@ -45,7 +47,6 @@ describe('adminNavigation', () => {
'订舱列表',
'拖车列表',
'货柜列表',
'预装清单',
'装柜清单',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
@@ -249,8 +250,8 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/loading-plans',
'/container-loading',
'/loading-lists',
])
})
+2 -3
View File
@@ -1,7 +1,6 @@
import type { Component } from 'vue'
import {
Building2,
ClipboardCheck,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -68,7 +67,9 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
],
},
@@ -102,7 +103,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
],
},
@@ -129,7 +129,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'overseas-acceptance', title: '尾程流程验收', icon: ClipboardCheck, path: '/prototype/overseas-delivery/acceptance-workbench' },
],
},
{
+12 -4
View File
@@ -9,8 +9,10 @@ const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
describe('admin nginx deployment config', () => {
it('allows qualification uploads with multipart overhead through the API proxy', () => {
expect(config).toContain('client_max_body_size 12m;')
it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => {
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(2)
expect(config).not.toContain('client_max_body_size 12m;')
expect(config).not.toContain('client_max_body_size 0;')
})
it('prevents the application shell from retaining stale chunk references', () => {
@@ -36,8 +38,13 @@ describe('admin nginx deployment config', () => {
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"')
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
expect(config).toContain('proxy_set_header Host $proxy_host;')
})
it('runs each deployment on its target node without controller-local SSH files', () => {
@@ -58,5 +65,6 @@ describe('admin nginx deployment config', () => {
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
})
})
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
startedAt: null, completedAt: null,
}
describe('platform container customs API', () => {
beforeEach(() => vi.restoreAllMocks())
it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs })
const result = await getContainerCustoms('2092438843630391402')
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402')
expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' })
expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' })
expect(result.documents[0]?.documentId).toBe('2092438843630391407')
})
it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
await startContainerCustoms('2092438843630391402', 3)
await completeAllBuyContainerCustoms('2092438843630391402', 4)
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/start', { expectedVersion: 3, confirm: true, remark: null }])
expect(post.mock.calls[1]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/complete-all-buy', { expectedVersion: 4, confirm: true, remark: null }])
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('uploads one completion file with final-confirmation query parameters', async () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
const completion = new File(['done'], 'done.pdf')
await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
it('keeps source and completion downloads isolated on the long document id', async () => {
const source = new Blob(['source'])
const completion = new Blob(['completion'])
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(source).mockResolvedValueOnce(completion)
const documentId = '2092438843630391407'
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
])
})
})
@@ -0,0 +1,116 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
export interface ContainerCustomsFile {
fileId: string
fileName: string
fileHash: string
sizeBytes: number
version: number
uploadedAt: string
}
export interface ContainerCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: ContainerCustomsTaxMode
covered: boolean
}
export interface ContainerCustomsDocument {
documentId: string
tenantId: string
source: ContainerCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: ContainerCustomsFile | null
}
export interface ContainerCustomsView {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo: string
cabinetNo: string
containerNo: string | null
status: ContainerCustomsStatus
version: number
taxRefundCount: number
coveredTaxRefundCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
startedAt: string | null
completedAt: string | null
}
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
aggregateId: string | number
loadingPlanId: string | number
containerBookingId: string | number
version: string | number
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
}
const basePath = '/platform/v1/customs/loading-plans'
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null {
return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null
}
function normalize(value: RawContainerCustomsView): ContainerCustomsView {
return {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
}
}
export async function getContainerCustoms(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
return normalize(unwrapApiResponse(response))
}
export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsCompletion(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
{ headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } },
)
return normalize(unwrapApiResponse(response))
}
export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'start', expectedVersion)
}
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
const body = new FormData()
body.append('file', fileValue)
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
body,
{ params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
)
const result = unwrapApiResponse(response)
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
}
@@ -1,13 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary,
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
updateContainer, updateContainerTransport,
updateContainer,
} from './platformContainerLoading'
vi.mock('@/shared/api/http', () => ({
@@ -42,14 +44,12 @@ describe('platform container loading API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
})
it('uses full-edit and transport whitelist request shapes', async () => {
it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport')
})
it('uses the frozen simulation create and ocean booking link contracts', async () => {
@@ -66,9 +66,9 @@ describe('platform container loading API', () => {
it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309' }] })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] })
expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309 }])
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }])
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } })
})
@@ -90,6 +90,21 @@ describe('platform container loading API', () => {
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
})
it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await assignSmartLoadingGroup('10', '2093000000000000006', 5)
await removeSmartLoadingGroup('10', '2093000000000000001', 6)
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments',
{ inboundId: '2093000000000000006', expectedVersion: 5, operationId: expect.stringContaining('assign-coload-group-') },
)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments/2093000000000000001',
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-coload-group-') } },
)
})
it('uses stable container id and versioned clearance document actions', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
@@ -112,10 +127,10 @@ describe('platform container loading API', () => {
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: 12, eventType: 'PUSH_PRELOAD', detail: '推送预装清单' }],
} })
const result = await getContainerOverview('7')
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] })
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: '12' }] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
@@ -60,7 +60,13 @@ function overview(raw: ContainerOverview): ContainerOverview {
documents: (raw.documents || []).map(overviewDocument),
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
timeline: (raw.timeline || []).map((entry) => ({
...entry,
id: optionalId(entry.id),
eventId: optionalId(entry.eventId),
idempotencyKey: optionalId(entry.idempotencyKey),
operatorId: optionalId(entry.operatorId),
})),
}
}
@@ -196,11 +202,6 @@ export async function updateContainer(containerId: string, version: number, data
return container(unwrapApiResponse(response))
}
export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) {
const response = await http.patch<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version })
return container(unwrapApiResponse(response))
}
export async function getContainerClearance(containerId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
return document(unwrapApiResponse(response))
@@ -229,6 +230,10 @@ export async function listSmartLoadingCandidates(bookingId: string, warehouseId:
return unwrapApiResponse(response).map((raw) => ({
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
coLoadGroupId: optionalId(raw.coLoadGroupId),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
locked: raw.locked == null ? null : Boolean(raw.locked),
availablePackageCount: numberValue(raw.availablePackageCount),
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
@@ -257,6 +262,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`,
{ inboundId, expectedVersion, operationId: operationId('assign-coload-group') },
)
return plan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
return plan(unwrapApiResponse(response))
@@ -272,6 +285,14 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp
return plan(unwrapApiResponse(response))
}
export async function removeSmartLoadingGroup(planId: string, groupId: string, expectedPlanVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`,
{ data: { expectedPlanVersion, operationId: operationId('remove-coload-group') } },
)
return plan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
return plan(unwrapApiResponse(response))
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms'
const api = vi.hoisted(() => ({
get: vi.fn(),
start: vi.fn(),
completeAllBuy: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
downloadCompletion: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
getContainerCustoms: api.get,
startContainerCustoms: api.start,
completeAllBuyContainerCustoms: api.completeAllBuy,
uploadContainerCustomsCompletion: api.upload,
downloadContainerCustomsDocument: api.downloadSource,
downloadContainerCustomsCompletion: api.downloadCompletion,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
}],
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
}
}
async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) {
api.get.mockResolvedValue(customs(status, hasCompletion))
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
return wrapper
}
function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
return wrapper.findAll('button').map((button) => button.text())
}
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.downloadSource.mockResolvedValue(new Blob(['source']))
api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.find('[aria-label="下载源资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('上传完成文件')
expect(wrapper.text()).toContain('提交')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖完成文件')
expect(wrapper.text()).toContain('报关完成文件.pdf')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关完成文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('routes source and completion downloads independently by the long document id and original names', async () => {
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadCompletion).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载完成文件 报关完成文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
})
it('preserves backend error code, request id and trace id when one download fails', async () => {
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载源资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('文件不可用')
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
expect(wrapper.text()).toContain('request-customs-download')
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadCompletion).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { createPlatformExportTask } from '@/modules/downloads'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
downloadContainerCustomsCompletion,
downloadContainerCustomsDocument,
getContainerCustoms,
startContainerCustoms,
uploadContainerCustomsCompletion,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
} from '../api/platformContainerCustoms'
const props = defineProps<{
loadingPlanId: string
canList: boolean
canManage: boolean
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES'
}>()
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
const completionFiles = reactive<Record<string, File | null>>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.taxRefundCount === 0)
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function selectCompletion(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
completionFiles[documentId] = upload.raw || null
actionError.value = null
}
async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
catch (error) { actionError.value = getApiErrorDetails(error, '开始整柜报关失败') }
finally { actionLoading.value = false }
}
async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应完成文件'); return }
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
if (finalConfirmation) {
try {
await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
} catch { return }
}
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
completionFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') }
finally { actionLoading.value = false }
}
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 downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') {
const target = kind === 'source' ? document.source : document.completion
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
actionError.value = null
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
}
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
catch { return }
actionLoading.value = true; actionError.value = null
try {
customs.value = await completeAllBuyContainerCustoms(props.loadingPlanId, customs.value.version)
emit('refreshed')
ElMessage.success('全买单柜报关已完成')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成全买单柜报关失败') }
finally { actionLoading.value = false }
}
async function exportAllBuy() {
actionLoading.value = true; actionError.value = null
try {
const task = await createPlatformExportTask({
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || '柜级明细'}`,
parameters: { loadingPlanId: String(props.loadingPlanId) },
})
ElMessage.success(`买单报关导出任务已创建:${task.taskNo}`)
} catch (error) { actionError.value = getApiErrorDetails(error, '买单报关导出任务创建失败') }
finally { actionLoading.value = false }
}
watch(() => props.loadingPlanId, () => { void load() })
onMounted(load)
</script>
<template>
<section class="container-customs surface-panel" aria-label="整柜报关">
<OperationErrorToast :details="actionError" />
<header class="customs-header">
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ statusText[customs.status] }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
<el-button v-if="canManage && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
<div v-if="actionError" class="action-error" role="alert"><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></div>
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
<template v-else-if="customs">
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺资料清单</h3>
<table class="compact-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>冻结资料与完成文件</h3>
<table class="compact-table document-table"><thead><tr><th>操作</th><th>源资料</th><th>完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖完成文件' : '上传完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载源资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>资料 v{{ document.source.version }}<span v-if="document.completion"> / 完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传完成文件' : '待上传' }}</td>
</tr>
</tbody></table>
</div>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
</section>
</template>
<style scoped>
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:230px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
</style>
@@ -47,11 +47,11 @@ describe('ContainerOverviewPanel', () => {
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('renders four compact document table rows with the operation column first', () => {
it('renders three compact non-customs document rows with the operation column first', () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4)
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
expect(wrapper.findAll('.document-item')).toHaveLength(0)
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
@@ -158,11 +158,4 @@ describe('ContainerOverviewPanel', () => {
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('已放行')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { Check, Download, FileUp, TriangleAlert } from '@lucide/vue'
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
@@ -10,36 +10,31 @@ const props = defineProps<{
overview: ContainerOverview
canManageDocuments: boolean
canManageCustoms: boolean
canDownloadCustoms?: boolean
canManageException: boolean
documentBusy?: boolean
customsCompleting?: boolean
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
completeCustoms: [document: ContainerOverviewDocument]
manageException: []
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'CUSTOMS_DECLARATION' && props.canDownloadCustoms)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
}
function canUploadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
}
function hasDocumentAction(documentType: string) {
const document = documentsByType.value.get(documentType)
@@ -47,9 +42,7 @@ function hasDocumentAction(documentType: string) {
}
function documentStatusText(documentType: string) {
const document = documentsByType.value.get(documentType)
if (documentType !== 'CUSTOMS_DECLARATION') return document ? formatTime(document.uploadedAt) : '等待上传'
if (!document) return '第一步上传附件,第二步确认报关完成'
return document.completed ? '已确认报关完成' : '附件已上传,待确认报关完成'
return document ? formatTime(document.uploadedAt) : '等待上传'
}
function documentStatusLabel(documentType: string) {
const document = documentsByType.value.get(documentType)
@@ -66,7 +59,10 @@ const pdaProgress = computed(() => {
if (!pda || pda.itemCount <= 0) return 0
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
})
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
const visibleSections = computed(() => new Set(props.sections || ['exception', 'documents', 'pda']))
const activePhotos = computed(() => visibleSections.value.has('pda')
? (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath)
: [])
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
let photoGeneration = 0
const workflowStatusLabels: Record<string, string> = {
@@ -118,19 +114,19 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<template>
<div class="overview-panel">
<section class="overview-section exception-management" aria-label="异常管理">
<section v-if="visibleSections.has('exception')" class="overview-section exception-management" aria-label="异常管理">
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
</section>
<section class="overview-section" aria-label="货柜业务资料">
<section v-if="visibleSections.has('documents')" class="overview-section" aria-label="货柜业务资料">
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
<div class="document-table-wrap">
<table class="document-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><el-button v-if="item.type === 'CUSTOMS_DECLARATION' && canManageCustoms && documentsByType.get(item.type) && !documentsByType.get(item.type)?.completed" size="small" type="primary" :loading="customsCompleting" @click="emit('completeCustoms', documentsByType.get(item.type)!)"><Check :size="15" />确认报关完成</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong><small v-if="item.type === 'CUSTOMS_DECLARATION'">{{ documentStatusText(item.type) }}</small></td>
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
@@ -140,7 +136,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</div>
</section>
<section class="overview-section" aria-label="PDA作业摘要">
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
@@ -151,7 +147,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</template>
<style scoped>
.overview-panel { display: grid; gap: 12px; }.overview-section:nth-child(3){order:1}.overview-section:nth-child(2){order:2}.overview-section:nth-child(1){order:3}
.overview-panel { display: grid; gap: 12px; }
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.overview-section h3 { margin: 0; font-size: 15px; }
@@ -1,427 +1,62 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
import { ApiResponseError } from '@/shared/api/http'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
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: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: vi.fn() }))
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
downloadContainerClearance: vi.fn(),
}))
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
const overview = {
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
loadingPlanId: '2092438843630391401', pda: null,
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
}
const outboundDetail = {
aggregate: {
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
},
timeline: [],
} as ContainerOutboundDispatchDetail
function mountView(customs = true, outbound = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|| (customs && permission === 'platform:firstleg:customs:manage')
|| permission === 'platform:firstleg:loading-list:list'
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
}
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! }
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
describe('ContainerDetailView', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance']))
vi.mocked(getLoadingDeclaration).mockResolvedValue(null)
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
describe('ContainerDetailView customs integration', () => {
it('renders the requested detail sections in the confirmed business order', () => {
const headings = [
'container-information-heading">货柜信息',
'container-voyage-heading">船名航次信息',
'container-customs-heading">报关清关信息',
'container-receiving-heading">签收信息',
'container-fees-heading">费用明细',
]
let previous = -1
for (const heading of headings) {
const current = source.indexOf(heading)
expect(current, heading).toBeGreaterThan(previous)
previous = current
}
})
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('国内出口报关附件')
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
wrapper.unmount()
it('reuses the current schedule, customs, receipt and container-fee capabilities', () => {
expect(source).toContain('<CarrierScheduleQueryPanel')
expect(source).toContain(':sections="[\'documents\']"')
expect(source).toContain(':sections="[\'pda\']"')
expect(source).toContain('<ContainerFeesPanel')
expect(source).toContain('getConfirmedLoadingList')
expect(source).toContain('getPreloadPlan')
})
it('shows a readable error when the container has no loading plan', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
expect(source).toContain(':loading-plan-id="overview.loadingPlanId"')
expect(source).toContain(':can-manage="canManageCustoms"')
})
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
const wrapper = mountView(false); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
wrapper.unmount()
it('allows the overview panel to render only the section owned by each detail group', () => {
expect(overviewSource).toContain("sections?: Array<'exception' | 'documents' | 'pda'>")
expect(overviewSource).toContain("visibleSections.has('exception')")
expect(overviewSource).toContain("visibleSections.has('documents')")
expect(overviewSource).toContain("visibleSections.has('pda')")
})
it('keeps destination clearance on its independent container clearance API', async () => {
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
it('removes the retired loading-list customs UI and calls', () => {
for (const retired of ['getLoadingDeclaration', 'uploadLoadingDeclaration', 'completeLoadingDeclaration', 'downloadLoadingDeclaration', 'CUSTOMS_DECLARATION']) {
expect(source).not.toContain(retired)
expect(overviewSource).not.toContain(retired)
}
})
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(click).toHaveBeenCalledOnce()
expect(URL.revokeObjectURL).toHaveBeenCalled()
wrapper.unmount()
})
it('keeps customs download after completion while hiding update and completion actions', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(row.text()).toContain('已完成')
wrapper.unmount()
})
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.text()).toContain('customs-new.pdf')
expect(row.text()).toContain('操作员')
expect(row.text()).toContain('2026-08-26 12:00')
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
wrapper.unmount()
})
it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => {
const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 }
const withoutCustoms = { ...overview, documents: [] }
vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never)
vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never)
vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises()
const file = new File(['next'], 'next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
expect(uploadLoadingDeclaration).toHaveBeenCalledOnce()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件')
expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-upload-409')
wrapper.unmount()
})
it('uses the independent destination document version and immediately replaces the table row after update', async () => {
const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料')
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
wrapper.unmount()
})
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '目的港清关资料')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises()
expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502')
expect(downloadLoadingDeclaration).not.toHaveBeenCalled()
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(click).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => {
const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never)
vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledOnce()
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-clearance-409')
wrapper.unmount()
})
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
const customs = documentArticle(wrapper, '国内出口报关附件')
expect(customs.text()).toContain('附件已上传,待确认报关完成')
expect(customs.text()).toContain('更新附件')
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledOnce()
expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows customs completion only for an existing unfinished attachment with manage permission', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never)
let wrapper = mountView(); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
wrapper = mountView(); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
wrapper = mountView(false); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows a readable completion error without a loading plan and does not call the API', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成')
expect(completeLoadingDeclaration).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
wrapper.unmount()
})
it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => {
let releasePrompt!: () => void
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise<void>((resolve) => { releasePrompt = resolve }) as never)
const wrapper = mountView(); await flushPromises()
const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成')
await button.trigger('click'); await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
releasePrompt(); await flushPromises()
vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409'))
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never)
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-customs-409')
expect(getContainerOverview).toHaveBeenCalledTimes(3)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('loads and displays the whole-container gate without querying an inbound id', async () => {
const wrapper = mountView(); await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('国内出仓门禁')
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
expect(wrapper.find('.gate-list').text()).toContain('IN-1')
expect(wrapper.text()).toContain('确认离仓')
wrapper.unmount()
})
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(carrierMounts.value).toBe(2)
expect(wrapper.text()).toContain('已确认离仓')
wrapper.unmount()
})
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
const wrapper = mountView(); await flushPromises()
const button = wrapper.find('.outbound-panel__actions button')
await button.trigger('click')
await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('hides the outbound gate and never calls its API without view permission', async () => {
const wrapper = mountView(true, false); await flushPromises()
expect(wrapper.text()).not.toContain('国内出仓门禁')
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] },
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.text()).toContain('报关协同完成')
expect(wrapper.text()).toContain('MISSING')
expect(wrapper.text()).toContain('COMPLETED')
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
wrapper.unmount()
})
it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
{ inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
expect(wrapper.findAll('.gate-row')).toHaveLength(1)
expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
expect(wrapper.find('.gate-row').text()).toContain('IN-101')
expect(wrapper.find('.gate-row').text()).toContain('IN-102')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
wrapper.unmount()
})
it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
{ inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
{ inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.find('.gate-list').text()).toContain('MISSING')
expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
expect(wrapper.find('.gate-list').text()).toContain('PENDING')
expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(3)
expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
wrapper.unmount()
})
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
truckAppointmentStatus: 'ARRIVED',
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' },
{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
] } }],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
expect(wrapper.text()).not.toContain('关联拖车已离仓')
expect(wrapper.text()).not.toContain('船期有效且未过期')
expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
wrapper.unmount()
it('offers the released-booking link workflow only for an eligible unlinked simulation container', () => {
expect(source).toContain("detail.value?.sourceType === 'SIMULATION'")
expect(source).toContain("!detail.value.oceanBookingId")
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
expect(source).toContain('关联已放舱订舱')
expect(source).toContain("query: { linkContainerId: detail.value.id }")
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, FileUp } from '@lucide/vue'
import { ArrowLeft, FileUp, Link2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,9 +11,14 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, type LoadingDetail } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -35,23 +40,29 @@ const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewContainerFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
&& detail.value?.sourceType === 'SIMULATION'
&& !detail.value.oceanBookingId
&& ['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)))
const loading = ref(false)
const detail = ref<ContainerBookingResource | null>(null)
const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentUploadKey = ref(0)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const customsCompleting = ref(false)
const customsActionError = ref<ApiErrorDetails | null>(null)
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
@@ -69,9 +80,11 @@ const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const loadingDetail = ref<LoadingDetail | null>(null)
const loadingDetailError = ref<ApiErrorDetails | null>(null)
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 documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
@@ -99,6 +112,24 @@ const outboundGateSummary = computed(() => ({
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
orderId: item.orderId,
orderNo: item.orderNo,
inboundId: item.inboundId,
inboundCode: item.inboundCode,
})))
function openOceanBookingLink() {
if (!detail.value || !canLinkOceanBooking.value) return
void router.push({ name: 'container-loading', query: { linkContainerId: detail.value.id } })
}
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
return {
@@ -112,16 +143,14 @@ function replaceOverviewDocument(document: ContainerOverviewDocument) {
if (!overview.value) return
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
}
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
let refreshCause: unknown
let refreshed: ContainerOverview | null = null
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
if (!current) {
try {
const business = documentType === 'CUSTOMS_DECLARATION'
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
: await getContainerClearance(containerId.value)
const business = await getContainerClearance(containerId.value)
if (business) {
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
@@ -145,6 +174,7 @@ async function load() {
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
await loadLoadingDetail(resource, aggregate)
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
@@ -158,6 +188,26 @@ async function loadOutboundDispatch() {
finally { outboundLoading.value = false }
}
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
loadingDetail.value = null; loadingDetailError.value = null
if (!canViewContainerFees.value || !aggregate.loadingPlanId || !resource.warehouseId) return
try {
if (resource.planStatus === 'COMPLETED') {
if (!canViewLoadingList.value) return
loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)
return
}
if (!canViewPreload.value) return
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
} catch (cause) {
loadingDetailError.value = getApiErrorDetails(cause, '费用归属信息加载失败')
}
}
async function refreshAfterCustoms() {
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
@@ -191,13 +241,9 @@ function truckReferenceText(status?: string | null) {
function openDocument(type: string) {
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
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !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: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
}
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
@@ -206,84 +252,41 @@ async function saveDocument() {
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
const expectedVersion = selectedDocument.value?.version || 0
if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
replaceOverviewDocument(optimistic)
try {
const refreshed = await getContainerOverview(containerId.value)
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
}
const targetType = documentType.value
const targetTitle = documentTitle.value
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
replaceOverviewDocument(optimistic)
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
ElMessage.success(`${targetTitle}已上传`)
try {
const refreshed = await getContainerOverview(containerId.value)
const current = refreshed.documents.find((item) => item.documentType === targetType)
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
}
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
} else {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
overview.value = await getContainerOverview(containerId.value)
}
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') {
if (documentType.value === 'CUSTOMS_DECLARATION') {
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
} else {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
}
}
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
documentError.value = details
}
finally { documentSaving.value = false }
}
async function refreshCustomsFacts() {
overview.value = await getContainerOverview(containerId.value)
await loadOutboundDispatch()
}
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
if (!overview.value?.loadingPlanId || !detail.value) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
return
}
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
try {
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
} catch { customsCompleting.value = false; return }
try {
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
try { await refreshCustomsFacts() }
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
} catch (cause) {
const details = getApiErrorDetails(cause, '确认报关完成失败')
if (details.code === 'A0409') {
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
}
customsActionError.value = details
} finally { customsCompleting.value = false }
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
return
}
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
documentDownloading.value = true
try {
const blob = item.documentType === 'CUSTOMS_DECLARATION'
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
: item.documentType === 'DESTINATION_CLEARANCE'
const blob = item.documentType === 'DESTINATION_CLEARANCE'
? await downloadContainerClearance(containerId.value, item.fileId)
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
} catch (cause) {
const details = getApiErrorDetails(cause, '资料下载失败')
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
ElMessage.error(details.message)
}
@@ -303,7 +306,7 @@ async function saveException() {
}
async function openClearance() {
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
@@ -312,17 +315,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
clearanceDocument.value = uploaded
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
ElMessage.success('目的港清关资料已上传')
try {
const refreshed = await getContainerOverview(containerId.value)
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
} catch (cause) {
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
if (details.code === 'A0409') {
@@ -334,8 +338,20 @@ async function uploadClearance() {
}
async function completeClearance() {
if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null
try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成')
if (!completed) throw new Error('确认清关完成但未返回附件信息')
clearanceDocument.value = completed
const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE')
replaceOverviewDocument(optimistic)
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
ElMessage.success('已完成清关')
try {
const refreshed = await getContainerOverview(containerId.value)
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] }
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') }
}
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false }
}
@@ -351,12 +367,31 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
<section class="detail-section surface-panel" aria-labelledby="container-information-heading">
<h2 id="container-information-heading">货柜信息</h2>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<div class="detail-fields"><dl><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">报关清关信息</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['documents']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
</section>
<section class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">签收信息</h2>
<div class="detail-fields"><dl><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
@@ -371,16 +406,24 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
</article>
</div>
</template>
</div>
</section>
<section class="detail-section surface-panel" aria-labelledby="container-fees-heading">
<h2 id="container-fees-heading">费用明细</h2>
<LoadErrorState v-if="loadingDetailError" compact title="费用明细加载失败" :message="loadingDetailError.message" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-alert v-else-if="!canViewContainerFees" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
<el-empty v-else description="当前货柜尚无可读取的装柜费用上下文" :image-size="64" />
</section>
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</template>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 16px}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
</style>
@@ -10,7 +10,7 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import AppDialog from '@/shared/components/AppDialog.vue'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
@@ -20,7 +20,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOcean
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), 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(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), 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(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() }))
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
@@ -219,7 +219,7 @@ describe('ContainersView', () => {
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
const labels = ['真实集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
@@ -231,20 +231,61 @@ describe('ContainersView', () => {
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport')
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
})
it('opens the existing released-booking workflow when entered from container detail', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('route.query.linkContainerId')
expect(source).toContain('const target = await getContainer(linkContainerId)')
expect(source).toContain('await openLink(target)')
expect(source).toContain("void router.replace({ name: 'container-loading' })")
})
it('normalizes the mandatory booking-link container number and keeps it as the only initial-entry write', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ' mscu1234567 '
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
})
expect(vm.linkContainerNo).toBe('MSCU1234567')
wrapper.unmount()
})
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')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).toContain('uploadLoadingDeclaration')
expect(detailSource).not.toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('ContainerCustomsPanel')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
@@ -361,8 +402,8 @@ describe('ContainersView', () => {
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -381,8 +422,8 @@ describe('ContainersView', () => {
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -404,7 +445,7 @@ describe('ContainersView', () => {
})
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -426,7 +467,7 @@ describe('ContainersView', () => {
describe('ContainersView trucking supplier contract', () => {
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'customsDeclarationDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`key: '${field}'`, previous + 1)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, 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 } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,13 +18,14 @@ 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 { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } 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<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
const router = useRouter()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
@@ -97,7 +98,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 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'))
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
@@ -146,7 +146,6 @@ 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: '国内出口报关附件', 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' },
@@ -166,6 +165,7 @@ const columns: DataTableColumn[] = [
]
function optional(value: string | null) { return value?.trim() || null }
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -203,7 +203,7 @@ function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
return {
containerNo: optional(form.containerNo),
containerNo: optional(detail.value?.containerNo || null),
warehouseId: form.warehouseId,
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
@@ -394,7 +394,8 @@ async function openLink(source: object) {
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const containerNo = linkContainerNo.value.trim()
const containerNo = normalizedContainerNo(linkContainerNo.value)
linkContainerNo.value = containerNo
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true
@@ -439,7 +440,16 @@ function handleMoreAction(command: string, source: object) {
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true }
try {
const latest = await getContainer(row.id)
if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) {
ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单')
return
}
detail.value = latest
replaceForm(latest)
editorOpen.value = true
}
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
@@ -451,8 +461,7 @@ async function save() {
saving.value = true
editorError.value = null
try {
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
else await updateContainer(form.id, form.version, payload())
await updateContainer(form.id, form.version, payload())
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '保存失败')
@@ -495,7 +504,23 @@ watch(linkOpen, (open) => {
linkSequence += 1
linkLoading.value = false
})
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
onMounted(async () => {
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
if (!linkContainerId || !canEdit.value) return
try {
const target = await getContainer(linkContainerId)
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !['WAIT_LOADING', 'EDITING'].includes(target.planStatus)) {
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
return
}
await openLink(target)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
} finally {
void router.replace({ name: 'container-loading' })
}
})
</script>
<template>
@@ -511,7 +536,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
@@ -541,34 +566,33 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待录入'" disabled /><small>初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" :disabled="transportOnly" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" :disabled="transportOnly" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
</AppDialog>
</section>
@@ -8,10 +8,13 @@ import { resolve } from 'node:path'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import {
assignSmartLoadingGroup,
assignSmartLoadingItem,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
recommendSmartLoading,
removeSmartLoadingGroup,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
@@ -33,11 +36,14 @@ vi.mock('../api/platformContainerLoading', () => ({
listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
assignSmartLoadingGroup: vi.fn(),
recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(),
removeSmartLoadingItem: vi.fn(),
removeSmartLoadingGroup: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -61,7 +67,7 @@ describe('GlobalSmartLoadingView', () => {
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
})
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
@@ -94,7 +100,7 @@ describe('GlobalSmartLoadingView', () => {
total: 1,
pages: 1,
})
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -184,13 +190,60 @@ describe('GlobalSmartLoadingView', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('item.accountId !== customerId')
expect(source).toContain('member.accountId === customerId')
expect(source).not.toContain('customer.tenantCode')
})
it('defaults candidate assignment to all available boxes', () => {
it('defaults ordinary candidate assignment to all available boxes', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
expect(source).toContain(': candidate.availablePackageCount')
})
it('aggregates a two-order candidate group and calls only whole-group assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '10', destinationWarehouseCode: 'JFK8', destinationWarehouseName: 'JFK8', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 2, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.4, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
])
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
const buttons = wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(plan.id, '201', plan.version)
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
it('blocks an incomplete three-member candidate group without exposing member assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
accountId: `${index + 1}`, inboundId, orderId: `50${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: `客户${index}`, goodsSummary: `货物${index}`, availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
})))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
expect(button.attributes('disabled')).toBeDefined()
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
})
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
})
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
@@ -296,8 +349,8 @@ describe('GlobalSmartLoadingView', () => {
it('matches candidate goods by their own formal top-level channel code', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 },
{ accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6 },
{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null },
{ accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null },
])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -339,7 +392,7 @@ describe('GlobalSmartLoadingView', () => {
'ERR_BAD_REQUEST',
undefined,
undefined,
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除', requestId: 'request-remove-1', traceId: 'trace-remove-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const errorMessage = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
@@ -353,8 +406,9 @@ describe('GlobalSmartLoadingView', () => {
expect(removeSmartLoadingItem).toHaveBeenCalled()
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('该订单在拣货中不许删除')
expect(wrapper.text()).toContain('装柜操作失败')
expect(wrapper.text()).toContain('request-remove-1')
expect(wrapper.text()).toContain('trace-remove-1')
})
it('removes every item with the latest returned plan version', async () => {
@@ -444,7 +498,7 @@ describe('GlobalSmartLoadingView', () => {
})
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -496,6 +550,10 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
@@ -14,6 +14,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingGroup,
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
@@ -21,6 +22,7 @@ import {
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingGroup,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
@@ -29,6 +31,7 @@ import type {
SmartLoadingPlan,
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -46,11 +49,13 @@ const selectionLoading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectionError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
@@ -79,7 +84,88 @@ const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const rows: SmartLoadingCandidateRow[] = []
const groups = new Map<string, SmartLoadingCandidate[]>()
for (const candidate of candidates.value) {
if (!candidate.coLoadGroupId) {
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
continue
}
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
}
for (const members of groups.values()) {
const first = members[0]
rows.push({
...first,
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
availablePackageCount: Number(first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0)),
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
sourceCandidates: members,
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(first.memberCount || members.length),
})
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
const grouped = new Map<string, SmartLoadingInboundItem[]>()
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
}
for (const rows of grouped.values()) {
const first = rows[0]
const group = planGroupsByInboundId.value.get(first.inboundId)!
result.push({
...first,
id: `coload:${group.groupId}`,
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
canAdjust: false,
canRemove: rows.every((row) => row.canRemove),
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
})
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
@@ -103,10 +189,10 @@ const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return candidates.value.filter((item) => {
return displayedCandidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && item.accountId !== customerId) return false
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
return !inboundCode || item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))
})
})
@@ -120,6 +206,7 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
@@ -129,6 +216,7 @@ const candidateColumns: DataTableColumn[] = [
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
@@ -139,7 +227,7 @@ function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
@@ -178,6 +266,8 @@ function clearSelectionData() {
candidates.value = []
noDraft.value = false
selectionError.value = null
mutationError.value = null
planGroupsByInboundId.value = new Map()
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
}
@@ -199,7 +289,11 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
])
if (request !== selectionRequest) return
candidates.value = candidateResult
if (planResult.value) acceptPlan(planResult.value, preserved, false)
if (planResult.value) {
acceptPlan(planResult.value, preserved, false)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
syncDrafts(planResult.value, preserved)
}
else noDraft.value = planResult.missing
} catch (error) {
if (request === selectionRequest) selectionError.value = getApiErrorDetails(error, '柜次装柜数据加载失败')
@@ -274,27 +368,39 @@ async function createPlan() {
}
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
assignForm.packageCount = candidate.availablePackageCount
assignForm.packageCount = candidate.coLoadGroupId
? Number(candidate.groupTotalBoxes || candidate.availablePackageCount)
: candidate.availablePackageCount
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
return
}
await mutate(async () => {
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
inboundId: selectedCandidate.value!.inboundId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
const selected = selectedCandidate.value!
acceptPlan(selected.coLoadGroupId
? await assignSmartLoadingGroup(plan.value!.id, selected.inboundId, plan.value!.version)
: await assignSmartLoadingItem(plan.value!.id, {
inboundId: selected.inboundId,
packageCount: assignForm.packageCount,
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
await loadSelectedData()
ElMessage.success('明细已加入当前柜次')
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
return Boolean(candidate.blockReason) || candidate.locked === true
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|| candidate.availablePackageCount < 1
}
async function recommend() {
if (!plan.value) return
await mutate(async () => {
@@ -330,11 +436,20 @@ async function adjust(source: object) {
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
const row = source as SmartLoadingPlanRow
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await loadSelectedData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
@@ -357,6 +472,11 @@ async function removeAll() {
await mutate(async () => {
let current = plan.value!
for (const inboundItem of removableItems) {
if (inboundItem.coLoadGroup) {
current = await removeSmartLoadingGroup(current.id, inboundItem.coLoadGroup.groupId, current.version)
acceptPlan(current)
continue
}
for (const original of inboundItem.sourceItems) {
const item = current.items.find((value) => value.id === original.id)
if (!item) continue
@@ -370,13 +490,13 @@ async function removeAll() {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
}, '推送预装清单失败')
}
@@ -386,6 +506,7 @@ async function mutate(
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
) {
mutating.value = true
mutationError.value = null
try {
await action()
} catch (error) {
@@ -402,6 +523,7 @@ async function mutate(
} else {
ElMessage.error(details.message)
}
mutationError.value = details
} finally {
mutating.value = false
}
@@ -440,6 +562,9 @@ onMounted(() => { void load(); void loadCustomers() })
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
@@ -458,10 +583,11 @@ onMounted(() => { void load(); void loadCustomers() })
size="small"
type="primary"
plain
:disabled="assignedInboundIds.has(row.inboundId) || row.availablePackageCount < 1"
:disabled="candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
@@ -489,7 +615,7 @@ onMounted(() => { void load(); void loadCustomers() })
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
</div>
@@ -539,8 +665,9 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust && !row.coLoadGroup" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
@@ -548,12 +675,19 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</template>
</div>
</AppDialog>
</template>
@@ -603,9 +737,12 @@ onMounted(() => { void load(); void loadCustomers() })
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.plan-pane :deep(.el-input-number) { width: 120px; }
.package-count-editor { display: flex; min-width: 0; }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; gap: 4px; }
.candidate-block { color: var(--el-color-danger) !important; }
@media (max-width: 1180px) {
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.embedded-smart-loading .dispatch-grid { height: auto; }
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
@@ -7,13 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading } from '../api/platformContainerLoading'
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = {
@@ -30,6 +31,7 @@ const planResponse = {
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [],
}
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
describe('SmartLoadingWorkbenchView', () => {
beforeEach(() => {
@@ -37,7 +39,7 @@ describe('SmartLoadingWorkbenchView', () => {
vi.clearAllMocks()
vi.mocked(getContainer).mockResolvedValue(container)
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate])
})
it('restores the real draft and explains candidate capacity', async () => {
@@ -99,6 +101,10 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain("canPush && plan?.status === 'EDITING'")
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
expect(source).not.toContain("label: '目的仓'")
expect(source).not.toContain('plan.planNo')
@@ -107,4 +113,68 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain('<span>集装箱号</span>')
expect(source).not.toContain('当前柜号')
})
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
})
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ ...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 4, availableVolumeCbm: 0.8, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ ...candidate, inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', availablePackageCount: 2, availableVolumeCbm: 0.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ ...candidate, accountId: '2', inboundId: '202', orderId: '302', inboundCode: 'IN-202', availablePackageCount: 6, availableVolumeCbm: 1.2, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
])
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9007199254740999 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
expect(wrapper.text()).toContain('整组 12 箱')
expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1)
await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click')
await flushPromises()
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows an exact three-member group block and prevents individual assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({
...candidate, inboundId, orderId: `5${index}`, inboundCode: `IN-${inboundId}`, coLoadGroupId: '9900', groupNo: 'CLG-9900',
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票',
})))
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
expect(button.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('本次计划装柜箱数')
})
it('keeps an ordinary ungrouped inbound on the existing partial-count assignment', async () => {
vi.mocked(assignSmartLoadingItem).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -11,12 +11,13 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
recommendSmartLoading, removeSmartLoadingItem,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
const route = useRoute()
const router = useRouter()
@@ -31,9 +32,10 @@ const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
@@ -43,7 +45,90 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || []))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const rows: SmartLoadingCandidateRow[] = []
const groups = new Map<string, SmartLoadingCandidate[]>()
for (const candidate of candidates.value) {
if (!candidate.coLoadGroupId) {
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
continue
}
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
}
for (const members of groups.values()) {
const first = members[0]
rows.push({
...first,
inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'),
goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'),
availablePackageCount: first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0),
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
sourceCandidates: members,
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(first.memberCount || members.length),
})
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
const grouped = new Map<string, SmartLoadingInboundItem[]>()
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
}
for (const rows of grouped.values()) {
const first = rows[0]
const group = planGroupsByInboundId.value.get(first.inboundId)!
result.push({
...first,
id: `coload:${group.groupId}`,
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
skuCode: [...new Set(rows.map((row) => row.skuCode).filter(Boolean))].join('、'),
productNameZh: [...new Set(rows.map((row) => row.productNameZh).filter(Boolean))].join('、'),
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
canAdjust: false,
canRemove: rows.every((row) => row.canRemove),
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
})
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
@@ -57,6 +142,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
@@ -66,6 +152,7 @@ const itemColumns: DataTableColumn[] = [
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
@@ -76,7 +163,7 @@ const candidateColumns: DataTableColumn[] = [
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function explainCandidate(source: object) {
const candidate = candidateRow(source)
@@ -100,9 +187,15 @@ function syncDrafts(preserved: Record<string, number> = {}) {
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
async function acceptPlanWithGroups(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
acceptPlan(next, preserved)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(next)
syncDrafts(preserved)
}
async function load() {
loading.value = true; loadError.value = null; noDraft.value = false
planGroupsByInboundId.value = new Map()
try {
container.value = await getContainer(containerId.value)
if (container.value.planStatus === 'COMPLETED') {
@@ -110,7 +203,7 @@ async function load() {
candidates.value = []
return
}
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
try { await acceptPlanWithGroups(await getSmartLoadingPlanByBooking(containerId.value)) }
catch (error) {
const detail = getApiErrorDetails(error)
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
@@ -132,34 +225,51 @@ function openOperationalList() {
async function createPlan() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
try { await acceptPlanWithGroups(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
finally { mutating.value = false }
}
function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true }
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
assignForm.packageCount = candidate.coLoadGroupId ? Number(candidate.groupTotalBoxes || candidate.availablePackageCount) : Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
mutating.value = true
mutationError.value = null
try {
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
assignOpen.value = false; ElMessage.success('货物已分配')
const selected = selectedCandidate.value
const next = selected.coLoadGroupId
? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version)
: await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version })
await acceptPlanWithGroups(next)
assignOpen.value = false
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
} catch (error) { await handleMutationError(error, '分配失败') }
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
return Boolean(candidate.blockReason) || candidate.locked === true
|| candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId))
|| candidate.availablePackageCount < 1
}
async function recommend() {
if (!plan.value) return
mutating.value = true
mutationError.value = null
try {
try {
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
} catch (error) {
const details = getApiErrorDetails(error, '智能推荐失败')
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
await load()
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
}
ElMessage.success('智能建议已生成,请人工核对后再推送')
}
@@ -173,24 +283,34 @@ async function adjust(source: object) {
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
mutating.value = true
mutationError.value = null
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
catch (error) { await handleMutationError(error, '调整失败') }
finally { mutating.value = false }
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
await ElMessageBox.confirm(`确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
const row = source as SmartLoadingPlanRow
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try {
if (row.coLoadGroup) {
await acceptPlanWithGroups(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await load()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
await acceptPlanWithGroups(current)
}
await load()
ElMessage.success('该入仓码已从装柜方案移除')
@@ -201,11 +321,11 @@ async function remove(source: object) {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
try { await acceptPlanWithGroups(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
@@ -265,22 +385,24 @@ onMounted(load)
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust && !row.coLoadGroup" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>全部入仓片段联动,固定 {{ row.coLoadGroup.fullPackageCount }} 箱</span></div><span v-else>普通单票</span></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="assignedInboundIds.has(row.inboundId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</template>
</template>
</template>
<AppDialog v-model="assignOpen" title="按入仓码分配箱数" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></div></AppDialog>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section>
</AdminPage>
</template>
@@ -306,8 +428,12 @@ onMounted(load)
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
.row-actions { min-width: 150px; }
.workbench :deep(.el-input-number) { width: 130px; }
.package-count-editor { display: flex; min-width: 0; }
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: fit-content; gap: 3px; }
.candidate-group > span,.candidate-group > small { color: var(--cc-muted); font-size: 11px; }
.candidate-group .candidate-block { color: var(--el-color-danger); }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
const plan = {
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
pdaTaskStatus: null, pdaTaskVersion: null,
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan
describe('smart loading plan co-load groups', () => {
it('rebuilds the locked group from the current inbound association contract', async () => {
const lookup = vi.fn(async (inboundId: string) => ({
inboundId, inboundCode: `IN-${inboundId}`, loadingPlanId: '99', coLoadGroupId: '9001', coLoadGroupNo: 'CLG-9001', items: [],
}))
const result = await loadSmartLoadingPlanCoLoadGroups(plan, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
expect(result.get('201')).toEqual({
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
})
expect(result.get('202')).toBe(result.get('201'))
})
it('leaves ordinary plan items ungrouped', async () => {
const result = await loadSmartLoadingPlanCoLoadGroups(plan, async (inboundId) => ({ inboundId, inboundCode: `IN-${inboundId}`, items: [] }))
expect(result.size).toBe(0)
})
})
@@ -0,0 +1,47 @@
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
import { groupSmartLoadingItems } from './smartLoadingItems'
import type { SmartLoadingPlan } from './types'
export interface SmartLoadingPlanCoLoadGroup {
groupId: string
groupNo: string
lockedPlanId: string
memberOrderCount: number
inboundFragmentCount: number
fullPackageCount: number
}
type AssociationLookup = (inboundId: string) => Promise<LoadingAssociationSummary>
export async function loadSmartLoadingPlanCoLoadGroups(
plan: SmartLoadingPlan,
lookup: AssociationLookup = getLoadingAssociationSummaryByInboundId,
) {
const inboundRows = groupSmartLoadingItems(plan.items || [])
if (!inboundRows.length) return new Map<string, SmartLoadingPlanCoLoadGroup>()
const associations = await Promise.all(inboundRows.map(async (row) => ({
row,
association: await lookup(row.inboundId),
})))
const membersByGroupId = new Map<string, typeof associations>()
for (const entry of associations) {
const groupId = String(entry.association.coLoadGroupId || '')
if (!groupId) continue
membersByGroupId.set(groupId, [...(membersByGroupId.get(groupId) || []), entry])
}
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
for (const [groupId, members] of membersByGroupId) {
const group = {
groupId,
groupNo: members.find((entry) => entry.association.coLoadGroupNo)?.association.coLoadGroupNo || '同柜不可拆组',
lockedPlanId: members.find((entry) => entry.association.loadingPlanId)?.association.loadingPlanId || plan.id,
memberOrderCount: new Set(members.map((entry) => entry.row.orderId)).size,
inboundFragmentCount: members.length,
fullPackageCount: members.reduce((sum, entry) => sum + entry.row.packageCount, 0),
} satisfies SmartLoadingPlanCoLoadGroup
for (const { row } of members) groupsByInboundId.set(row.inboundId, group)
}
return groupsByInboundId
}
+19 -1
View File
@@ -88,7 +88,7 @@ export interface BusinessDocument {
version: number
}
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
export interface ContainerOverviewDocument {
@@ -149,8 +149,20 @@ export interface ContainerExceptionHistory {
}
export interface ContainerTimelineEntry {
id?: string | null
eventId?: string | null
idempotencyKey?: string | null
eventType: string
action?: string | null
fromStatus?: string | null
toStatus?: string | null
detail: string | null
summary?: string | null
changeSummary?: string | null
reason?: string | null
changedFields?: unknown
fieldChanges?: unknown
changes?: unknown
operatorId: string | null
operatorName: string | null
occurredAt: string | null
@@ -236,6 +248,12 @@ export interface SmartLoadingCandidate {
unitNetWeightKg: number | null
averageUnitVolumeCbm: number | null
availableVolumeCbm: number | null
coLoadGroupId: string | null
groupNo: string | null
memberCount: number | null
groupTotalBoxes: number | null
locked: boolean | null
blockReason: string | null
}
export interface SmartLoadingItem {
@@ -9,7 +9,14 @@ const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/cus
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), put: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
unwrapApiResponse: <T>(response: { data: T }) => {
const value = response.data as T & { code?: string; message?: string; data?: unknown }
if (value && typeof value === 'object' && 'code' in value) {
if (value.code !== '200') throw new Error(value.message || 'request failed')
return value.data as T
}
return value
},
}))
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
@@ -46,6 +53,19 @@ describe('customer overseas storage fee api', () => {
expect(result.version).toBe(1)
})
it('rejects a business error returned by the update endpoint', async () => {
vi.mocked(http.put).mockResolvedValue({ data: { code: 'A0400', message: '璇锋眰鏃犳晥', data: null } })
await expect(updateCustomerOverseasStorageFees(config.customerId, {
customerId: config.customerId,
customerName: '瀹㈡埛',
currency: 'USD',
version: 0,
tiers: [{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0 }],
})).rejects.toThrow('璇锋眰鏃犳晥')
expect(http.get).not.toHaveBeenCalled()
})
it('normalizes an omitted end day as an open-ended tier', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } })
const result = await getCustomerOverseasStorageFees(config.customerId)
+2 -1
View File
@@ -55,7 +55,8 @@ export async function getCustomerOverseasStorageFees(tenantId: string) {
}
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
const response = await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
unwrapApiResponse(response)
return getCustomerOverseasStorageFees(tenantId)
}
+22
View File
@@ -56,6 +56,28 @@ describe('finance api', () => {
])
})
it('includes a supplier filter only for payable fee-pool requests', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
await listFinanceFeePool('10001', 'PAYABLE', '301')
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-pool', {
params: { customerId: '10001', direction: 'PAYABLE', supplierId: '301' },
})
})
it('preserves settlement mode from fee-pool candidates for client-side eligibility checks', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{
poolItemId: 'PAYABLE:42', customerId: 10001, direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: 42,
sourceVersion: 1, feeItemName: '进仓费', amount: 33, currency: 'CNY', settlementMode: 'DIRECT_PAID',
status: 'AVAILABLE', createdAt: '2026-08-26T00:00:00',
}] })
const [candidate] = await listFinanceFeePool('10001', 'PAYABLE', '301')
expect(candidate.settlementMode).toBe('DIRECT_PAID')
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
+6 -4
View File
@@ -111,12 +111,14 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
}
}
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } })
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE', supplierId?: string): Promise<FinanceFeePoolItem[]> {
const params: Record<string, unknown> = { customerId: customerId?.trim() || undefined, direction }
if (supplierId?.trim()) params.supplierId = supplierId.trim()
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params })
return unwrapApiResponse(response).map((item) => {
const poolItemId = stringValue(item.poolItemId)
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', settlementMode: item.settlementMode == null ? null : stringValue(item.settlementMode), status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
})
}
@@ -124,7 +126,7 @@ export async function listFinanceAccounts(params: { page?: number; size?: number
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
const data = unwrapApiResponse(response)
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const records: FinanceAccountSummary[] = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
@@ -52,6 +52,7 @@ describe('finance six-page delivery contract', () => {
it('connects G1 customer accounts to the formal list and ledger operations', () => {
const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue')
const types = source('src/modules/finance/types.ts')
expect(api).toContain('`${basePath}/accounts`')
expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`')
expect(page).toContain('listFinanceAccounts')
@@ -62,6 +63,11 @@ describe('finance six-page delivery contract', () => {
expect(page).toContain('options: customers.value.filter')
expect(page).toContain('label: item.tenantName')
expect(page).not.toContain('tenantCode')
expect(types).toContain('export interface FinanceOperatingFundsSummary')
expect(types).toContain('export interface FinanceAccountSummary extends FinanceOperatingFundsSummary')
expect(api).toContain('const records: FinanceAccountSummary[]')
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
for (const forbidden of ['客户线下支付', '已收客户款', '已付成本']) expect(page).not.toContain(forbidden)
const columns = page.slice(page.indexOf('const columns: DataTableColumn[]'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'customerName'"))
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
+7 -1
View File
@@ -1,3 +1,9 @@
export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance'
export {
getFinanceCustomerAccountSummary,
getFinanceSettlement,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
type FinanceCustomerAccountSummaryResource,
} from './api/finance'
@@ -54,9 +54,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="account-metrics"')
expect(page).toContain('待收金额')
expect(page).toContain('待开账金额')
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
expect(page.match(/class="account-metric /g)).toHaveLength(3)
expect(page).toContain('class="ledger-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
expect(page).toContain('class="ledger-table"')
expect(page).toContain('<template #toolbar-actions>')
expect(page).toContain('<template #pagination-left>共 {{ ledgerTotal }} 条</template>')
@@ -71,7 +72,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
for (const metric of ['总成本(CNY)', '已付成本(CNY)', '总成本', '已付成本', '待付金额']) expect(page).toContain(metric)
for (const column of ['总成本(CNY)', '待收客户款(CNY)', '待付供应商款(CNY)', '已线下支付供应商款(CNY)']) expect(page).toContain(column)
expect(page).not.toContain('已付成本')
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
@@ -102,8 +104,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
})
it('shows only derived CNY summaries and no wallet actions', () => {
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
for (const marker of ['运营资金:待收客户款', '待付供应商款', '已线下支付供应商款', 'CNY']) expect(page).toContain(marker)
for (const forbidden of ['客户线下支付', '已收客户款', '手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
})
it('selects an active customer by name from master data', () => {
@@ -150,6 +152,12 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(wrapper.text()).toContain('C-001')
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics')
expect(metrics.findAll('.account-metric')).toHaveLength(3)
expect(metrics.text()).toContain('待收客户款100.00CNY')
expect(metrics.text()).toContain('待付供应商款40.00CNY')
expect(metrics.text()).toContain('已线下支付供应商款50.00CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
wrapper.unmount()
@@ -18,7 +18,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceOperatingFundsSummary } from '../types'
const access = useAccessPreviewStore()
const route = useRoute()
@@ -65,12 +65,12 @@ const columns: DataTableColumn[] = [
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'outstandingReceivable', label: '待收客户款(CNY)', width: 155 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'totalCost', label: '总成本(CNY)', width: 140 },
{ key: 'paidCost', label: '已付成本(CNY)', width: 150 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'paidCost', label: '已线下支付供应商款(CNY)', width: 210 },
{ key: 'outstandingPayable', label: '待付供应商款(CNY)', width: 165 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
@@ -87,14 +87,11 @@ const ledgerColumns: DataTableColumn[] = [
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const summary = computed(() => rows.value.reduce((result, row) => ({
receivable: result.receivable + row.outstandingReceivable,
pending: result.pending + row.pendingBilling,
received: result.received + row.receivedInPeriod,
totalCost: result.totalCost + row.totalCost,
const operatingFundsSummary = computed<FinanceOperatingFundsSummary>(() => rows.value.reduce((result, row) => ({
outstandingReceivable: result.outstandingReceivable + row.outstandingReceivable,
outstandingPayable: result.outstandingPayable + row.outstandingPayable,
paidCost: result.paidCost + row.paidCost,
payable: result.payable + row.outstandingPayable,
}), { receivable: 0, pending: 0, received: 0, totalCost: 0, paidCost: 0, payable: 0 }))
}), { outstandingReceivable: 0, outstandingPayable: 0, paidCost: 0 }))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -267,7 +264,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 总成本 {{ money(summary.totalCost) }} · 已付成本 {{ money(summary.paidCost) }} · 待付 {{ money(summary.payable) }} CNY</span></template>
<template #pagination-left><span>运营资金:待收客户款 {{ money(operatingFundsSummary.outstandingReceivable) }} · 待付供应商款 {{ money(operatingFundsSummary.outstandingPayable) }} · 已线下支付供应商款 {{ money(operatingFundsSummary.paidCost) }} CNY</span></template>
</DataTable>
</section>
</ListPageLayout>
@@ -275,12 +272,12 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric"><span>总成本</span><strong>{{ money(selected.totalCost) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已付成本</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户运营资金"><div class="account-metric is-receivable"><span>待收客户款</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付供应商款</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已线下支付供应商款</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
@@ -129,6 +129,28 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(adjustFinanceFeeSource).not.toHaveBeenCalled()
})
it('closes the customer dropdown when the surrounding page is scrolled', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(source).toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)")
expect(source).toContain('onVisibleChange: onCustomerSelectVisible')
expect(source).not.toContain('input?.blur()')
expect(source).toContain("class: 'finance-customer-select'")
})
it('uses sourceSystem to suppress Firstleg editing when the source type is not in the legacy fallback list', async () => {
const firstlegOtherType = { ...feeSummary, sourceType: 'ORDER_FEE', reviewStatus: 'DRAFT' as const }
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [firstlegOtherType], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
expect(buttonLabels).not.toContain('缂栬緫')
const vm = wrapper.vm as unknown as { openMutation(row: Record<string, unknown>, mode: 'EDIT'): Promise<void> }
await vm.openMutation(firstlegOtherType as unknown as Record<string, unknown>, 'EDIT')
expect(getFinanceFeeSource).not.toHaveBeenCalled()
expect(editFinanceFeeSource).not.toHaveBeenCalled()
})
it('loads all customers by default and keeps explicit customer filtering', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list'])
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, customerId: undefined, direction: undefined }))
@@ -25,6 +25,7 @@ import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow {
id: string
feeSourceId: string | null
sourceSystem: string
customerId: string
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
@@ -53,7 +54,6 @@ const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const customerSelectOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
@@ -78,6 +78,7 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const exportBusy = ref(false)
const customerSelectVisible = ref(false)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
@@ -93,15 +94,6 @@ function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown
}
const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
function onCustomerSelectVisible(visible: boolean) {
customerSelectOpen.value = visible
}
function closeCustomerSelectOnScroll() {
if (!customerSelectOpen.value || typeof document === 'undefined') return
const input = document.querySelector('.finance-customer-select input') as HTMLInputElement | null
input?.blur()
customerSelectOpen.value = false
}
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
@@ -114,10 +106,10 @@ const queryFields = computed<QueryField[]>(() => [
class: 'finance-customer-select',
popperClass: 'finance-customer-arap-popper',
teleported: false,
onVisibleChange: onCustomerSelectVisible,
filterable: true,
clearable: true,
loading: customersLoading.value,
onVisibleChange: onCustomerSelectVisible,
},
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
@@ -128,6 +120,12 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
])
function onCustomerSelectVisible(visible: boolean) { customerSelectVisible.value = visible }
function closeCustomerSelectOnScroll() {
if (!customerSelectVisible.value) return
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
@@ -217,7 +215,7 @@ async function load(model: QueryModel = filters.value) {
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
})
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, sourceSystem: item.sourceSystem, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
@@ -336,7 +334,7 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getFinanceFeeSource(feeSourceId)
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
selected.value = { id: detail.value.poolItemId, feeSourceId, sourceSystem: detail.value.sourceSystem, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
@@ -348,9 +346,7 @@ onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await load()
})
onBeforeUnmount(() => {
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
})
onBeforeUnmount(() => document.removeEventListener('scroll', closeCustomerSelectOnScroll, true))
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
@@ -222,9 +222,38 @@ describe('FinanceCustomerStatementsView', () => {
expect(vm.filteredRows).toHaveLength(1)
vm.filters.containerNo = 'CONT-99'
await flushPromises()
expect(vm.filteredRows).toHaveLength(1)
await vm.load({
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-99',
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
})
await flushPromises()
expect(vm.filteredRows).toHaveLength(0)
})
it('keeps statement status filter options aligned with the list status column', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as {
fields: Array<{ key: string; label: string; placeholder?: string; options?: Array<{ label: string; value: string }> }>
columns: Array<{ key: string; label: string }>
}
const statusField = vm.fields.find((field) => field.key === 'status')
const statusColumn = vm.columns.find((column) => column.key === 'status')
expect(statusField).toMatchObject({ label: '对账单状态', placeholder: '请选择对账单状态' })
expect(statusField?.options).toEqual([
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已审核', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已出账', value: 'ISSUED' },
{ label: '部分结清', value: 'PARTIALLY_PAID' },
{ label: '已结清', value: 'PAID' },
{ label: '已作废', value: 'VOIDED' },
])
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' })
})
it('creates a formal statement export task with only non-empty filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
@@ -18,7 +18,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
@@ -47,6 +47,7 @@ const filters = ref<QueryModel>({
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
settled: '', lastPaymentRange: [], createdRange: [],
})
const appliedFilters = ref<QueryModel>({ ...filters.value })
const rows = ref<StatementRow[]>([])
const page = ref(1)
const pageSize = ref(20)
@@ -99,10 +100,7 @@ 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<QueryField[]>(() => [
{ 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: 'status', type: 'select', label: '对账单状态', width: 180, placeholder: '请选择对账单状态', options: financeBillStatusOptions },
{ 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: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
@@ -114,7 +112,7 @@ const fields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
{ key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
@@ -208,9 +206,9 @@ function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconcilia
}
}
function stringFilter(key: string) { return String(filters.value[key] || '').trim().toLowerCase() }
function stringFilter(key: string) { return String(appliedFilters.value[key] || '').trim().toLowerCase() }
function inDateRange(value: string, key: string) {
const range = filters.value[key]
const range = appliedFilters.value[key]
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
const date = value.slice(0, 10)
return date >= String(range[0]) && date <= String(range[1])
@@ -251,6 +249,7 @@ async function load(model?: QueryModel) {
const hydrateDetails = model !== undefined
if (model) {
filters.value = { ...model }
appliedFilters.value = { ...model }
page.value = 1
}
rows.value = []
@@ -260,7 +259,7 @@ async function load(model?: QueryModel) {
error.value = null
loading.value = true
try {
const statements = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined)
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
const detailErrors: ApiErrorDetails[] = []
const hydrated = await Promise.all(statements.map(async (row) => {
if (!hydrateDetails) return detailToRow(row)
@@ -510,7 +509,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load(filters.value)
await load(appliedFilters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
@@ -540,7 +539,7 @@ async function submitAdjustment() {
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(filters.value)
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
finally { adjustBusy.value = false }
@@ -648,7 +647,7 @@ watch(detailRouteId, (statementId) => {
</el-tabs>
</DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<template v-if="!customerError && !error">
<section class="surface-panel table-panel">
@@ -61,4 +61,8 @@ describe('formal settlement field contract', () => {
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
expect(pageSource).not.toContain('{{ detail.version }}')
})
it('does not offer directly paid costs as payable settlement sources', () => {
expect(pageSource).toContain("item.settlementMode?.toUpperCase() !== 'DIRECT_PAID'")
})
})
@@ -49,6 +49,7 @@ const router = useRouter()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const rows = ref<SettlementRow[]>([])
const page = ref(1)
const pageSize = ref(20)
@@ -113,9 +114,9 @@ const fields = computed<QueryField[]>(() => [
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
])
const filteredRows = computed(() => {
const status = String(filters.value.status || '')
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
const supplierName = String(filters.value.supplierName || '')
const status = String(appliedFilters.value.status || '')
const settlementNo = String(appliedFilters.value.settlementNo || '').trim().toLowerCase()
const supplierName = String(appliedFilters.value.supplierName || '')
return rows.value.filter((item) => (!status || item.status === status)
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
@@ -233,13 +234,14 @@ async function loadCustomers() {
async function load(model?: QueryModel) {
if (model) {
filters.value = { ...model }
appliedFilters.value = { ...model }
page.value = 1
}
rows.value = []
error.value = null
loading.value = true
try {
const customerId = String(filters.value.customerId || '').trim() || undefined
const customerId = String(appliedFilters.value.customerId || '').trim() || undefined
rows.value = props.kind === 'receivable'
? await listFinanceReceivableSettlements(customerId)
: await listFinancePayableSettlements(customerId)
@@ -304,8 +306,13 @@ async function refreshCreateDependencies(customerId?: string) {
return
}
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE')
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
const selectedSupplierId = String(createForm.value.supplierId || '').trim() || undefined
if (!selectedSupplierId) {
createForm.value.feeSourceIds = []
return
}
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE', selectedSupplierId)
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE' && item.settlementMode?.toUpperCase() !== 'DIRECT_PAID')
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
createForm.value.supplierId = ''
}
@@ -321,6 +328,12 @@ async function refreshCreateDependencies(customerId?: string) {
watch(() => createForm.value.customerId, (customerId) => {
if (createOpen.value) void refreshCreateDependencies(customerId)
})
watch(() => createForm.value.supplierId, () => {
if (!createOpen.value) return
createForm.value.feeSourceIds = []
feeOptions.value = []
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId)
})
async function submitCreate() {
const customerId = String(createForm.value.customerId || '')
@@ -580,7 +593,7 @@ onMounted(() => {
</DetailPageLayout>
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" />
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
</template>
<template v-if="customerError || error" #state>
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
@@ -79,7 +79,7 @@ describe('Finance G3/G4 views', () => {
document.body.innerHTML = ''
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
@@ -125,6 +125,25 @@ describe('Finance G3/G4 views', () => {
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
})
it('loads payable sources only for the selected supplier and clears them when the supplier changes', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { openCreate(): Promise<void>; createForm: { customerId: string; supplierId: string; feeSourceIds: string[] } }
await vm.openCreate()
vm.createForm.customerId = '10001'
await flushPromises()
expect(listFinanceFeePool).not.toHaveBeenCalled()
vm.createForm.supplierId = '301'
await flushPromises()
expect(listFinanceFeePool).toHaveBeenCalledWith('10001', 'PAYABLE', '301')
vm.createForm.feeSourceIds = ['81']
vm.createForm.supplierId = ''
await flushPromises()
expect(vm.createForm.feeSourceIds).toEqual([])
})
it('renders request and trace ids when G3 detail loading fails', async () => {
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
@@ -149,4 +168,19 @@ describe('Finance G3/G4 views', () => {
expect(listFinanceReceivableSettlements).toHaveBeenLastCalledWith('10001')
expect(listFinancePayableSettlements).toHaveBeenLastCalledWith('10001')
})
it('applies settlement list text filters only after an explicit query', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list'], { kind: 'payable' })
const vm = wrapper.vm as unknown as {
filters: Record<string, unknown>
filteredRows: FinancePayableSettlement[]
load(model: Record<string, unknown>): Promise<void>
}
vm.filters.settlementNo = 'NOT-FOUND'
await flushPromises()
expect(vm.filteredRows).toHaveLength(1)
await vm.load({ customerId: '', supplierName: '', settlementNo: 'NOT-FOUND', status: '' })
await flushPromises()
expect(vm.filteredRows).toHaveLength(0)
})
})
@@ -75,11 +75,13 @@ describe('finance presentation governance', () => {
expect(arap).toContain("placeholder: '请选择客户'")
expect(arap).not.toContain("placeholder: '全部客户'")
expect(statements).toContain("placeholder: '请选择客户'")
expect(statements).toContain("placeholder: '请选择审核状态'")
expect(statements).toContain("placeholder: '请选择对账单状态'")
expect(statements).toContain(':search-on-clear="false"')
expect(statements).toContain("placeholder: '请选择结清状态'")
expect(statements).toContain("startPlaceholder: '请选择开始日期'")
expect(statements).toContain("endPlaceholder: '请选择结束日期'")
expect(statements).not.toContain("placeholder: '全部客户'")
expect(settlement).toContain(':search-on-clear="false"')
})
it('uses active-customer name selectors across all formal finance pages', () => {
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
import { financeArapStatusOptions, financeBillStatusOptions, financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
describe('finance status presentation', () => {
it.each([
@@ -47,4 +47,26 @@ describe('finance status presentation', () => {
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
})
it('uses review-status values for the customer AR/AP review filter', () => {
expect(financeArapStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING' },
{ label: '已审核', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
])
})
it('uses the complete reconciliation statement lifecycle for statement filters', () => {
expect(financeBillStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已审核', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已出账', value: 'ISSUED' },
{ label: '部分结清', value: 'PARTIALLY_PAID' },
{ label: '已结清', value: 'PAID' },
{ label: '已作废', value: 'VOIDED' },
])
})
})
+6 -7
View File
@@ -31,20 +31,19 @@ const financeStatuses: Record<string, FinanceStatusDefinition> = {
export const financeBillStatusOptions = [
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已出账', value: 'ISSUED' },
{ label: '已审核', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已出账', value: 'ISSUED' },
{ label: '部分结清', value: 'PARTIALLY_PAID' },
{ label: '已结清', value: 'PAID' },
{ label: '已作废', value: 'VOIDED' },
]
export const financeArapStatusOptions = [
...financeBillStatusOptions,
{ label: '待结算', value: 'AVAILABLE' },
{ label: '结算锁定', value: 'LOCKED' },
{ label: '已入账', value: 'BILLED' },
{ label: '已登记', value: 'RECORDED' },
{ label: '已确认', value: 'CONFIRMED' },
{ label: '草稿', value: 'DRAFT' },
{ label: '待审核', value: 'PENDING' },
{ label: '已审核', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
]
function normalizeStatus(status: unknown) {
+9 -5
View File
@@ -1,4 +1,4 @@
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinanceBillStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
@@ -59,6 +59,7 @@ export interface FinanceFeePoolItem {
feeItemName: string
amount: number
currency: 'CNY'
settlementMode?: string | null
status: string
createdAt: string
}
@@ -174,18 +175,21 @@ export interface FinancePayableSettlement {
version: number
}
export interface FinanceAccountSummary {
export interface FinanceOperatingFundsSummary {
outstandingReceivable: number
outstandingPayable: number
paidCost: number
}
export interface FinanceAccountSummary extends FinanceOperatingFundsSummary {
customerId: string
customerCode: string
customerName: string
contactName: string
contactPhone: string
outstandingReceivable: number
pendingBilling: number
receivedInPeriod: number
totalCost: number
paidCost: number
outstandingPayable: number
status: string
lastChangedAt: string
}
+11 -11
View File
@@ -153,12 +153,12 @@ describe('InternalUsersView platform internal user management', () => {
expect(document.body.textContent).toContain('确认添加')
})
it('requires a shared receiving warehouse for WAREHOUSE_PICKER', async () => {
it('requires a shared receiving warehouse for OVERSEAS_WAREHOUSE_PDA_OPERATOR', 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' },
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
],
})
const wrapper = await mountView()
@@ -180,16 +180,16 @@ describe('InternalUsersView platform internal user management', () => {
})
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
expect(internalUsersSource).toContain("role.roleCode === 'WAREHOUSE_PICKER'")
expect(internalUsersSource).toContain('overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null')
expect(internalUsersSource).not.toContain("role.name === '仓库拣货员'")
expect(internalUsersSource).toContain("role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'")
expect(internalUsersSource).toContain('overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null')
expect(internalUsersSource).not.toContain("role.name === '海外仓 PDA 作业员'")
})
it('preserves the picker warehouse binding when editing a WAREHOUSE_PICKER user', async () => {
it('preserves the picker warehouse binding when editing a OVERSEAS_WAREHOUSE_PDA_OPERATOR user', async () => {
const pickerUser: PlatformManagedUser = {
...user,
roleIds: ['4000005'],
roleNames: ['仓库拣货员'],
roleNames: ['海外仓 PDA 作业员'],
overseasWarehouseId: '90071992547409931',
}
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
@@ -197,7 +197,7 @@ describe('InternalUsersView platform internal user management', () => {
})
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
})
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
@@ -220,12 +220,12 @@ describe('InternalUsersView platform internal user management', () => {
})
})
it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => {
it('clears a warehouse binding after switching away from OVERSEAS_WAREHOUSE_PDA_OPERATOR', 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' },
{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' },
],
})
const wrapper = await mountView()
@@ -250,7 +250,7 @@ describe('InternalUsersView platform internal user management', () => {
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' }],
roles: [{ id: '4000005', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外仓 PDA 作业员', status: 'ACTIVE' }],
})
const wrapper = await mountView()
const setupState = (wrapper.vm.$ as unknown as {
+8 -8
View File
@@ -105,10 +105,10 @@ const availableRoles = computed(() => options.value.roles.filter((role) =>
String(role.tenantId) === '0'
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
))
const warehousePickerRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'WAREHOUSE_PICKER').map((role) => String(role.id)),
const overseasPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasWarehousePickerRole = computed(() => form.roleIds.some((roleId) => warehousePickerRoleIds.value.has(roleId)))
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
const formRules = computed<FormRules>(() => ({
username: editingUser.value ? [] : [
@@ -122,7 +122,7 @@ const formRules = computed<FormRules>(() => ({
],
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
overseasWarehouseId: !hasWarehousePickerRole.value || (editingUser.value && !canEdit.value) ? [] : [
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
],
password: editingUser.value && !form.resetPassword ? [] : [
@@ -263,7 +263,7 @@ async function saveUser() {
email: form.email.trim(),
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
password: form.password,
status: form.status,
})
@@ -277,7 +277,7 @@ async function saveUser() {
email: form.email.trim(),
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
})
}
if (canChangeStatus.value && form.status !== row.status) {
@@ -330,7 +330,7 @@ function applyFilters() {
}
watch([page, pageSize], () => void loadUsers())
watch(hasWarehousePickerRole, (selected) => {
watch(hasOverseasPdaRole, (selected) => {
if (!selected) {
form.overseasWarehouseId = ''
overseasWarehousesError.value = null
@@ -419,7 +419,7 @@ onMounted(loadInitialData)
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item v-if="hasWarehousePickerRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
<el-form-item v-if="hasOverseasPdaRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
<el-select v-model="form.overseasWarehouseId" filterable
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
placeholder="请选择平台共享海外收货仓">
@@ -26,6 +26,8 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain('批量审核通过')
expect(source).toContain('getMeasurementReviewDetail')
expect(source).toContain('approveMeasurement')
expect(source).toContain('expectedVersion: detail.inboundVersion')
expect(source).not.toContain('expectedVersion: detail.version')
expect(source).toContain('approval.initialPricingSucceeded')
expect(source).toContain('费用初算未生成,可补价后重新计算')
})
@@ -65,6 +67,15 @@ describe('InboundTaskList measurement scope', () => {
expect(queryFieldsSource).not.toContain('客户编码')
})
it('clears date conditions safely and uses explicit select placeholders', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const columns'))
expect(source).toContain('Array.isArray(filters.value.expectedArrivalRange)')
expect(queryFieldsSource).toContain("placeholder: customerLoadError.value ? '客户列表加载失败' : '请选择客户'")
expect(queryFieldsSource).toContain("placeholder: '请选择仓库'")
})
it('requests every active-customer page and merges name-only options', async () => {
const calls: Array<{ page: number; size: number; status: 'ACTIVE' }> = []
const records = await loadAllActiveCustomers(async (query) => {
@@ -2,7 +2,7 @@
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
import { CircleCheckBig, Eye, Link2, Ruler, SearchCheck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
@@ -12,6 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
@@ -44,6 +45,8 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const relationshipDialogOpen = ref(false)
const relationshipDialogInboundCode = ref('')
const batchReviewLoading = ref(false)
const selectedReviewRows = ref<InboundOrderSummary[]>([])
let requestSerial = 0
@@ -65,14 +68,14 @@ const queryFields = computed<QueryField[]>(() => [
type: 'select',
label: '客户',
width: 260,
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
placeholder: customerLoadError.value ? '客户列表加载失败' : '请选择客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: customer.legalName?.trim() || customer.tenantName,
})),
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
},
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '请选择仓库', options: warehouseOptions.value, props: { filterable: true } },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
])
@@ -164,7 +167,10 @@ async function loadTasks() {
}
function applyFilters() {
appliedFilters.value = { ...filters.value, expectedArrivalRange: [...filters.value.expectedArrivalRange] }
appliedFilters.value = {
...filters.value,
expectedArrivalRange: Array.isArray(filters.value.expectedArrivalRange) ? [...filters.value.expectedArrivalRange] : [],
}
if (page.value === 1) void loadTasks()
else page.value = 1
}
@@ -187,6 +193,13 @@ function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
function openRelationship(row: InboundOrderSummary) {
const code = row.inboundCode?.trim()
if (!code) return
relationshipDialogInboundCode.value = code
relationshipDialogOpen.value = true
}
function handleTaskChanged() {
reviewDialogOpen.value = false
selectedInbound.value = null
@@ -216,17 +229,17 @@ async function batchApproveMeasurements() {
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
const approval = await approveMeasurement(String(row.tenantId), String(row.id), {
measurementId: detail.measurementId,
expectedVersion: detail.version,
expectedVersion: detail.inboundVersion,
})
successCount += 1
if (!approval.initialPricingSucceeded) {
pricingWarnings.push(
`${row.inboundCode || row.id}:${approval.initialPricingMessage || '费用初算未生成'}`,
`${row.inboundCode || '入仓码待补充'}:${approval.initialPricingMessage || '费用初算未生成'}`,
)
}
} catch (error) {
failureCount += 1
failures.push(getApiErrorDetails(error, `${row.inboundCode || row.id} 审核失败`).message)
failures.push(getApiErrorDetails(error, `${row.inboundCode || '入仓码待补充'} 审核失败`).message)
}
}
await loadTasks()
@@ -326,7 +339,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div>
</template>
<template #inboundCode="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
@@ -349,7 +362,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<el-dialog
v-if="isReview"
v-model="reviewDialogOpen"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || '入仓码待补充'}` : taskLabel"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
@@ -378,6 +391,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
@submitted="handleMeasurementSubmitted"
@closed="clearSelectedInbound"
/>
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</AdminPage>
</template>
@@ -388,6 +402,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
.row-actions .el-button { margin: 0; }
.primary-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }
.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
@media (max-width: 520px) {
.row-actions { align-items: stretch; flex-direction: column; }
@@ -35,7 +35,7 @@ const revision = ref(0)
let requestSerial = 0
let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
@@ -22,7 +22,8 @@ describe('InboundMeasurementDialog', () => {
})
it('shows the inbound code once in the dialog title and not as a second body heading', () => {
expect(source).toContain('`测量 · ${props.inboundCode?.trim() || props.inboundId}`')
expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`")
expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId')
expect(source).toContain(':title="title"')
expect(panelSource).not.toContain('<span>入仓码</span>')
expect(panelSource).not.toContain('<strong>{{ detail.inboundCode')
@@ -19,7 +19,7 @@ const emit = defineEmits<{
}>()
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || props.inboundId}`)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`)
async function mayClose() {
if (!measurementPanelRef.value?.isDirty) return true
@@ -115,7 +115,7 @@ const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
isOpen: registrationOpen,
model: form,
requiredPermission,
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Link2, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -10,6 +10,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -42,6 +43,8 @@ const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const printingInboundId = ref('')
const relationshipDialogOpen = ref(false)
const relationshipDialogInboundCode = ref('')
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
@@ -286,6 +289,7 @@ function openDetail(row: InboundOrderSummary) {
}
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
function openRelationship(row: InboundOrderSummary) { const code = row.inboundCode?.trim(); if (!code) return; relationshipDialogInboundCode.value = code; relationshipDialogOpen.value = true }
async function printCartonLabels(row: InboundOrderSummary) {
if (printingInboundId.value) return
printingInboundId.value = row.id
@@ -294,7 +298,7 @@ async function printCartonLabels(row: InboundOrderSummary) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${row.inboundCode || row.id}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
anchor.download = `${row.inboundCode || '入仓码待补充'}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
anchor.click()
URL.revokeObjectURL(url)
ElMessage.success('箱唛已生成,重复打印将继续使用原箱号')
@@ -413,7 +417,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #inbound="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #inbound="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
@@ -456,12 +460,13 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
</DataTable>
</section>
</ListPageLayout>
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</AdminPage>
</template>
<style scoped>
.inbound-orders-page { min-width: 0; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
.inbound-tabs::-webkit-scrollbar { display: none; }
@@ -246,9 +246,9 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.intelligentImageFileId"
:alt="`箱规 ${row.boxCode || row.itemNo || row.id} 的测量图片`"
:alt="`箱规 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的测量图片`"
empty-text="无图片"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || row.id)"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || '箱规编码待补充')"
/>
</template>
</el-table-column>
+2
View File
@@ -66,7 +66,9 @@ export interface MeasurementReviewDetail {
measurementId: string
sourceType: string | null
measurementStatus: string | null
/** 入库单乐观锁版本;审核接口 expectedVersion 使用此字段。 */
inboundVersion: number
/** 测量批次自身版本,不用于入库单状态审核。 */
version: number
originalDocumentNo: string | null
originalGrossWeightKg: number | null
@@ -82,17 +82,40 @@ describe('inbound weight OCR API', () => {
)
})
it('loads same-warehouse pallet snapshots through Firstleg', async () => {
const options = [{ palletId: '11', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }]
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: options })
it('normalizes the formal paged pallet response returned by Firstleg', async () => {
const records = [
{ palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 },
{ palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 },
]
vi.mocked(http.get).mockResolvedValue({
code: '200',
message: 'success',
data: { records, page: '1', size: '20', total: '2', pages: '1' },
})
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual(options)
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({
records, page: 1, size: 20, total: 2, pages: 1,
})
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' } },
)
})
it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' },
})
const controller = new AbortController()
await getWeightPalletOptions('10001', '20001', controller.signal)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' }, signal: controller.signal },
)
})
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
+25 -4
View File
@@ -6,6 +6,7 @@ import type {
CreateWeightResultRequest,
WeightImageUploadResult,
WeightPalletOption,
WeightPalletOptionPage,
WeightResultResource,
WeightWorkspaceResource,
} from '../types'
@@ -15,10 +16,23 @@ const imageBasePath = '/platform/v1/files/measurements'
const maxErrorEnvelopeBytes = 1024 * 1024
const confidenceScale = 100_000
interface WeightPalletOptionPageResponse {
records: WeightPalletOption[]
page: number | string
size: number | string
total: number | string
pages: number | string
}
function segment(value: string) {
return encodeURIComponent(value)
}
function normalizedPageNumber(value: number | string, fallback: number, minimum: number) {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) ? Math.max(minimum, Math.trunc(parsed)) : fallback
}
export function normalizeWeightConfidence(value: number | null) {
if (value == null || !Number.isFinite(value)) return null
const clamped = Math.min(1, Math.max(0, value))
@@ -60,12 +74,19 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) {
return unwrapApiResponse(response)
}
export async function getWeightPalletOptions(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<WeightPalletOption[]>, ApiEnvelope<WeightPalletOption[]>>(
export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
{ params: { tenantId } },
{ params: { tenantId }, ...(signal ? { signal } : {}) },
)
return unwrapApiResponse(response)
const page = unwrapApiResponse(response)
return {
records: page.records,
page: normalizedPageNumber(page.page, 1, 1),
size: normalizedPageNumber(page.size, page.records.length, 0),
total: normalizedPageNumber(page.total, page.records.length, 0),
pages: normalizedPageNumber(page.pages, 0, 0),
} satisfies WeightPalletOptionPage
}
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
@@ -72,6 +72,12 @@ const workspace: WeightWorkspaceResource = {
weightResults: [pendingWeight],
}
function palletPage(records = [{
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}]) {
return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 }
}
enableAutoUnmount(afterEach)
const allWeightPermissions = [
@@ -122,9 +128,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
vi.mocked(getWeightPalletOptions).mockResolvedValue([{
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}])
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage())
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
vi.stubGlobal('URL', {
@@ -326,7 +330,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
it('shows an actionable empty state when the warehouse has no pallet options', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce([])
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce(palletPage([]))
const wrapper = mountPanel()
await flushPromises()
@@ -334,6 +338,28 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true)
})
it('renders records from the formal paged response without showing a load failure', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValueOnce({
records: [
{ palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 },
{ palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 },
],
page: 1,
size: 20,
total: 2,
pages: 1,
})
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³')
expect(document.body.textContent).toContain('塑料托盘 · 皮重 10.000 KG · 容量 1.1 m³')
expect(wrapper.text()).not.toContain('可选托盘加载失败')
})
it('shows pallet loading failures without hiding existing weight results', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
const wrapper = mountPanel()
@@ -352,6 +378,64 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => {
const wrapper = mountPanel(['platform:firstleg:inbound:measure:view'])
await flushPromises()
expect(getWeightWorkspace).toHaveBeenCalledOnce()
expect(getWeightPalletOptions).not.toHaveBeenCalled()
expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false)
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
it('does not request pallet candidates when the inbound status is not editable', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING' })
mountPanel()
await flushPromises()
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => {
vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage(Array.from({ length: 1_000 }, (_, index) => ({
palletId: String(60_000 + index),
palletCode: `TP-${index}`,
palletType: `托盘类型 ${index}`,
capacityCbm: 1.2,
tareWeightKg: 12,
}))))
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
await flushPromises()
const renderedOptions = document.body.querySelectorAll('.el-select-dropdown__item')
expect(renderedOptions.length).toBeGreaterThan(0)
expect(renderedOptions.length).toBeLessThan(100)
})
it('aborts an in-flight pallet request and ignores its late result when the panel closes', async () => {
let resolveOptions: ((value: Awaited<ReturnType<typeof getWeightPalletOptions>>) => void) | undefined
vi.mocked(getWeightPalletOptions).mockImplementationOnce(() => new Promise((resolve) => {
resolveOptions = resolve
}))
const wrapper = mountPanel()
await flushPromises()
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]
wrapper.unmount()
resolveOptions?.(palletPage([{
palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10,
}]))
await flushPromises()
expect(signal?.aborted).toBe(true)
expect(document.body.textContent).not.toContain('过期托盘')
})
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
@@ -55,8 +55,10 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain("option.palletType || '未记录类型'")
})
it('keeps pallet type, tare weight, and capacity in one select line', () => {
expect(source).toContain(':label="palletOptionLabel(option)"')
it('keeps pallet type, tare weight, and capacity in one virtualized select line', () => {
expect(source).toContain('<el-select-v2')
expect(source).toContain(':options="virtualPalletOptions"')
expect(source).toContain('label: palletOptionLabel(option)')
expect(source).toContain("`${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`")
expect(source).not.toContain('<small v-if="selectedPallet">')
expect(source).toContain('width: clamp(360px, 36vw, 520px)')
@@ -67,6 +69,9 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain("platform:firstleg:inbound:weight:edit")
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
expect(source).toContain('&& canEdit.value')
expect(source).toContain('&& editable.value')
expect(source).toContain('cancelPalletOptionsLoad()')
expect(source).toContain("workspace.value?.processStatus === 'MEASURE_PENDING'")
expect(source).toContain('v-if="canEdit && !reviewMode"')
expect(source).toContain('v-if="canEdit && editable && !reviewMode"')
@@ -59,6 +59,8 @@ const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
let loadSequence = 0
let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
@@ -68,6 +70,18 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
const canLoadPalletOptions = computed(() => Boolean(
!props.reviewMode
&& customerId.value
&& props.inboundId
&& canView.value
&& canEdit.value
&& editable.value,
))
const virtualPalletOptions = computed(() => palletOptions.value.map((option) => ({
label: palletOptionLabel(option),
value: option.palletId,
})))
const reviewCorrectionEnabled = computed(() => Boolean(
props.reviewMode
&& props.correctionMode
@@ -212,32 +226,44 @@ async function loadWorkspace() {
}
async function loadPalletOptions() {
if (props.reviewMode) {
if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
return
}
if (!customerId.value || !props.inboundId || !canView.value) {
palletOptions.value = []
selectedPalletId.value = ''
return
}
cancelPalletOptionsLoad()
const sequence = ++palletLoadSequence
const controller = new AbortController()
palletAbortController = controller
palletLoading.value = true
palletError.value = null
try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId)
palletOptions.value = result
if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal)
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
} catch (error) {
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
} finally {
palletLoading.value = false
if (sequence === palletLoadSequence) {
palletLoading.value = false
if (palletAbortController === controller) palletAbortController = null
}
}
}
function cancelPalletOptionsLoad() {
++palletLoadSequence
palletAbortController?.abort()
palletAbortController = null
palletLoading.value = false
}
function chooseFile() {
if (!editable.value) {
ElMessage.warning('当前入库状态不允许上传称重图片')
@@ -397,17 +423,32 @@ watch(
previewOpen.value = false
releasePreview()
void loadWorkspace()
void loadPalletOptions()
},
{ immediate: true },
)
watch(
() => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const,
() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions()
},
)
watch(previewOpen, (open) => {
if (!open) {
releasePreview()
}
})
onBeforeUnmount(releasePreview)
onBeforeUnmount(() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
releasePreview()
})
defineExpose({ reload: loadWorkspace })
</script>
@@ -424,22 +465,16 @@ defineExpose({ reload: loadWorkspace })
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
</div>
<div class="pallet-picker">
<el-select
v-if="!reviewMode"
<el-select-v2
v-if="canEdit && !reviewMode"
v-model="selectedPalletId"
:options="virtualPalletOptions"
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
>
<el-option
v-for="option in palletOptions"
:key="option.palletId"
:label="palletOptionLabel(option)"
:value="option.palletId"
/>
</el-select>
/>
</div>
<input
v-if="!reviewMode"
+8
View File
@@ -43,6 +43,14 @@ export interface WeightPalletOption {
tareWeightKg: number
}
export interface WeightPalletOptionPage {
records: WeightPalletOption[]
page: number
size: number
total: number
pages: number
}
export interface WeightImageUploadResult {
fileId: string
fileName: string
@@ -3,9 +3,6 @@ import { http } from '@/shared/api/http'
import {
getConfirmedLoadingList,
confirmPreloadPlan,
uploadLoadingDeclaration,
completeLoadingDeclaration,
downloadLoadingDeclaration,
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
@@ -162,28 +159,4 @@ describe('platform loading lists API', () => {
})
})
it('uses stable plan and warehouse ids for customs document actions', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
await uploadLoadingDeclaration('100', '300', file, 1, '上传资料')
await completeLoadingDeclaration('100', '300', 2, '完成报关')
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/document/upload')
const body = post.mock.calls[0]?.[1] as FormData
expect(body.get('file')).toBe(file)
expect(body.get('expectedVersion')).toBe('1')
expect(body.get('operationReason')).toBe('上传资料')
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
expect(post.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/complete')
})
it('downloads the current customs attachment through the exact loading-list endpoint with string ids', async () => {
const blob = new Blob(['declaration'], { type: 'application/pdf' })
const get = vi.spyOn(http, 'get').mockResolvedValue(blob)
const result = await downloadLoadingDeclaration('2092438843630391401', '193000000000200002', '2092438843630391502')
expect(get).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-lists/2092438843630391401/customs/document/2092438843630391502/download',
{ params: { warehouseId: '193000000000200002' }, responseType: 'blob' },
)
expect(result).toBe(blob)
})
})
@@ -126,26 +126,10 @@ export interface LoadingHistory {
occurredAt: string
}
export interface BusinessDocument {
id: string | null
documentType: string
fileId: string
fileName: string
fileHash: string | null
uploadedBy: string | null
uploadedByName: string | null
uploadedAt: string | null
completedBy: string | null
completedByName: string | null
completedAt: string | null
version: number
}
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
summary: T
items: LoadingItem[]
histories: LoadingHistory[]
customsDocument?: BusinessDocument | null
}
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
@@ -271,11 +255,6 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
}
}
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
if (!raw) return null
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
}
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
return {
records: (raw.records || []).map(normalize),
@@ -308,7 +287,6 @@ export async function getPreloadPlan(planId: string, warehouseId: string) {
summary: normalizePreloadSummary(result.summary),
items: (result.items || []).map(normalizeItem),
histories: (result.histories || []).map(normalizeHistory),
customsDocument: normalizeDocument(result.customsDocument),
}
}
@@ -323,7 +301,6 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
summary: normalizePreloadSummary(result.summary),
items: (result.items || []).map(normalizeItem),
histories: (result.histories || []).map(normalizeHistory),
customsDocument: normalizeDocument(result.customsDocument),
}
}
@@ -355,32 +332,6 @@ export async function confirmPreloadPlan(planId: string, expectedVersion: number
}
}
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
return normalizeDocument(unwrapApiResponse(response))
}
export async function uploadLoadingDeclaration(planId: string, warehouseId: string, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
body.append('expectedVersion', String(expectedVersion))
if (operationReason) body.append('operationReason', operationReason)
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/upload`, body, { params: { warehouseId }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': `loading-customs-upload-${planId}-${Date.now()}` } })
return normalizeDocument(unwrapApiResponse(response))
}
export async function downloadLoadingDeclaration(planId: string, warehouseId: string, fileId: string) {
return http.get<Blob, Blob>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/${encodeURIComponent(fileId)}/download`, {
params: { warehouseId },
responseType: 'blob',
})
}
export async function completeLoadingDeclaration(planId: string, warehouseId: string, expectedVersion: number, operationReason?: string | null) {
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/complete`, { expectedVersion, operationReason }, { params: { warehouseId }, headers: { 'Idempotency-Key': `loading-customs-complete-${planId}-${Date.now()}` } })
return normalizeDocument(unwrapApiResponse(response))
}
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
`${basePath}/loading-lists`,
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
@@ -13,6 +14,9 @@ vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
})
vi.mock('@/modules/finance', () => ({
getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
}))
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
@@ -23,7 +27,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers }, global: { plugins: [pinia, ElementPlus] } })
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
await flushPromises(); return wrapper
}
@@ -33,6 +37,9 @@ beforeEach(() => {
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never)
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
document.body.innerHTML = ''
})
@@ -82,6 +89,10 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain("cost.supplierName || '服务端未返回'")
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
expect(panel).toContain("orderNumbers.get(row.orderId) || '订单编码待补充'")
expect(panel).toContain("inboundCodes.get(row.inboundId) || '入仓码待补充'")
expect(panel).not.toContain("prop: 'orderId', label: '订单'")
expect(panel).not.toContain("prop: 'inboundId', label: '入库单'")
})
it('clears the previous customer context and ignores a late response', async () => {
@@ -140,6 +151,30 @@ describe('ContainerFeesPanel contract', () => {
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
})
it('hides adjustment and void actions for settled fees', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never])
vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never)
const wrapper = await mountPanel([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
'platform:finance:settlement-ar:list',
])
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1')
})
it('hides adjustment and void actions for directly paid payable fees', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review'])
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
it('keeps receivable usable when supplier permission is missing', async () => {
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
@@ -9,12 +9,19 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
const props = defineProps<{
planId: string
containerBookingId: string
customers: Array<{ accountId: string; customerName: string | null }>
businessReferences: Array<{ orderId: string; orderNo: string; inboundId: string; inboundCode: string | null }>
}>()
const access = useAccessPreviewStore()
const accountId = ref('')
const fees = ref<ContainerFeeResource[]>([])
const settledFeeIds = ref<Set<string>>(new Set())
const feeItems = ref<ApplicableFeeItem[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const costs = ref<ContainerCostDetail[]>([])
@@ -54,6 +61,8 @@ const customerOptions = computed(() => {
return [...unique].map(([value, label]) => ({ value, label }))
})
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo])))
const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode])))
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
@@ -71,8 +80,8 @@ const columns: DataTableColumn[] = [
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
const allocationColumns: DataTableColumn[] = [
{ key: 'orderId', prop: 'orderId', label: '订单', fixed: 'left', minWidth: 150 },
{ key: 'inboundId', prop: 'inboundId', label: '入库单', minWidth: 150 },
{ key: 'orderNo', label: '订单编码', fixed: 'left', minWidth: 170, slotName: 'orderNo' },
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
@@ -98,6 +107,9 @@ function paidSummary(item: ContainerFeeResource) {
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
}
function isSettled(item: ContainerFeeResource) {
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
}
async function selectVoucher(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -153,6 +165,8 @@ async function load() {
const feeRows = await listContainerFees(props.planId, requestedAccountId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
fees.value = feeRows
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
costs.value = []
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
@@ -166,6 +180,28 @@ async function load() {
finally { if (sequence === loadSequence) loading.value = false }
}
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
settledFeeIds.value = settled
const listRequests: Array<Promise<Awaited<ReturnType<typeof listFinanceReceivableSettlements>> | Awaited<ReturnType<typeof listFinancePayableSettlements>>>> = []
if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId))
if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId))
if (!listRequests.length) return
const settlementResults = await Promise.allSettled(listRequests)
const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled'
? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID')
: [])
const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id)))
for (const result of detailResults) {
if (result.status !== 'fulfilled') continue
for (const line of result.value.lines) {
if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId)
}
}
if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled
}
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
async function save() {
@@ -230,7 +266,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
function clearCustomerContext() {
loadSequence += 1
supplierLoadSequence += 1
fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
resetForm()
}
@@ -263,7 +299,7 @@ onMounted(() => void load())
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
@@ -271,7 +307,7 @@ onMounted(() => void load())
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft, Check, FileUp, Pencil } from '@lucide/vue'
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -17,12 +17,12 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
canEditTransport?: boolean
transportNote?: string
confirmSaving?: boolean
canManageCustoms?: boolean
customsSaving?: boolean
}>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: [] }>()
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
const baseItemColumns: DataTableColumn[] = [
@@ -49,7 +49,6 @@ const baseItemColumns: DataTableColumn[] = [
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
@@ -84,6 +83,12 @@ const feeCustomers = computed(() => {
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
orderId: item.orderId,
orderNo: item.orderNo,
inboundId: item.inboundId,
inboundCode: item.inboundCode,
})))
const title = computed(() => {
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props.mode === 'preload'
@@ -118,18 +123,13 @@ function formatNumber(value?: number | null, digits = 3) {
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</p>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<template v-if="canEditItemRemark">
<el-button @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />纠正集装箱号 / 航次</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</template>
<template v-else-if="mode === 'confirmed'">
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
<el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button>
<StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" />
</template>
</div>
</header>
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
@@ -158,6 +158,7 @@ function formatNumber(value?: number | null, digits = 3) {
<strong>暂不能确认预装:</strong>
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
</div>
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
<section class="detail-section">
<DataTable
@@ -181,11 +182,10 @@ function formatNumber(value?: number | null, digits = 3) {
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
</DataTable>
</section>
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
@@ -40,7 +40,7 @@ describe('LoadingListsView', () => {
expect(source).toContain('LoadErrorState')
expect(source).toContain('ApiErrorMeta')
expect(preloadDetailSource).toContain('AppDialog')
expect(confirmedDetailSource).toContain('AppDialog')
expect(confirmedDetailSource).not.toContain('AppDialog')
expect(detailSource).not.toContain('AppDrawer')
expect(detailSource).toContain('ContainerFeesPanel')
})
@@ -81,8 +81,8 @@ describe('LoadingListsView', () => {
it('only exposes transport editing for LOADING preload details', () => {
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
expect(detailSource).toContain('const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)')
expect(detailSource).toContain('报关资料')
expect(detailSource).toContain('完成报关')
expect(detailSource).not.toContain('报关资料')
expect(detailSource).not.toContain('完成报关')
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
})
@@ -96,8 +96,9 @@ describe('LoadingListsView', () => {
it('keeps transport editing as a detail-page action', () => {
expect(source).not.toContain('openEditFromRow(row)')
expect(source).not.toContain('编辑运输信息')
expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}")
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')")
expect(detailSource).toContain('纠正集装箱号 / 航次')
expect(detailSource).toContain('v-if="canEditTransport"')
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')")
})
it('refreshes the detail version on A0409 while preserving transport input', () => {
@@ -168,7 +169,6 @@ describe('LoadingListsView', () => {
'customerRemark',
'internalRemark',
'warehouseRemark',
'customsDocument',
]
let previous = detailSource.indexOf("key: 'customerName'")
for (const field of fields) {
@@ -234,15 +234,10 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
})
it('uploads declaration documents from a file picker and freezes them after completion', () => {
expect(confirmedDetailSource).toContain('<el-upload :auto-upload="false" :limit="1"')
expect(confirmedDetailSource).toContain('@confirm="uploadCustomsDocument"')
expect(confirmedDetailSource).toContain('uploadLoadingDeclaration(')
expect(confirmedDetailSource).toContain('getLoadingDeclaration(planId.value, warehouseId.value)')
expect(confirmedDetailSource).toContain("details.code === 'A0404'")
expect(detailSource).toContain("canManageCustoms && !detail.customsDocument?.completedAt")
expect(confirmedDetailSource).not.toContain('文件 ID')
expect(confirmedDetailSource).not.toContain('fileId')
expect(confirmedDetailSource).not.toContain('上传入口待')
it('does not retain the retired loading-list customs entry', () => {
expect(confirmedDetailSource).not.toContain('LoadingDeclaration')
expect(confirmedDetailSource).not.toContain('customsDocument')
expect(detailSource).not.toContain('customsDocument')
expect(detailSource).not.toContain('报关资料')
})
})
+1
View File
@@ -1,3 +1,4 @@
export * from './api/platformLoadingLists'
export { loadingListRoutes } from './routes'
export { loadingListStatusLabel } from './status'
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
@@ -1,37 +1,23 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FileUp } from '@lucide/vue'
import { ElMessage, 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'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import {
completeLoadingDeclaration,
getConfirmedLoadingList,
getLoadingDeclaration,
uploadLoadingDeclaration,
type ConfirmedSummary,
type LoadingDetail,
} from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const loading = ref(false)
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const customsDialogOpen = ref(false)
const customsFile = ref<File | null>(null)
const customsSaving = ref(false)
const customsError = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
async function loadDetail() {
const sequence = ++loadSequence
@@ -44,20 +30,13 @@ async function loadDetail() {
loading.value = true
error.value = null
try {
const [result, customsDocument] = await Promise.all([
getConfirmedLoadingList(planId.value, warehouseId.value),
getLoadingDeclaration(planId.value, warehouseId.value).catch((cause) => {
const details = getApiErrorDetails(cause)
if (details.code === 'A0404') return null
throw cause
}),
])
const result = await getConfirmedLoadingList(planId.value, warehouseId.value)
if (sequence !== loadSequence) return
if (result.summary.status !== 'COMPLETED') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
return
}
detail.value = { ...result, customsDocument }
detail.value = result
} catch (cause) {
if (sequence !== loadSequence) return
detail.value = null
@@ -72,66 +51,6 @@ function goBack() {
else void router.push({ name: 'loading-lists' })
}
function openCustomsDocument() {
customsFile.value = null
customsError.value = null
customsDialogOpen.value = true
}
function selectCustomsFile(uploadFile: UploadFile) {
customsFile.value = uploadFile.raw ?? null
customsError.value = null
}
function removeCustomsFile() {
customsFile.value = null
}
async function uploadCustomsDocument() {
if (!detail.value || !customsFile.value) {
customsError.value = { code: 'FILE_REQUIRED', message: '请选择报关资料文件' }
return
}
customsSaving.value = true
customsError.value = null
try {
detail.value.customsDocument = await uploadLoadingDeclaration(
planId.value,
warehouseId.value,
customsFile.value,
detail.value.customsDocument?.version || 0,
'上传装柜清单报关资料',
)
customsFile.value = null
customsDialogOpen.value = false
ElMessage.success('报关资料已上传')
} catch (cause) {
customsError.value = getApiErrorDetails(cause, '报关资料上传失败')
} finally {
customsSaving.value = false
}
}
async function completeCustomsDocument() {
if (!detail.value?.customsDocument) return
customsSaving.value = true
customsError.value = null
try {
detail.value.customsDocument = await completeLoadingDeclaration(
planId.value,
warehouseId.value,
detail.value.customsDocument.version,
'确认装柜清单报关完成',
)
ElMessage.success('已完成报关')
} catch (cause) {
customsError.value = getApiErrorDetails(cause, '报关完成失败')
ElMessage.error(customsError.value.message)
} finally {
customsSaving.value = false
}
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
</script>
@@ -139,40 +58,16 @@ watch([planId, warehouseId], () => void loadDetail())
<template>
<AdminPage>
<div class="confirmed-loading-detail-page">
<OperationErrorToast :details="customsError" />
<LoadingListDetailDrawer
mode="confirmed"
:loading="loading"
:detail="detail"
:error="error"
:can-edit="false"
:can-manage-customs="canManageCustoms"
:customs-saving="customsSaving"
@back="goBack"
@retry="loadDetail"
@attach-customs="openCustomsDocument"
@complete-customs="completeCustomsDocument"
/>
</div>
<AppDialog
v-model="customsDialogOpen"
title="装柜清单报关资料"
width="min(520px, calc(100vw - 28px))"
confirm-text="上传资料"
:confirm-loading="customsSaving"
:confirm-disabled="!customsFile"
@confirm="uploadCustomsDocument"
>
<el-form label-position="top">
<el-form-item label="报关资料">
<el-upload :auto-upload="false" :limit="1" :on-change="selectCustomsFile" :on-remove="removeCustomsFile">
<el-button><FileUp :size="16" />选择文件</el-button>
<template #tip><div class="el-upload__tip">{{ detail?.customsDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的装柜清单报关资料。' }}</div></template>
</el-upload>
</el-form-item>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -0,0 +1,136 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessage } from 'element-plus'
import { ApiResponseError, http } from '@/shared/api/http'
import { getPreloadPlan } from '../api/platformLoadingLists'
import PreloadPlanDetailView from './PreloadPlanDetailView.vue'
const routerReplace = vi.hoisted(() => vi.fn())
const removeWorkspaceTabs = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { planId: '1900000000000000001' }, query: { warehouseId: '1900000000000000002' } }),
useRouter: () => ({ replace: routerReplace, push: vi.fn(), back: vi.fn() }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: removeWorkspaceTabs }) }))
vi.mock('../api/platformLoadingLists', () => ({
confirmPreloadPlan: vi.fn(),
getPreloadPlan: vi.fn(),
updateLoadingItemWarehouseRemark: vi.fn(),
updatePreloadTransport: vi.fn(),
}))
async function mountView() {
const wrapper = shallowMount(PreloadPlanDetailView, {
global: {
stubs: {
AdminPage: { template: '<main><slot /></main>' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['error', 'loading'], template: '<section />' },
OperationErrorToast: true,
AppDialog: true,
},
},
})
await flushPromises()
return wrapper
}
function drawerError(wrapper: Awaited<ReturnType<typeof mountView>>) {
return wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('error')
}
function lifecycle(detailResource: 'PRELOAD' | 'LOADING_LIST', planStatus: string) {
return { code: '200', message: 'success', data: { planId: '1900000000000000001', detailResource, planStatus } }
}
describe('PreloadPlanDetailView lifecycle handoff', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
routerReplace.mockResolvedValue(undefined)
})
it('replaces the current tab after authoritative lifecycle resolution', async () => {
vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'COMPLETED'))
const message = vi.spyOn(ElMessage, 'success').mockImplementation(() => undefined as never)
const wrapper = await mountView()
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/lifecycle',
{ params: { warehouseId: '1900000000000000002' } },
)
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(routerReplace).toHaveBeenCalledTimes(1)
expect(routerReplace).toHaveBeenCalledWith({
name: 'loading-list-detail',
params: { planId: '1900000000000000001' },
query: { warehouseId: '1900000000000000002' },
})
expect(removeWorkspaceTabs).toHaveBeenCalledWith(['preload-plan-detail'])
expect(routerReplace.mock.invocationCallOrder[0]).toBeLessThan(removeWorkspaceTabs.mock.invocationCallOrder[0]!)
expect(message).toHaveBeenCalledWith('装柜已完成,已切换至装柜清单详情')
expect(drawerError(wrapper)).toBeNull()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('loading')).toBe(false)
})
it('loads preload detail only while the lifecycle remains PRELOAD', async () => {
vi.spyOn(http, 'get').mockResolvedValue(lifecycle('PRELOAD', 'LOADING'))
vi.mocked(getPreloadPlan).mockResolvedValue({
summary: {
status: 'LOADING', containerNo: null,
readiness: { preloadPushed: true, pdaTaskStatus: null },
},
items: [], histories: [],
} as never)
const wrapper = await mountView()
expect(getPreloadPlan).toHaveBeenCalledWith('1900000000000000001', '1900000000000000002')
expect(routerReplace).not.toHaveBeenCalled()
expect(removeWorkspaceTabs).not.toHaveBeenCalled()
expect(drawerError(wrapper)).toBeNull()
})
it('shows A0404 only when lifecycle resolution confirms the plan is absent', async () => {
vi.spyOn(http, 'get').mockRejectedValue(new ApiResponseError('装柜计划不存在', 'A0404', 404, undefined, 'request-lifecycle', 'trace-lifecycle'))
const wrapper = await mountView()
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(routerReplace).not.toHaveBeenCalled()
expect(drawerError(wrapper)).toMatchObject({ code: 'A0404', requestId: 'request-lifecycle', traceId: 'trace-lifecycle', message: '装柜计划不存在' })
})
it.each([
['A0403', 403, 'request-forbidden', 'trace-forbidden'],
['S0503', 503, 'request-unavailable', 'trace-unavailable'],
])('does not swallow a lifecycle %s error', async (code, status, requestId, traceId) => {
vi.spyOn(http, 'get').mockRejectedValue(new ApiResponseError('装柜计划解析失败', code, status, undefined, requestId, traceId))
const wrapper = await mountView()
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(routerReplace).not.toHaveBeenCalled()
expect(drawerError(wrapper)).toMatchObject({ code, requestId, traceId })
})
it('surfaces a network failure without guessing from another detail endpoint', async () => {
vi.spyOn(http, 'get').mockRejectedValue(new Error('Network Error'))
const wrapper = await mountView()
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(routerReplace).not.toHaveBeenCalled()
expect(drawerError(wrapper)).toMatchObject({ message: '预装清单详情加载失败,请稍后重试' })
})
it('does not redirect for an internally inconsistent lifecycle payload', async () => {
vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'LOADING'))
const wrapper = await mountView()
expect(routerReplace).not.toHaveBeenCalled()
expect(drawerError(wrapper)).toMatchObject({ code: 'MODE_STATUS_MISMATCH' })
})
})
@@ -20,6 +20,18 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain('MODE_STATUS_MISMATCH')
})
it('switches a completed preload lifecycle to the confirmed detail without swallowing unrelated errors', () => {
expect(source).toContain('/loading-plans/${encodeURIComponent(planId.value)}/lifecycle')
expect(source).toContain("lifecycle.detailResource === 'LOADING_LIST'")
expect(source).toContain("lifecycle.planStatus !== 'COMPLETED'")
expect(source).toContain("router.replace({ name: 'loading-list-detail'")
expect(source).toContain("workspace.removeByIds(['preload-plan-detail'])")
expect(source).toContain('装柜已完成,已切换至装柜清单详情')
expect(source).not.toContain("preloadError.code !== 'A0404'")
expect(source).not.toContain('getConfirmedLoadingList')
expect(source).not.toContain("router.push({ name: 'loading-list-detail'")
})
it('uses the real optimistic-lock transport endpoint without inventing note writes', () => {
expect(source).toContain('updatePreloadTransport')
expect(source).toContain('expectedVersion: detail.value.summary.version')
@@ -49,11 +61,20 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain(':confirm-loading="remarkSaving"')
})
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
it('keeps preload as a correction-only entry before PDA claim and does not imply that preload directly opens PDA', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(source).toContain('成功后由服务端按真实条件创建唯一 PDA 任务')
expect(source).toContain('当前页面不会伪造 PDA 成功')
expect(source).toContain("summary.status === 'EDITING'")
expect(source).toContain("(!pdaStatus || pdaStatus === 'PENDING')")
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')")
expect(source).toContain("{ required: true, message: '请输入操作原因'")
expect(source).toContain("String(value || '').trim().toUpperCase()")
expect(source).toContain('operationReason: editForm.operationReason.trim()')
expect(detailSource).toContain('v-if="canEditTransport"')
expect(detailSource).not.toContain("preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(source).toContain('由服务端按真实条件创建唯一 PDA 任务')
expect(source).toContain('真实集装箱号未录入时不能确认')
expect(source).toContain('PDA 领取或作业开始后集装箱号只读')
})
it('uses a flat blocking note instead of repeated colored page alerts', () => {
@@ -7,14 +7,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const workspace = useWorkspaceTabsStore()
const loading = ref(false)
const detail = ref<LoadingDetail<PreloadSummary> | null>(null)
const error = ref<ApiErrorDetails | null>(null)
@@ -36,17 +38,44 @@ const remarkOperationId = ref('')
let loadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' }
async function resolveLoadingPlanLifecycle() {
const response = await http.get<ApiEnvelope<LoadingPlanLifecycle>, ApiEnvelope<LoadingPlanLifecycle>>(
`/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId.value)}/lifecycle`,
{ params: { warehouseId: warehouseId.value } },
)
return unwrapApiResponse(response)
}
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')
const canCorrectTransport = computed(() => {
const summary = detail.value?.summary
const pdaStatus = summary?.readiness.pdaTaskStatus
return Boolean(canEdit.value && summary?.containerNo && summary.status === 'EDITING'
&& summary.readiness.preloadPushed && (!pdaStatus || pdaStatus === 'PENDING'))
})
const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary
if (!summary?.containerNo) return '真实集装箱号尚未录入;请返回货柜管理,通过“关联已放舱订舱”完成初次录入。'
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,保存时必须填写操作原因。'
})
const editDialogTitle = computed(() => '纠正集装箱号 / 航次')
const editRules: FormRules = {
containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' },
],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
operationReason: [
{ required: true, message: '请输入操作原因', trigger: 'blur' },
{ max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' },
],
}
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
async function loadDetail() {
const sequence = ++loadSequence
detail.value = null
@@ -58,6 +87,22 @@ async function loadDetail() {
loading.value = true
error.value = null
try {
const lifecycle = await resolveLoadingPlanLifecycle()
if (sequence !== loadSequence) return
if (lifecycle.detailResource === 'LOADING_LIST') {
if (lifecycle.planStatus !== 'COMPLETED') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '装柜计划阶段异常,暂时无法切换详情,请稍后重试。' }
return
}
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query: { warehouseId: warehouseId.value } })
workspace.removeByIds(['preload-plan-detail'])
if (sequence === loadSequence) ElMessage.success('装柜已完成,已切换至装柜清单详情')
return
}
if (lifecycle.detailResource !== 'PRELOAD') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '无法识别当前装柜计划阶段,请稍后重试。' }
return
}
const result = await getPreloadPlan(planId.value, warehouseId.value)
if (sequence !== loadSequence) return
if (!['EDITING', 'LOADING'].includes(result.summary.status)) {
@@ -80,7 +125,7 @@ function goBack() {
}
function openEdit() {
if (!detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
if (!detail.value || !canCorrectTransport.value) return
editForm.containerNo = detail.value.summary.containerNo || ''
editForm.voyageNo = detail.value.summary.voyageNo || ''
editForm.operationReason = ''
@@ -89,14 +134,15 @@ function openEdit() {
}
async function saveTransport() {
if (!detail.value || !(await editFormRef.value?.validate().catch(() => false))) return
if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return
editForm.containerNo = normalizedContainerNo(editForm.containerNo)
editSaving.value = true
editError.value = null
try {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo.trim(),
containerNo: editForm.containerNo,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
operationReason: editForm.operationReason.trim(),
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
@@ -196,14 +242,14 @@ watch([planId, warehouseId], () => void loadDetail())
<AdminPage>
<div class="preload-detail-page">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
<el-form-item label="操作原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="必填,将记录在操作历史中" /></el-form-item>
</el-form>
</AppDialog>
@@ -219,7 +265,7 @@ watch([planId, warehouseId], () => void loadDetail())
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="系统会原子确认本计划包含的全部入库预装票据;成功后由服务端按真实条件创建唯一 PDA 任务,当前页面不会伪造 PDA 成功。" />
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="真实集装箱号未录入时不能确认;PDA 领取或作业开始后集装箱号只读。系统会原子确认本计划包含的全部入库预装票据,并由服务端按真实条件创建唯一 PDA 任务;当前页面不会伪造 PDA 成功。" />
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
@@ -21,6 +21,7 @@ const payload: SavePlatformPartnerRequest = {
partnerName: '测试往来单位',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '测试联系人',
contactPhone: '13800000000',
contactEmail: null,
@@ -66,7 +67,7 @@ describe('platform partner API', () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: {
id: '19001', tenantId: 0, dataScopeId: 0, partnerCode: 'CUS-001', partnerName: '测试单位',
partnerType: 'CONSIGNEE', contactName: '张三', contactPhone: '13800000000', contactEmail: null,
partnerType: 'CONSIGNEE', carrierCode: null, contactName: '张三', contactPhone: '13800000000', contactEmail: null,
countryCode: 'CN', province: null, city: '深圳市', district: null, addressLine: '测试地址',
postalCode: null, status: 'ACTIVE',
} })
@@ -2,6 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type PlatformPartnerType = 'CARRIER' | 'SHIPPER' | 'CONSIGNEE'
export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE'
export type PlatformPartnerCarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
export interface PlatformPartnerResource {
id: number | string
@@ -11,6 +12,7 @@ export interface PlatformPartnerResource {
partnerName: string
partnerType: PlatformPartnerType
types: PlatformPartnerType[]
carrierCode: PlatformPartnerCarrierCode | null
contactName: string
contactPhone: string
contactEmail: string | null
@@ -54,6 +56,7 @@ export interface SavePlatformPartnerRequest {
partnerName: string
partnerType: PlatformPartnerType
types: PlatformPartnerType[]
carrierCode: PlatformPartnerCarrierCode | null
contactName: string
contactPhone: string
contactEmail: string | null
@@ -89,6 +92,7 @@ function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerRes
...resource,
dataScopeId: resource.dataScopeId ?? resource.tenantId,
types: resource.types?.length ? resource.types : [resource.partnerType],
carrierCode: resource.carrierCode ?? null,
remark: resource.remark ?? null,
}
}
@@ -52,6 +52,7 @@ const partner: PlatformPartnerResource = {
partnerName: '深圳示例贸易有限公司',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '张三',
contactPhone: '13800138000',
contactEmail: 'contact@example.com',
@@ -159,6 +160,25 @@ describe('PartnersView real API integration', () => {
expect(document.body.textContent).not.toContain('供应商')
})
it('uses the fixed carrier-code selector when the unit is a shipping company', async () => {
const wrapper = await mountView()
await wrapper.findAll('button').find((button) => button.text().trim() === '新增往来单位')?.trigger('click')
await flushPromises()
const typeSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('multiple'))
typeSelect!.vm.$emit('update:modelValue', ['CARRIER'])
await flushPromises()
expect(document.body.textContent).toContain('船司标准编码')
const carrierValues = new Set(['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER'])
const carrierOptions = wrapper.findAllComponents({ name: 'ElOption' }).filter((option) => carrierValues.has(String(option.props('value'))))
expect(carrierOptions.map((option) => option.props('value'))).toEqual(['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER'])
expect(carrierOptions.map((option) => option.props('label'))).toContain('美森 (MATSON)')
expect(source).toContain('placeholder="请选择船司标准编码"')
expect(source).toContain("MATSON: '美森'")
expect(source).toContain("OTHER: '其他'")
expect(source).toContain("carrierCode: types.includes('CARRIER') ? form.carrierCode : null")
})
it('renders a padded detail layout without an empty data-scope cell', async () => {
const wrapper = await mountView()
const detailButton = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
@@ -23,6 +23,7 @@ import {
listPlatformPartners,
updatePlatformPartner,
type PlatformPartnerResource,
type PlatformPartnerCarrierCode,
type PlatformPartnerStatus,
type PlatformPartnerType,
type SavePlatformPartnerRequest,
@@ -94,6 +95,7 @@ const emptyForm = (): PartnerForm => ({
partnerName: '',
partnerType: 'SHIPPER',
types: ['SHIPPER'],
carrierCode: null,
contactName: '',
contactPhone: '',
contactEmail: null,
@@ -121,6 +123,7 @@ const rules: FormRules<PartnerForm> = {
partnerCode: [{ required: true, whitespace: true, message: '请输入往来单位编码', trigger: 'blur' }],
partnerName: [{ required: true, whitespace: true, message: '请输入往来单位名称', trigger: 'blur' }],
types: [{ required: true, type: 'array', min: 1, message: '请至少选择一个单位类型', trigger: 'change' }],
carrierCode: [{ validator: (_rule, value, callback) => form.types.includes('CARRIER') && !value ? callback(new Error('请选择船司标准编码')) : callback(), trigger: 'change' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
@@ -139,6 +142,11 @@ const typeLabels: Record<PlatformPartnerType, string> = {
CONSIGNEE: '收货单位',
}
const typeOptions = Object.entries(typeLabels).map(([value, label]) => ({ value: value as PlatformPartnerType, label }))
const carrierCodeLabels: Record<PlatformPartnerCarrierCode, string> = {
MATSON: '美森', SML: 'SML', ZIM: '以星', COSCO: '中远', EMC: '长荣', HEDE: '合德', OTHER: '其他',
}
const carrierCodeOptions = (Object.entries(carrierCodeLabels) as [PlatformPartnerCarrierCode, string][]).map(([value, label]) => ({ value, label: `${label} (${value})` }))
function formatCarrierCode(value?: PlatformPartnerCarrierCode | null) { return value ? `${carrierCodeLabels[value]} (${value})` : '—' }
function partnerTypeLabel(type: PlatformPartnerType) {
return typeLabels[type]
}
@@ -225,6 +233,7 @@ function openEdit(row: PlatformPartnerResource) {
partnerName: row.partnerName,
partnerType: row.types[0],
types: [...row.types],
carrierCode: row.carrierCode,
contactName: row.contactName,
contactPhone: row.contactPhone,
contactEmail: row.contactEmail,
@@ -249,6 +258,7 @@ function savePayload(): SavePlatformPartnerRequest {
partnerName: form.partnerName.trim(),
partnerType: types[0],
types,
carrierCode: types.includes('CARRIER') ? form.carrierCode : null,
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
contactEmail: nullable(form.contactEmail),
@@ -342,6 +352,11 @@ function formatUpdatedAt(value?: string | null) {
}
watch([page, pageSize], () => { void loadPartners() })
watch(() => form.types.includes('CARRIER'), (carrier) => {
if (carrier) return
form.carrierCode = null
formRef.value?.clearValidate('carrierCode')
})
watch(detailOpen, (open) => {
if (open) return
detailSequence += 1
@@ -415,6 +430,7 @@ onMounted(() => {
<el-form-item label="单位名称" prop="partnerName" required><el-input v-model="form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></el-form-item>
<el-form-item label="单位类型" prop="types" required><el-select v-model="form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="form.types.includes('CARRIER')" label="船司标准编码" prop="carrierCode" required><el-select v-model="form.carrierCode" placeholder="请选择船司标准编码" style="width:100%"><el-option v-for="option in carrierCodeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
</div></FormSection>
<FormSection title="联系信息"><div class="form-grid two-cols">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
@@ -446,6 +462,7 @@ onMounted(() => {
<div><dt>单位名称</dt><dd>{{ detail.partnerName }}</dd></div>
<div><dt>状态</dt><dd><el-tag :type="detail.status === 'ACTIVE' ? 'success' : 'info'">{{ detail.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></dd></div>
<div><dt>单位类型</dt><dd class="partner-types"><el-tag v-for="type in detail.types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></dd></div>
<div v-if="detail.types.includes('CARRIER')"><dt>船司标准编码</dt><dd>{{ formatCarrierCode(detail.carrierCode) }}</dd></div>
</dl></FormSection>
<FormSection title="联系与地址"><dl class="detail-grid">
<div><dt>联系人</dt><dd>{{ detail.contactName }}</dd></div><div><dt>联系电话</dt><dd>{{ detail.contactPhone }}</dd></div>
@@ -4,9 +4,14 @@ import {
cancelTask,
changeTemplateStatus,
createNotification,
getMyNotification,
getMyUnreadNotificationCount,
getTask,
listMyNotifications,
listTasks,
listTemplates,
markAllMyNotificationsRead,
markMyNotificationRead,
retryTask,
updateTemplate,
} from './notificationCenter'
@@ -62,4 +67,24 @@ describe('notificationCenter API', () => {
expect(result.recipients[0]?.id).toBe('21')
expect(result.lastError).toBe('gateway timeout')
})
it('uses platform inbox paths and normalizes inbox ids', async () => {
const inbox = { id: 14, category: 'ORDER', title: 'order notice', summary: 'summary', status: 'UNREAD' as const, sentAt: '2026-08-27T10:00:00', readAt: null }
const detail = { ...inbox, body: 'body', sourceType: null, sourceId: null }
vi.mocked(http.get)
.mockResolvedValueOnce(envelope({ records: [inbox], page: 1, size: 20, total: 1, pages: 1 }))
.mockResolvedValueOnce(envelope(detail))
.mockResolvedValueOnce(envelope({ unreadCount: 1 }))
vi.mocked(http.put)
.mockResolvedValueOnce(envelope(detail))
.mockResolvedValueOnce(envelope({ updatedCount: 1 }))
const result = await listMyNotifications({ page: 1, size: 20, status: 'UNREAD' })
await getMyNotification('14')
await markMyNotificationRead('14')
await markAllMyNotificationsRead()
expect(await getMyUnreadNotificationCount()).toBe(1)
expect(result.records[0]?.id).toBe('14')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/14/read')
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
})
})
@@ -1,11 +1,13 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
type RawId = string | number
type RawPage<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
type RawTaskDetail = Omit<NotificationTaskDetail, 'id' | 'recipients'> & { id: RawId; recipients: Array<Omit<NotificationTaskDetail['recipients'][number], 'id'> & { id: RawId }> }
type RawInbox = Omit<NotificationInboxItem, 'id'> & { id: RawId }
type RawInboxDetail = Omit<NotificationInboxDetail, 'id'> & { id: RawId }
const notificationPath = '/platform/v1/notifications'
const templatePath = '/platform/v1/notification-templates'
@@ -15,6 +17,8 @@ const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
const taskDetail = (value: RawTaskDetail): NotificationTaskDetail => ({ ...value, id: id(value.id), recipients: value.recipients.map((recipient) => ({ ...recipient, id: id(recipient.id) })) })
const inbox = (value: RawInbox): NotificationInboxItem => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null })
const inboxDetail = (value: RawInboxDetail): NotificationInboxDetail => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null, body: value.body ?? '', sourceType: value.sourceType ?? null, sourceId: value.sourceId ?? null })
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(notificationPath, payload); return task(unwrapApiResponse(response)) }
export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
@@ -25,3 +29,26 @@ export async function listTasks(query: { page: number; size: number; taskNo?: st
export async function getTask(taskId: string) { const response = await http.get<ApiEnvelope<RawTaskDetail>, ApiEnvelope<RawTaskDetail>>(`${taskPath}/${encodeURIComponent(taskId)}`); return taskDetail(unwrapApiResponse(response)) }
export async function cancelTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
export async function retryTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
const inboxPath = '/platform/v1/notifications'
export async function listMyNotifications(query: { page: number; size: number; status?: NotificationInboxStatus; category?: string }) {
const response = await http.get<ApiEnvelope<RawPage<RawInbox>>, ApiEnvelope<RawPage<RawInbox>>>(inboxPath, { params: query })
const data = unwrapApiResponse(response)
return page({ ...data, records: data.records.map(inbox) })
}
export async function getMyNotification(notificationId: string) {
const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}`)
return inboxDetail(unwrapApiResponse(response))
}
export async function markMyNotificationRead(notificationId: string) {
const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}/read`)
return inboxDetail(unwrapApiResponse(response))
}
export async function markAllMyNotificationsRead() {
const response = await http.put<ApiEnvelope<{ updatedCount: number }>, ApiEnvelope<{ updatedCount: number }>>(`${inboxPath}/read-all`)
return unwrapApiResponse(response)
}
export async function getMyUnreadNotificationCount() {
const response = await http.get<ApiEnvelope<{ unreadCount: number }>, ApiEnvelope<{ unreadCount: number }>>(`${inboxPath}/unread-count`)
return unwrapApiResponse(response).unreadCount
}
@@ -18,17 +18,28 @@ const recipientIdsText = ref('')
const variables = reactive<Record<string, string>>({})
const form = reactive({ tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE' as RecipientType, roleCode: '', scheduledAt: '' })
const selectedTemplate = computed(() => props.templates.find((item) => item.templateCode === form.templateCode) ?? null)
const scheduled = computed({ get: () => Boolean(form.scheduledAt), set: (value) => { if (!value) form.scheduledAt = '' } })
function toDateTimeValue(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const scheduled = computed({
get: () => Boolean(form.scheduledAt),
set: (value: boolean) => {
if (value && !form.scheduledAt) form.scheduledAt = toDateTimeValue(new Date(Date.now() + 5 * 60 * 1000))
if (!value) form.scheduledAt = ''
},
})
const needsIds = computed(() => ['USER', 'MEMBERSHIP'].includes(form.recipientType))
const needsRole = computed(() => ['TENANT_ROLE', 'PLATFORM_ROLE'].includes(form.recipientType))
const allowedRecipients = computed(() => scope.value === 'platform'
? [{ label: '指定平台用户', value: 'USER' }, { label: '平台角色', value: 'PLATFORM_ROLE' }]
: [{ label: '指定企业成员', value: 'MEMBERSHIP' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
: [{ label: '指定企业成员', value: 'USER' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
watch(scope, (value) => { form.tenantId = value === 'platform' ? '0' : ''; form.recipientType = value === 'platform' ? 'PLATFORM_ROLE' : 'TENANT_ALL'; form.roleCode = ''; recipientIdsText.value = '' })
watch(selectedTemplate, (value) => { for (const key of Object.keys(variables)) delete variables[key]; for (const key of value?.variableWhitelist ?? []) variables[key] = '' })
function reset() { scope.value = 'platform'; Object.assign(form, { tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE', roleCode: '', scheduledAt: '' }); recipientIdsText.value = ''; error.value = null; for (const key of Object.keys(variables)) delete variables[key] }
const MAX_LONG_ID = 9_223_372_036_854_775_807n
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
async function submit() {
@@ -36,7 +47,7 @@ async function submit() {
const recipientIds = parseIds()
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
if (scope.value === 'customer' && !form.tenantId) return ElMessage.warning('请选择目标企业')
if (needsIds.value && (!recipientIds.length || recipientIds.some((value) => !/^\d+$/.test(value)))) return ElMessage.warning('请输入有效的数字用户或成员 ID')
if (needsIds.value && (!recipientIds.length || recipientIds.length > 1000 || recipientIds.some((value) => !/^\d+$/.test(value) || BigInt(value) > MAX_LONG_ID))) return ElMessage.warning('请输入有效的数字用户 ID,单次最多 1000 个且不能超过 19 位')
if (needsRole.value && !form.roleCode.trim()) return ElMessage.warning('请输入角色编码')
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
submitting.value = true
@@ -56,7 +67,7 @@ async function submit() {
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
<el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item>
<div class="form-grid"><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="needsRole" label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></div>
<el-form-item v-if="needsIds" label="接收人 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个数字 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
<el-form-item v-if="needsIds" label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
<section v-if="selectedTemplate?.variableWhitelist.length" class="variables"><strong>模板变量</strong><el-form-item v-for="key in selectedTemplate.variableWhitelist" :key="key" :label="key" required><el-input v-model="variables[key]" maxlength="2000" /></el-form-item></section>
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item>
</el-form>
@@ -61,6 +61,13 @@ describe('notification center contract UI', () => {
expect(wrapper.text()).not.toContain('存为草稿')
})
it('keeps the scheduled-send switch actionable and clears its date when disabled', () => {
const composer = source('./components/MessageComposerDialog.vue')
expect(composer).toContain('if (value && !form.scheduledAt)')
expect(composer).toContain('if (!value) form.scheduledAt = \'\'')
expect(composer).toContain('Date.now() + 5 * 60 * 1000')
})
it('uses the prototype editor width and exposes the real save command', () => {
const wrapper = mount(TemplateEditorDialog, {
props: { modelValue: true },
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bell, CheckCheck, Eye, RefreshCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getMyNotification, getMyUnreadNotificationCount, listMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notificationCenter'
import { notificationCategoryLabel } from '../presentation'
import type { NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, PageResult } from '../types'
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const result = ref<PageResult<NotificationInboxItem>>({ records: [], page: 1, size: 20, total: 0, pages: 0 })
const filters = ref<QueryModel>({ status: '', category: '' })
const unreadCount = ref<number | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<NotificationInboxDetail | null>(null)
const access = useAccessPreviewStore()
const canRead = computed(() => access.can('platform:notification:read'))
const canReadAll = computed(() => access.can('platform:notification:read-all'))
const queryFields: QueryField[] = [
{ key: 'status', type: 'select', label: '消息状态', placeholder: '全部状态', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
{ key: 'category', type: 'input', label: '消息分类', placeholder: '请输入消息分类' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'title', prop: 'title', label: '消息标题', minWidth: 220 },
{ key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 300, showOverflowTooltip: true },
{ key: 'category', label: '分类', width: 130, slotName: 'category' },
{ key: 'sentAt', label: '发送时间', width: 180, slotName: 'sentAt' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
function statusLabel(status: NotificationInboxStatus) { return status === 'UNREAD' ? '未读' : '已读' }
function formatDate(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
function search() { page.value === 1 ? void load() : page.value = 1 }
async function load() {
loading.value = true
error.value = null
try {
result.value = await listMyNotifications({ page: page.value, size: pageSize.value, status: filters.value.status as NotificationInboxStatus || undefined, category: String(filters.value.category || '').trim() || undefined })
unreadCount.value = await getMyUnreadNotificationCount()
} catch (reason) {
error.value = getApiErrorDetails(reason, '我的消息加载失败')
result.value = { records: [], page: page.value, size: pageSize.value, total: 0, pages: 0 }
} finally { loading.value = false }
}
async function openDetail(row: NotificationInboxItem) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getMyNotification(row.id)
if (detail.value.status === 'UNREAD' && canRead.value) {
detail.value = await markMyNotificationRead(row.id)
await load()
}
} catch (reason) { detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { detailLoading.value = false }
}
async function markRead(row: NotificationInboxItem) {
if (!canRead.value || row.status !== 'UNREAD') return
try { await markMyNotificationRead(row.id); ElMessage.success('消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
}
async function markAllRead() {
try { await markAllMyNotificationsRead(); ElMessage.success('全部消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
}
watch([page, pageSize], () => void load())
onMounted(() => void load())
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="我的消息列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
<template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<template #toolbar-actions><el-button v-if="canReadAll && unreadCount" type="primary" plain :disabled="loading" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button></template>
<template #pagination-left><span>共 {{ result.total }} 条<span v-if="unreadCount">,未读 {{ unreadCount }} 条</span></span></template>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationInboxItem)"><Eye :size="15" />查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationInboxItem)"><CheckCheck :size="15" />标为已读</el-button></div></template>
<template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template>
<template #sentAt="{ row }">{{ formatDate((row as NotificationInboxItem).sentAt) }}</template>
<template #status="{ row }"><el-tag :type="(row as NotificationInboxItem).status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel((row as NotificationInboxItem).status) }}</el-tag></template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
<LoadErrorState v-if="detailError" title="消息详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<article v-else-if="detail" class="notification-detail"><header><h2>{{ detail.title }}</h2><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></header><p class="meta">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p><div class="body">{{ detail.body || '暂无消息内容' }}</div></article>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
.notification-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.notification-detail h2 { margin: 0; color: var(--text-primary, #1f2937); font-size: 20px; line-height: 1.4; }
.notification-detail .meta { color: var(--text-secondary, #64748b); font-size: 12px; }
.notification-detail .body { padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); white-space: pre-wrap; line-height: 1.8; }
</style>
+6
View File
@@ -1,6 +1,12 @@
import type { RouteRecordRaw } from 'vue-router'
export const notificationRoutes = [
{
path: 'notifications',
name: 'notification-inbox',
component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'),
meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' },
},
{
path: 'notifications/messages',
name: 'notification-messages',
+4
View File
@@ -8,6 +8,10 @@ export interface NotificationTask { id: string; taskNo: string; category: string
export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null }
export interface NotificationTaskDetail extends Omit<NotificationTask, 'duplicated'> { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] }
export type NotificationInboxStatus = 'UNREAD' | 'READ'
export interface NotificationInboxItem { id: string; category: string; title: string; summary: string; status: NotificationInboxStatus; sentAt: string; readAt: string | null }
export interface NotificationInboxDetail extends NotificationInboxItem { body: string; sourceType: string | null; sourceId: string | null }
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record<string, string>; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
export interface UpdateTemplateRequest extends Omit<CreateTemplateRequest, 'templateCode'> { version: number }
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'DRAFT') => ({
@@ -9,6 +9,7 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
parties: [],
containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }],
documents: [{ id: 9007199254740997, oceanBookingId: 123, fileId: 9007199254740998, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }],
histories: [],
})
@@ -48,6 +49,7 @@ describe('ocean bookings api', () => {
expect(created.status).toBe('DRAFT')
expect(created.containerRequirements[0]).toMatchObject({ id: '9007199254740994', requirementId: '9007199254740994', quantity: 2 })
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994' })
expect(created.documents[0]).toMatchObject({ id: '9007199254740996', oceanBookingId: '123', fileId: '9007199254740998', sizeBytes: 128, uploadedBy: '7' })
})
it('uses draft update, delete and release contracts without legacy actions', async () => {
@@ -68,4 +70,56 @@ describe('ocean bookings api', () => {
await expect(listWarehouseLogisticsChannels('8')).resolves.toEqual([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }])
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/channels', { params: { originWarehouseId: '8' } })
})
it('loads only server-filtered booking agents for the selected carrier and preserves long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ supplierId: 9007199254740993n, name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }] })
await expect(listOceanBookingAgents('MATSON')).resolves.toEqual([{ supplierId: '9007199254740993', name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }])
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/booking-agents', { params: { carrierCode: 'MATSON' } })
})
it('treats an empty server candidate response as a successful empty list', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
await expect(listOceanBookingAgents('HEDE')).resolves.toEqual([])
})
it('downloads both booking PDF documents from their dedicated public endpoints', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
await downloadOceanBookingPrint('123/456', 'SHIPPING_INSTRUCTION')
await downloadOceanBookingPrint('123/456', 'BILL_OF_LADING_CONFIRMATION')
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123%2F456/prints/shipping-instruction', { responseType: 'blob' })
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123%2F456/prints/bill-of-lading-confirmation', { responseType: 'blob' })
})
it('uses the booking-owned document list, upload, reference, invalidate and download contracts', async () => {
const rawDocument = { id: 9007199254740993n, oceanBookingId: 123, fileId: 9007199254740995n, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', data: [rawDocument] })
.mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
const post = vi.spyOn(http, 'post')
.mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '3' } })
.mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '4' } })
const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', data: { documentId: 9007199254740993n, bookingVersion: '5' } })
await expect(listOceanBookingDocuments('123')).resolves.toMatchObject([{ id: '9007199254740993', oceanBookingId: '123', fileId: '9007199254740995', sizeBytes: 128, uploadedBy: '7' }])
await expect(referenceOceanBookingDocument('123', 2, '9007199254740995', 'BOOKING_CONFIRMATION')).resolves.toMatchObject({ bookingVersion: 3 })
const file = new File(['bill'], '提单.pdf', { type: 'application/pdf' })
await expect(uploadOceanBookingDocument('123', 3, 'BILL_OF_LADING', file)).resolves.toMatchObject({ bookingVersion: 4 })
const form = post.mock.calls[1]?.[1] as FormData
expect(form.get('expectedVersion')).toBe('3')
expect(form.get('documentType')).toBe('BILL_OF_LADING')
expect(form.get('file')).toBe(file)
await expect(invalidateOceanBookingDocument('123', '9007199254740993', 4)).resolves.toEqual({ documentId: '9007199254740993', bookingVersion: 5 })
await downloadOceanBookingDocument('123', '9007199254740993')
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents')
expect(post).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents', { expectedVersion: 2, fileId: '9007199254740995', documentType: 'BOOKING_CONFIRMATION' }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(post).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/upload', expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/documents/9007199254740993', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } })
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/9007199254740993/download', { responseType: 'blob' })
})
it('preserves request and trace ids when a PDF endpoint returns a JSON business envelope as a blob', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' })], { type: 'application/json' }))
await expect(downloadOceanBookingPrint('123', 'SHIPPING_INSTRUCTION')).rejects.toMatchObject({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' })
})
})
@@ -1,7 +1,9 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
const maxErrorEnvelopeBytes = 1024 * 1024
export interface OceanLogisticsChannel {
channelCode: string
@@ -16,6 +18,7 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
version: Number(item.version ?? 0),
customerId: raw.customerId == null ? null : String(raw.customerId),
shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId),
carrierCode: raw.carrierCode ?? null,
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
containerCount: Number(raw.containerCount ?? 0),
pol: raw.pol ?? raw.departurePortName ?? null,
@@ -50,6 +53,7 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
capacityCbm: Number(container.capacityCbm),
maxWeightKg: Number(container.maxWeightKg),
})),
documents: (raw.documents || []).map(document),
histories,
statusHistory: histories,
}
@@ -86,9 +90,80 @@ export async function createOceanBooking(payload: OceanBookingSaveRequest) { con
export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
const printPaths: Record<OceanBookingPrintType, string> = {
SHIPPING_INSTRUCTION: 'shipping-instruction',
BILL_OF_LADING_CONFIRMATION: 'bill-of-lading-confirmation',
}
function document(raw: OceanBookingDocument): OceanBookingDocument {
return {
...raw,
id: String(raw.id),
oceanBookingId: String(raw.oceanBookingId),
fileId: String(raw.fileId),
sizeBytes: raw.sizeBytes == null ? null : Number(raw.sizeBytes),
uploadedBy: String(raw.uploadedBy),
invalidatedBy: raw.invalidatedBy == null ? null : String(raw.invalidatedBy),
}
}
export async function downloadOceanBookingPrint(id: string, type: OceanBookingPrintType) {
return requestBlob(`${basePath}/${encodeURIComponent(id)}/prints/${printPaths[type]}`)
}
async function parseBlobError(blob: Blob, status?: number) {
if (!blob.type.toLowerCase().includes('json')) return null
if (blob.size > maxErrorEnvelopeBytes) return new ApiResponseError('文件接口返回了超出限制的 JSON 响应', 'INVALID_FILE_RESPONSE', status)
try {
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
return new ApiResponseError(payload.code === '200' ? '文件接口未返回文件内容' : payload.message || '文件请求失败', payload.code === '200' ? 'INVALID_FILE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'FILE_REQUEST_FAILED'), status, payload.data, payload.requestId, payload.traceId)
} catch {
return new ApiResponseError('文件接口返回了无法识别的 JSON 响应', 'INVALID_FILE_RESPONSE', status)
}
}
async function requestBlob(path: string) {
try {
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
const responseError = await parseBlobError(blob, 200)
if (responseError) throw responseError
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await parseBlobError(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export async function listOceanBookingDocuments(id: string) {
const response = await http.get<ApiEnvelope<OceanBookingDocument[]>, ApiEnvelope<OceanBookingDocument[]>>(`${basePath}/${encodeURIComponent(id)}/documents`)
return (unwrapApiResponse(response) || []).map(document)
}
export async function referenceOceanBookingDocument(id: string, expectedVersion: number, fileId: string, documentType: OceanBookingDocumentType) {
const response = await http.post<ApiEnvelope<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${basePath}/${encodeURIComponent(id)}/documents`, { expectedVersion, fileId, documentType }, { headers: { 'Idempotency-Key': idempotencyKey() } })
const result = unwrapApiResponse(response)
return { document: document(result.document), bookingVersion: Number(result.bookingVersion) }
}
export async function uploadOceanBookingDocument(id: string, expectedVersion: number, documentType: OceanBookingDocumentType, file: File) {
const form = new FormData()
form.append('expectedVersion', String(expectedVersion))
form.append('documentType', documentType)
form.append('file', file)
const response = await http.post<ApiEnvelope<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${basePath}/${encodeURIComponent(id)}/documents/upload`, form, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': idempotencyKey() } })
const result = unwrapApiResponse(response)
return { document: document(result.document), bookingVersion: Number(result.bookingVersion) }
}
export async function invalidateOceanBookingDocument(id: string, documentId: string, expectedVersion: number) {
const response = await http.delete<ApiEnvelope<OceanBookingDocumentInvalidation>, ApiEnvelope<OceanBookingDocumentInvalidation>>(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } })
const result = unwrapApiResponse(response)
return { documentId: String(result.documentId), bookingVersion: Number(result.bookingVersion) }
}
export async function downloadOceanBookingDocument(id: string, documentId: string) {
return requestBlob(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}/download`)
}
export async function listWarehouseLogisticsChannels(warehouseId: string): Promise<OceanLogisticsChannel[]> {
const response = await http.get<ApiEnvelope<OceanLogisticsChannel[]>, ApiEnvelope<OceanLogisticsChannel[]>>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } })
return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) }))
}
export async function listOceanBookingAgents(carrierCode: OceanBookingCarrierCode): Promise<OceanBookingAgentOption[]> {
const response = await http.get<ApiEnvelope<OceanBookingAgentOption[]>, ApiEnvelope<OceanBookingAgentOption[]>>(`${basePath}/booking-agents`, { params: { carrierCode } })
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, supplierId: String(item.supplierId), name: String(item.name), carrierCodes: [...(item.carrierCodes || [])] }))
}
export type { OceanBookingResource, OceanBookingStatus }
@@ -1,15 +1,18 @@
import { describe, expect, it } from 'vitest'
import { isActiveBookingAgent } from './bookingAgents'
import { shouldClearBookingAgent } from './bookingAgents'
describe('ocean booking agent candidates', () => {
it('accepts an active booking supplier', () => {
expect(isActiveBookingAgent({ category: 'BOOKING', status: 'ACTIVE' })).toBe(true)
const options = [{ supplierId: '11', name: '代理A', carrierCodes: ['MATSON', 'COSCO'] as const }]
it('keeps an agent returned by the server-filtered carrier candidates', () => {
expect(shouldClearBookingAgent('11', options.map((item) => ({ ...item, carrierCodes: [...item.carrierCodes] })))).toBe(false)
})
it.each([
{ category: 'BOOKING', status: 'INACTIVE' },
{ category: 'TRUCKING', status: 'ACTIVE' },
])('rejects a non-eligible supplier: %o', (supplier) => {
expect(isActiveBookingAgent(supplier)).toBe(false)
it('clears an agent missing from the newly selected carrier candidates', () => {
expect(shouldClearBookingAgent('12', options.map((item) => ({ ...item, carrierCodes: [...item.carrierCodes] })))).toBe(true)
})
it('does not clear an empty selection', () => {
expect(shouldClearBookingAgent(null, [])).toBe(false)
})
})
+3 -7
View File
@@ -1,9 +1,5 @@
export interface BookingAgentCandidate {
category: string
status: string
}
import type { OceanBookingAgentOption } from './types'
export function isActiveBookingAgent(candidate: BookingAgentCandidate) {
return candidate.category === 'BOOKING'
&& candidate.status === 'ACTIVE'
export function shouldClearBookingAgent(currentSupplierId: string | null | undefined, options: OceanBookingAgentOption[]) {
return Boolean(currentSupplierId) && !options.some((option) => option.supplierId === String(currentSupplierId))
}
@@ -0,0 +1,95 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import OceanBookingDocumentsPanel from './OceanBookingDocumentsPanel.vue'
import { uploadOceanBookingDocument } from '../api/platformOceanBookings'
vi.mock('../api/platformOceanBookings', () => ({
downloadOceanBookingDocument: vi.fn(),
invalidateOceanBookingDocument: vi.fn(),
uploadOceanBookingDocument: vi.fn(),
}))
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue'), 'utf8')
describe('OceanBookingDocumentsPanel contract', () => {
beforeEach(() => { vi.clearAllMocks() })
it('owns the two confirmed document types under the booking', () => {
expect(source).toContain("value: 'BOOKING_CONFIRMATION', label: '订舱确认文件'")
expect(source).toContain("value: 'BILL_OF_LADING', label: '提单文件'")
expect(source).toContain('文件统一归属当前订舱')
expect(source).not.toContain('TRUCK_APPOINTMENT_ATTACHMENT')
})
it('supports upload, replacement, logical invalidation and download with version chaining', () => {
expect(source).toContain('uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)')
expect(source).toContain('invalidateOceanBookingDocument(props.bookingId, item.id, version)')
expect(source).toContain('version = invalidated.bookingVersion')
expect(source).toContain('确认替换')
expect(source).toContain('确认作废')
expect(source).toContain('downloadOceanBookingDocument')
expect(source).toContain('URL.createObjectURL(blob)')
})
it('accepts every file format while rejecting a single file above 50 MiB before upload', () => {
expect(source).toContain('const maxFileBytes = 50 * 1024 * 1024')
expect(source).toContain('file.size > maxFileBytes')
expect(source).toContain('单个订舱资料文件不能超过 50MB,请重新选择')
expect(source).toContain('任意格式,单文件不超过 50MB')
expect(source).not.toContain('accept="')
})
it('rejects an oversized arbitrary-format file before calling the backend', async () => {
const wrapper = mount(OceanBookingDocumentsPanel, {
props: { bookingId: '123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [], canManage: true },
global: { plugins: [ElementPlus] },
})
const file = new File(['data'], 'archive.custom', { type: 'application/x-custom' })
Object.defineProperty(file, 'size', { value: 50 * 1024 * 1024 + 1 })
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('单个订舱资料文件不能超过 50MB')
expect(uploadOceanBookingDocument).not.toHaveBeenCalled()
wrapper.unmount()
})
it('makes released booking files permanently read-only and preserves traced errors', () => {
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
expect(source).toContain('已放舱 · 永久只读')
expect(source).toContain('manageReason')
expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('订舱资料上传失败,请稍后重试')
expect(source).toContain('订舱资料下载失败,请稍后重试')
})
it('suppresses duplicate file mutations and protects list-only access', () => {
expect(source).toContain('if (busyDocumentId.value || busyType.value) return')
expect(source).toContain('if (busyDocumentId.value || busyType.value || !editable.value) return')
expect(source).toContain('!file || !editable.value || busyType.value || busyDocumentId.value')
expect(source).toContain(':disabled="Boolean(busyType || busyDocumentId)"')
})
it('keeps long Unicode active and invalidated file names readable', () => {
const fileName = '客户资料-中文-🚢-é-'.repeat(16) + '.数据包'
const document = {
id: 'doc-1', oceanBookingId: '123', fileId: 'file-1', documentType: 'BILL_OF_LADING' as const,
fileName, fileHash: null, sizeBytes: 12, contentType: null, valid: true,
uploadedBy: '7', uploadedByName: '操作员', uploadedAt: '2026-08-31',
invalidatedBy: null, invalidatedByName: null, invalidatedAt: null,
}
const wrapper = mount(OceanBookingDocumentsPanel, {
props: { bookingId: '123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [document], canManage: false, manageReason: '暂无订舱资料维护权限' },
global: { plugins: [ElementPlus] },
})
expect(wrapper.get('.booking-document-list strong').attributes('title')).toBe(fileName)
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('作废')
expect(source).toContain('.booking-document-history li span{min-width:0;overflow-wrap:anywhere}')
wrapper.unmount()
})
})
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Download, FileUp, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
const props = defineProps<{ bookingId: string; bookingVersion: number; bookingStatus: OceanBookingStatus; documents: OceanBookingDocument[]; canManage: boolean; manageReason?: string }>()
const emit = defineEmits<{ updated: [documents: OceanBookingDocument[], bookingVersion: number] }>()
const busyType = ref<OceanBookingDocumentType | ''>('')
const busyDocumentId = ref('')
const error = ref<ApiErrorDetails | null>(null)
const maxFileBytes = 50 * 1024 * 1024
const types: Array<{ value: OceanBookingDocumentType; label: string; description: string }> = [
{ value: 'BOOKING_CONFIRMATION', label: '订舱确认文件', description: '用于确认已订妥舱位及放舱前资料完整性' },
{ value: 'BILL_OF_LADING', label: '提单文件', description: '用于提单号和拖车派车前资料校验' },
]
const editable = computed(() => props.bookingStatus === 'DRAFT' && props.canManage)
const validDocuments = computed(() => props.documents.filter((item) => item.valid))
const invalidDocuments = computed(() => props.documents.filter((item) => !item.valid))
function documentsOfType(type: OceanBookingDocumentType) { return validDocuments.value.filter((item) => item.documentType === type) }
function fileSize(value: number | null) {
if (value == null) return '大小未知'
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function saveBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = name || '订舱资料'
anchor.click()
URL.revokeObjectURL(url)
}
async function download(item: OceanBookingDocument) {
if (busyDocumentId.value || busyType.value) return
busyDocumentId.value = item.id
error.value = null
try {
saveBlob(await downloadOceanBookingDocument(props.bookingId, item.id), item.fileName)
} catch (cause) {
error.value = getApiErrorDetails(cause, '订舱资料下载失败,请稍后重试')
} finally {
busyDocumentId.value = ''
}
}
async function invalidate(item: OceanBookingDocument, expectedVersion = props.bookingVersion) {
const result = await invalidateOceanBookingDocument(props.bookingId, item.id, expectedVersion)
const nextDocuments = props.documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
emit('updated', nextDocuments, result.bookingVersion)
return { documents: nextDocuments, version: result.bookingVersion }
}
async function remove(item: OceanBookingDocument) {
if (busyDocumentId.value || busyType.value || !editable.value) return
busyDocumentId.value = item.id
error.value = null
try {
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
await invalidate(item)
ElMessage.success('订舱资料已作废')
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
error.value = getApiErrorDetails(cause, '订舱资料作废失败,请稍后重试')
} finally {
busyDocumentId.value = ''
}
}
async function selectFile(event: Event, type: OceanBookingDocumentType) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !editable.value || busyType.value || busyDocumentId.value) return
if (file.size > maxFileBytes) {
error.value = { message: '单个订舱资料文件不能超过 50MB,请重新选择' }
return
}
const previous = documentsOfType(type)
busyType.value = type
error.value = null
try {
if (previous.length) {
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
}
const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)
let documents = [...props.documents, uploaded.document]
let version = uploaded.bookingVersion
emit('updated', documents, version)
for (const item of previous) {
const invalidated = await invalidateOceanBookingDocument(props.bookingId, item.id, version)
version = invalidated.bookingVersion
documents = documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
emit('updated', documents, version)
}
ElMessage.success(previous.length ? '订舱资料已替换' : '订舱资料已上传')
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
error.value = getApiErrorDetails(cause, '订舱资料上传失败,请稍后重试')
} finally {
busyType.value = ''
}
}
</script>
<template>
<section class="surface-panel booking-documents" aria-label="订舱资料">
<div class="booking-documents__heading"><div><h2>订舱资料</h2><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></div><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div class="booking-document-types">
<article v-for="type in types" :key="type.value" class="booking-document-card">
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || item.uploadedBy }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions"><el-button size="small" plain :loading="busyDocumentId === item.id" @click="download(item)"><Download :size="14" />下载</el-button><el-button v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)"><Trash2 :size="14" />作废</el-button></div></li></ul>
</article>
</div>
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || item.invalidatedBy || '系统' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
</section>
</template>
<style scoped>
.booking-documents{padding:16px}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:16px}.booking-documents__heading{margin-bottom:14px}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:16px}.booking-documents__heading p,.booking-document-card__heading p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:11px}.booking-document-types{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.booking-document-card{min-width:0;padding:14px;border:1px solid var(--cc-border);border-radius:8px}.booking-document-card h3{font-size:14px}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--cc-primary);border-radius:6px;color:var(--cc-primary);font-size:12px;cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:8px;margin:12px 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;padding:9px 10px;border-radius:6px;background:var(--el-fill-color-lighter)}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:11px}.booking-document-actions{flex:0 0 auto;gap:6px}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:14px;color:var(--cc-muted);font-size:12px}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:12px;padding:7px 10px;border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
</style>
@@ -0,0 +1,79 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OceanBookingPrintDialog from './OceanBookingPrintDialog.vue'
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
import { ApiResponseError } from '@/shared/api/http'
vi.mock('../api/platformOceanBookings', () => ({ downloadOceanBookingPrint: vi.fn() }))
describe('OceanBookingPrintDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() })
})
it('offers the two confirmed documents and previews the selected PDF', async () => {
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
const wrapper = mount(OceanBookingPrintDialog, {
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true },
global: { plugins: [ElementPlus] },
attachTo: document.body,
})
await flushPromises()
expect(document.body.textContent).toContain('出口货物托运单')
expect(document.body.textContent).toContain('提单确认件')
await wrapper.findAll('.booking-print-option')[0]!.trigger('click')
await flushPromises()
await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click()
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
wrapper.unmount()
})
it('shows permission and API errors without attempting a download', async () => {
const denied = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: false }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
expect(document.body.textContent).toContain('暂无订舱单据打印权限')
expect(downloadOceanBookingPrint).not.toHaveBeenCalled()
denied.unmount()
vi.mocked(downloadOceanBookingPrint).mockRejectedValue(new ApiResponseError('缺少提单号', 'B0409', 200, undefined, 'req-print', 'trace-print'))
const failed = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: true }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
await failed.findAll('.booking-print-option')[1]!.trigger('click')
await flushPromises()
await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click()
await flushPromises()
expect(document.body.textContent).toContain('缺少提单号')
expect(document.body.textContent).toContain('req-print')
expect(document.body.textContent).toContain('trace-print')
failed.unmount()
})
it('suppresses duplicate preview clicks and restores loading after completion', async () => {
let resolvePrint!: (blob: Blob) => void
vi.mocked(downloadOceanBookingPrint).mockImplementation(() => new Promise<Blob>((resolve) => { resolvePrint = resolve }))
const wrapper = mount(OceanBookingPrintDialog, {
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true },
global: { plugins: [ElementPlus] },
attachTo: document.body,
})
await flushPromises()
await wrapper.findAll('.booking-print-option')[0]!.trigger('click')
const preview = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))!
preview.click()
preview.click()
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenCalledTimes(1)
expect(preview.disabled).toBe(true)
resolvePrint(new Blob(['pdf'], { type: 'application/pdf' }))
await flushPromises()
expect(document.body.querySelector('iframe')).not.toBeNull()
wrapper.unmount()
})
})
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Download, FileText } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
import type { OceanBookingPrintType } from '../types'
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean }>()
const open = defineModel<boolean>({ default: false })
const selectedType = ref<OceanBookingPrintType | ''>('')
const loading = ref(false)
const previewUrl = ref('')
const printBlob = ref<Blob | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const options: Array<{ value: OceanBookingPrintType; label: string; description: string }> = [
{ value: 'SHIPPING_INSTRUCTION', label: '出口货物托运单', description: 'Sea Freight Shipping Instructions · PDF' },
{ value: 'BILL_OF_LADING_CONFIRMATION', label: '提单确认件', description: '提单内容确认及打印 · PDF' },
]
const selectedOption = computed(() => options.find((item) => item.value === selectedType.value) || null)
const previewTitle = computed(() => `${selectedOption.value?.label || '打印单据'} · ${props.bookingNo || props.bookingId}`)
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
printBlob.value = null
}
function reset() {
releasePreview()
selectedType.value = ''
error.value = null
loading.value = false
}
async function preview() {
if (!props.allowed || !selectedType.value || loading.value) return
loading.value = true
error.value = null
releasePreview()
try {
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
printBlob.value = blob
previewUrl.value = URL.createObjectURL(blob)
} catch (cause) {
error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function backToSelection() {
releasePreview()
error.value = null
}
function download() {
if (!printBlob.value || !selectedOption.value) return
const url = URL.createObjectURL(printBlob.value)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${props.bookingNo || props.bookingId}-${selectedOption.value.label}.pdf`
anchor.click()
URL.revokeObjectURL(url)
}
watch(open, (value) => { if (!value) reset() })
onBeforeUnmount(releasePreview)
</script>
<template>
<AppDialog
v-model="open"
:title="previewUrl ? previewTitle : '选择打印单据'"
width="min(1120px, calc(100vw - 48px))"
:loading="loading"
:show-confirm="false"
:cancel-text="previewUrl ? '关闭预览' : '取消'"
destroy-on-close
>
<el-alert v-if="!allowed" title="暂无订舱单据打印权限" type="warning" :closable="false" show-icon />
<template v-else-if="!previewUrl">
<p class="booking-print-current">当前订舱:<strong>{{ bookingNo || bookingId }}</strong></p>
<div class="booking-print-options" role="radiogroup" aria-label="打印单据类型">
<label v-for="item in options" :key="item.value" class="booking-print-option" :class="{ 'is-selected': selectedType === item.value }">
<el-radio v-model="selectedType" :value="item.value"><span class="sr-only">{{ item.label }}</span></el-radio>
<FileText :size="22" />
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small></span>
</label>
</div>
<LoadErrorState v-if="error" title="打印单据加载失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<div v-else class="booking-print-preview" :aria-label="previewTitle">
<iframe :src="previewUrl" :title="`${previewTitle} PDF 预览`" />
</div>
<template #footer="{ close }">
<div class="app-dialog__footer booking-print-footer">
<el-button v-if="previewUrl" :disabled="loading" @click="backToSelection">返回选择</el-button>
<el-button v-else :disabled="loading" @click="close">取消</el-button>
<el-button v-if="previewUrl" type="primary" :disabled="!printBlob" @click="download"><Download :size="16" />下载 PDF</el-button>
<el-button v-else type="primary" :loading="loading" :disabled="!allowed || !selectedType" @click="preview">预览 PDF</el-button>
</div>
</template>
</AppDialog>
</template>
<style scoped>
.booking-print-current{margin:0 0 16px;color:var(--cc-muted);font-size:13px}.booking-print-current strong{color:var(--cc-text)}.booking-print-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.booking-print-option{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid var(--cc-border);border-radius:10px;background:#fff;cursor:pointer}.booking-print-option.is-selected{border-color:var(--cc-primary);background:var(--cc-primary-soft)}.booking-print-option span{display:grid;min-width:0;gap:4px}.booking-print-option strong{color:var(--cc-text);font-size:15px}.booking-print-option small{color:var(--cc-muted);font-size:12px}.booking-print-preview{min-height:0;overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#f3f5f8}.booking-print-preview iframe{display:block;width:100%;height:min(72vh,760px);border:0;background:#fff}.booking-print-footer{width:100%}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.booking-print-options{grid-template-columns:1fr}.booking-print-preview iframe{height:68vh}}
</style>
@@ -0,0 +1,154 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam'
import { listPlatformPartners } from '@/modules/master-data'
import { listPlatformWarehouses } from '@/modules/warehouses'
import {
getOceanBooking,
listOceanBookingAgents,
listWarehouseLogisticsChannels,
releaseOceanBooking,
updateOceanBookingDraft,
} from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
import OceanBookingDetailView from './OceanBookingDetailView.vue'
const routerReplace = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }),
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformOceanBookings', () => ({
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(),
}))
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
const resource = {
id: '123', bookingNo: 'OB-123', status, version, customerId: '456', customerName: '测试客户',
billOfLadingNo, shipperName: '发货人', consigneeName: '收货人', containerCount: 2,
channelCode: 'SEA_FCL', channelName: '海运整箱', warehouseId: '8', warehouseName: '义乌仓',
shippingCompanyId: '10', shippingCompanyName: '测试船公司', carrierCode: 'MATSON' as const,
bookingAgentId: '20', bookingAgentName: '订舱代理', vesselName: 'TEST VESSEL', voyageNo: 'V001',
departurePortCode: 'CNSHA', departurePortName: '上海', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'ONT8', serviceTerm: 'CY-CY', freightTerm: 'PREPAID',
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: null,
createdAt: null, updatedAt: null, orderNo: null, carrierName: null, bookingAgent: null,
pol: '上海', pod: '洛杉矶', mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null,
etd: null, atd: null, eta: null, ata: null, containerSummary: null, amount: null, currency: null,
}
return {
...resource,
oceanBooking: resource,
parties: [
{ partyType: 'SHIPPER', partyName: '发货人' },
{ partyType: 'CONSIGNEE', partyName: '收货人' },
{ partyType: 'NOTIFY_PARTY', partyName: '通知人' },
],
containerRequirements: [{ requirementId: '31', containerType: '40HC', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
containers: [],
documents: [{ id: '41', oceanBookingId: '123', fileId: '51', documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: '7', uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }],
histories: [], statusHistory: [],
}
}
type ViewModel = {
form: { data: { billOfLadingNo: string } }
detail: OceanBookingDetail | null
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
actionSaving: boolean
submitRelease: () => Promise<void>
}
async function mountView() {
const wrapper = mount(OceanBookingDetailView, {
global: {
plugins: [ElementPlus],
stubs: {
AdminPage: { template: '<main><slot /></main>' },
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
PageToolbar: { template: '<header><slot name="actions" /></header>' },
CarrierScheduleQueryPanel: true,
},
},
})
await flushPromises()
return { wrapper, vm: wrapper.vm as unknown as ViewModel }
}
describe('OceanBookingDetailView one-click release', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }])
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
})
it('saves dirty form data and releases with the returned latest version', async () => {
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL'))
vi.mocked(releaseOceanBooking).mockResolvedValue(booking('RELEASED', 5, 'NEW-BL'))
const { vm } = await mountView()
vm.form.data.billOfLadingNo = ' NEW-BL '
await vm.submitRelease()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ expectedVersion: 3, data: expect.objectContaining({ billOfLadingNo: 'NEW-BL' }) }))
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
expect(vi.mocked(updateOceanBookingDraft).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(releaseOceanBooking).mock.invocationCallOrder[0]!)
expect(vm.detail?.status).toBe('RELEASED')
expect(vm.detail?.version).toBe(5)
})
it('does not call release when automatic draft saving fails', async () => {
vi.mocked(updateOceanBookingDraft).mockRejectedValue(new ApiResponseError('保存草稿失败', 'A0409', 409, undefined, 'request-save', 'trace-save'))
const { vm } = await mountView()
vm.form.data.billOfLadingNo = 'NEW-BL'
await vm.submitRelease()
expect(releaseOceanBooking).not.toHaveBeenCalled()
expect(vm.form.data.billOfLadingNo).toBe('NEW-BL')
expect(vm.actionError).toMatchObject({ code: 'A0409', requestId: 'request-save', traceId: 'trace-save' })
})
it('keeps the saved draft and evidence when release fails after automatic saving', async () => {
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL'))
vi.mocked(releaseOceanBooking).mockRejectedValue(new ApiResponseError('放舱失败', 'A0409', 409, undefined, 'request-release', 'trace-release'))
const { vm } = await mountView()
vm.form.data.billOfLadingNo = 'NEW-BL'
await vm.submitRelease()
expect(vm.detail).toMatchObject({ status: 'DRAFT', version: 4, billOfLadingNo: 'NEW-BL' })
expect(vm.form.data.billOfLadingNo).toBe('NEW-BL')
expect(vm.actionError).toMatchObject({ code: 'A0409', requestId: 'request-release', traceId: 'trace-release' })
})
it('releases an unchanged draft directly and ignores duplicate submissions while busy', async () => {
let resolveRelease!: (value: OceanBookingDetail) => void
vi.mocked(releaseOceanBooking).mockReturnValue(new Promise((resolve) => { resolveRelease = resolve }))
const { vm } = await mountView()
const first = vm.submitRelease()
const duplicate = vm.submitRelease()
await flushPromises()
expect(vm.actionSaving).toBe(true)
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(releaseOceanBooking).toHaveBeenCalledTimes(1)
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
resolveRelease(booking('RELEASED', 4))
await Promise.all([first, duplicate])
expect(vm.actionSaving).toBe(false)
})
})
@@ -24,8 +24,14 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).not.toContain('cancelOceanBooking')
})
it('invalidates the cached booking list after deleting a draft', () => {
expect(source).toContain("workspace.bumpCacheEpoch('ocean-bookings')")
expect(source).toContain("await router.replace('/ocean-bookings')")
})
it('saves incomplete DRAFT data and sends null for blank nullable fields', () => {
expect(source).toContain('保存草稿')
expect(source).toContain('function draftPayload(): OceanBookingDraftData')
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null')
expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())')
@@ -48,20 +54,56 @@ describe('OceanBookingDetailView draft and release contract', () => {
it('performs the complete release gate and presents the irreversible warning', () => {
expect(source).toContain('function releaseValidationError()')
expect(source).toContain('预计到港时间不能早于预计开船时间')
expect(source).toContain('放舱前请填写起运港代码或名称')
expect(source).toContain('放舱前请填写目的港代码或名称')
expect(source).toContain("[form.data.placeOfDelivery, '交货地点']")
expect(source).toContain("[form.data.customerId, '客户']")
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
expect(source).not.toContain('请先保存草稿后再放舱')
expect(source).toContain('放舱前请填写${missing[1]}')
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version })')
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
expect(source).toContain('const saved = await persistDraft(false)')
expect(source).toContain('releaseVersion = saved.version')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: releaseVersion })')
expect(source.indexOf('const saved = await persistDraft(false)')).toBeLessThan(source.indexOf('releaseOceanBooking(id.value, { expectedVersion: releaseVersion })'))
expect(source).toContain('if (!saved) return')
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
expect(source).not.toContain('releaseReason')
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
expect(source).toContain('OceanBookingDocumentsPanel')
expect(source).toContain('@updated="updateDocuments"')
})
it('keeps warehouse-scoped channel and customer/supplier selectors accessible', () => {
it('freezes the selected carrier standard code into the booking draft', () => {
expect(source).toContain('form.data.carrierCode = partner?.carrierCode ?? null')
expect(source).toContain('carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null')
expect(source).toContain('form.data.shippingCompanyId && !form.data.carrierCode')
expect(source).toContain('form.data.carrierCode = selected?.carrierCode ?? null')
})
it('keeps warehouse-scoped channel and server-filtered booking-agent selectors accessible', () => {
expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'")
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('placeholder="草稿可暂不选择"')
expect(source).toContain('暂无启用的订舱代理')
expect(source).toContain('当前船公司暂无可用订舱代理')
expect(source).toContain(':label="supplier.name"')
expect(source).toContain('filterable clearable')
expect(source).toContain(':disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable"')
expect(source).toContain(':disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError"')
})
it('loads booking agents from the carrier-filtered backend contract and clears an invalid selection', () => {
expect(source).toContain('listOceanBookingAgents(carrierCode)')
expect(source).toContain('shouldClearBookingAgent(form.data.bookingAgentId, options)')
expect(source).toContain('clearBookingAgent()')
expect(source).toContain("form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'")
expect(source).toContain('<ApiErrorMeta :details="bookingAgentsError" />')
expect(source).not.toContain('listSuppliers(')
expect(source).not.toContain('isActiveBookingAgent')
})
it('keeps dense layout, operation feedback and narrow-screen behavior', () => {
@@ -69,6 +111,15 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('LoadErrorState')
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
expect(source).toContain('OceanBookingPrintDialog')
expect(source).toContain('打印单据')
})
it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
expect(source).toContain('const formWritable = computed(() => canSave.value)')
expect(source).toContain(':disabled="!formWritable" class="booking-groups"')
expect(source).toContain('v-if="formWritable" class="container-actions"')
expect(source).toContain(':disabled="!formWritable"')
})
it('carries the existing read-only CarrierScheduleQuery on the booking detail page', () => {
@@ -2,10 +2,10 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { ArrowLeft, Plus, Printer, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
@@ -17,13 +17,13 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import {
createOceanBooking,
deleteOceanBookingDraft,
getOceanBooking,
listOceanBookingAgents,
releaseOceanBooking,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
@@ -31,14 +31,19 @@ import {
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingAgentOption,
OceanBookingCarrierCode,
OceanBookingDraftData,
OceanBookingDocument,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { isActiveBookingAgent } from '../bookingAgents'
import { shouldClearBookingAgent } from '../bookingAgents'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
@@ -65,7 +70,9 @@ const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const formRef = ref<FormInstance>()
const customers = ref<PlatformTenantResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const bookingSuppliers = ref<Supplier[]>([])
const bookingAgents = ref<OceanBookingAgentOption[]>([])
const bookingAgentsLoading = ref(false)
const bookingAgentsError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
@@ -74,11 +81,11 @@ const channelsLoading = ref(false)
const channelsError = ref('')
const selectedChannelName = ref('')
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const selectableBookingAgents = computed(() => bookingSuppliers.value.filter(isActiveBookingAgent))
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0)
function blankParties(): OceanBookingParty[] {
@@ -88,16 +95,24 @@ function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
containerRequirements: [createBlankContainerRequirement()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
const formWritable = computed(() => canSave.value)
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list'))
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && Boolean(detail.value.customerId) && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : !detail.value?.customerId ? '请先选择客户并保存订舱草稿,再上传订舱资料' : '')
const workflowBusy = computed(() => saving.value || actionSaving.value)
const releaseDialogOpen = ref(false)
const printOpen = ref(false)
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
@@ -106,18 +121,21 @@ async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [customerResult, partnerResult, supplierResult, warehouseResult] = await Promise.all([
const [customerResult, partnerResult, warehouseResult] = await Promise.all([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listSuppliers({ page: 1, size: 200, category: 'BOOKING', status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE')
partners.value = partnerResult.records
bookingSuppliers.value = supplierResult.records.filter(isActiveBookingAgent)
if (form.data.shippingCompanyId && !form.data.carrierCode) {
const selected = partners.value.find((partner) => String(partner.id) === String(form.data.shippingCompanyId))
form.data.carrierCode = selected?.carrierCode ?? null
}
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '客户、船公司或订舱代理选项加载失败').message
optionsError.value = getApiErrorDetails(cause, '客户、船公司或仓库选项加载失败').message
} finally {
optionsLoading.value = false
}
@@ -128,16 +146,25 @@ function selectCustomer(value: unknown) {
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer?.tenantName || null
}
function normalizeDraftData() {
form.data.customerId = form.data.customerId || null
form.data.customerName = form.data.customerId ? form.data.customerName : null
form.data.serviceTerm = form.data.serviceTerm?.trim() || null
form.data.freightTerm = form.data.freightTerm?.trim() || null
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containerRequirements.forEach((requirement) => { requirement.quantity = Number(requirement.quantity) })
function draftPayload(): OceanBookingDraftData {
const customerId = form.data.customerId || null
return {
...form.data,
customerId,
customerName: customerId ? form.data.customerName : null,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
serviceTerm: form.data.serviceTerm?.trim() || null,
freightTerm: form.data.freightTerm?.trim() || null,
billOfLadingNo: form.data.billOfLadingNo.trim(),
vesselName: form.data.vesselName.trim(),
voyageNo: form.data.voyageNo.trim(),
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
}
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
function selectWarehouse(value: unknown) {
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
@@ -165,24 +192,53 @@ function selectChannel(channelCode: string) {
form.data.channelCode = channelCode
selectedChannelName.value = channel?.channelName || ''
}
function clearBookingAgent() {
form.data.bookingAgentId = null
form.data.bookingAgentName = null
}
async function loadBookingAgents(carrierCode: OceanBookingCarrierCode) {
const serial = ++bookingAgentRequestSerial
bookingAgentsLoading.value = true
bookingAgentsError.value = null
try {
const options = await listOceanBookingAgents(carrierCode)
if (serial !== bookingAgentRequestSerial || carrierCode !== form.data.carrierCode) return
bookingAgents.value = options
if (formWritable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent()
} catch (cause) {
if (serial !== bookingAgentRequestSerial) return
bookingAgents.value = []
bookingAgentsError.value = getApiErrorDetails(cause, '订舱代理候选加载失败')
} finally {
if (serial === bookingAgentRequestSerial) bookingAgentsLoading.value = false
}
}
function selectShippingCompany(partnerId: string) {
const partner = partners.value.find((item) => String(item.id) === partnerId)
form.data.shippingCompanyId = partnerId || null
form.data.shippingCompanyName = partner?.partnerName || null
form.data.carrierCode = partner?.carrierCode ?? null
bookingAgents.value = []
bookingAgentsError.value = null
bookingAgentRequestSerial += 1
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
else clearBookingAgent()
}
function selectBookingAgent(supplierId: string) {
const supplier = selectableBookingAgents.value.find((item) => String(item.id) === supplierId)
form.data.bookingAgentId = supplier ? String(supplier.id) : null
form.data.bookingAgentName = supplier?.name || null
const supplier = bookingAgents.value.find((item) => item.supplierId === supplierId)
form.data.bookingAgentId = supplier?.supplierId ?? null
form.data.bookingAgentName = supplier?.name ?? null
}
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
const selectedCarrier = partners.value.find((partner) => String(partner.id) === String(value.shippingCompanyId || ''))
form.data = {
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null,
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
@@ -192,8 +248,10 @@ function applyDetail(value: OceanBookingDetail) {
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
channels.value = []
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
}
async function load() {
if (!id.value || isNew.value) return
@@ -203,63 +261,84 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
async function save() {
if (!canSave.value) return
actionError.value = null
normalizeDraftData()
async function validateDraftForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return }
saving.value = true
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
return true
}
async function persistDraft(showSuccess: boolean) {
if (!canSave.value || !(await validateDraftForm())) return null
actionError.value = null
try {
const payload = {
...form.data,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })),
}
const payload = draftPayload()
const saved = isNew.value
? await createOceanBooking({ expectedVersion: 0, data: payload })
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
await router.replace('/ocean-bookings')
return
return saved
}
applyDetail(saved)
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
return saved
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
}
async function save() {
if (!canSave.value || workflowBusy.value) return
saving.value = true
try { await persistDraft(true) }
finally { saving.value = false }
}
function openReleaseDialog() {
normalizeDraftData()
async function openReleaseDialog() {
if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
releaseDialogOpen.value = true
}
function releaseValidationError() {
if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'
if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称'
if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称'
const requiredFields: Array<[unknown, string]> = [
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'],
[form.data.placeOfDelivery, '交货地点'],
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) return `放舱前请填写${missing[1]}`
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
return containerRequirementError(form.data.containerRequirements)
}
async function submitRelease() {
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
if (!detail.value) return
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
detail.value.documents = documents
detail.value.version = bookingVersion
detail.value.oceanBooking.version = bookingVersion
form.expectedVersion = bookingVersion
}
async function submitRelease() {
if (!detail.value || workflowBusy.value) return
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version }))
if (!(await validateDraftForm())) { releaseDialogOpen.value = false; return }
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
let releaseVersion = detail.value.version
if (hasUnsavedDraftChanges.value) {
const saved = await persistDraft(false)
if (!saved) return
releaseVersion = saved.version
}
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: releaseVersion }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
@@ -273,6 +352,7 @@ async function removeDraft() {
actionSaving.value = true
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace('/ocean-bookings')
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
@@ -305,23 +385,24 @@ onMounted(() => { void load(); void loadOptions() })
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
<div class="actions">
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存草稿</el-button>
<el-tooltip :content="canPrint ? '选择出口货物托运单或提单确认件' : detail?.status === 'DRAFT' ? '订舱放舱后方可打印' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="booking-groups">
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups">
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(放舱前必填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="草稿可暂不选择" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" no-data-text="暂无启用的订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="supplier.name" :value="String(supplier.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
@@ -349,14 +430,14 @@ onMounted(() => { void load(); void loadOptions() })
<tbody>
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
<el-tooltip v-if="formWritable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)"><Trash2 :size="14" /></el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="requirement.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="requirement.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!editable" :min="1" :max="99" controls-position="right" /></td>
<td><el-select :model-value="requirement.containerType" :disabled="!formWritable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" /></td>
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
@@ -364,7 +445,7 @@ onMounted(() => { void load(); void loadOptions() })
</tbody>
</table>
</div>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" />新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
<div v-if="formWritable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" />新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
@@ -376,14 +457,16 @@ onMounted(() => { void load(); void loadOptions() })
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!editable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
<el-input v-model="party.contactName" :disabled="!editable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!editable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!editable" placeholder="地址" />
<el-input v-model="party.partyName" :disabled="!formWritable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
<el-input v-model="party.contactName" :disabled="!formWritable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!formWritable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!formWritable" placeholder="地址" />
</div>
</div>
</section>
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.id" /></div>
<section v-if="detail" class="surface-panel history-panel">
@@ -392,16 +475,17 @@ onMounted(() => { void load(); void loadOptions() })
</section>
</template>
</DetailPageLayout>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
<el-descriptions class="release-summary" :column="2" border>
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ detail?.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ form.data.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ form.data.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions>
</el-form>
</AppDialog>
@@ -35,6 +35,9 @@ describe('OceanBookingsView', () => {
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
expect(source).not.toContain('MoreHorizontal')
expect(source).toContain('打印单据')
expect(source).toContain('OceanBookingPrintDialog')
expect(source).toContain("access.can('platform:firstleg:ocean-booking:list')")
})
it('uses only the frozen DRAFT and RELEASED status options', () => {

Some files were not shown because too many files have changed in this diff Show More