Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev
This commit is contained in:
@@ -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' })")
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user