Merge remote-tracking branch 'origin/release/test' into dev

This commit is contained in:
2026-09-02 10:34:07 +08:00
80 changed files with 2219 additions and 591 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 货柜、排柜、预装/装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。整柜报关按 `loadingPlanId` 展示,逐资料完成,全买单柜使用 `complete-all-buy`;Backend `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
| 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
+2 -1
View File
@@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => {
const listLayout = source('src/shared/components/ListPageLayout.vue')
expect(queryToolbar).toContain('@media (max-width: 720px)')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
expect(dataTable).toContain('@media (max-width: 720px)')
expect(dataTable).toContain('flex-direction: column')
+4
View File
@@ -98,6 +98,10 @@ describe('admin auth session', () => {
await auth.login('platform_admin', 'password')
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
loginId: 'platform_admin',
password: 'password',
})
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-domestic')
})
@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
listContainers, listSmartLoadingCandidates, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary,
@@ -66,28 +66,23 @@ 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', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', 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, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }])
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', 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' } })
})
it('sends plan and item versions plus operation and idempotency keys', async () => {
it('sends plan and item versions plus operation identifiers', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
const normalized = await createSmartLoadingPlan('7', null)
await assignSmartLoadingItem('10', { inboundId: '2', packageCount: 4, expectedVersion: 5 })
await adjustSmartLoadingItem('10', '11', 6, 7, 8)
await removeSmartLoadingItem('10', '11', 9, 10)
await pushSmartLoadingPreload('10', 11)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null })
expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] })
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments')
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') })
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments')
expect(vi.mocked(http.post).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') })
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
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 () => {
@@ -252,11 +252,6 @@ export async function getSmartLoadingPlan(planId: string) {
return plan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string, remark: string | null) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans`, { bookingId, remark })
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { ...payload, operationId: operationId('assign-inbound') })
return plan(unwrapApiResponse(response))
@@ -269,7 +264,6 @@ export async function assignSmartLoadingGroup(planId: string, inboundId: string,
)
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))
@@ -293,7 +287,3 @@ export async function removeSmartLoadingGroup(planId: string, groupId: string, e
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))
}
@@ -69,27 +69,30 @@ describe('ContainerCustomsPanel', () => {
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(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关完成文件')
expect(wrapper.text()).not.toContain('客户报关资料')
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('上传报关完成文件')
expect(wrapper.text()).toContain('提交')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('覆盖完成文件')
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('上传报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -98,19 +101,19 @@ describe('ContainerCustomsPanel', () => {
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('上传报关完成文件')
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 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 wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
@@ -122,7 +125,7 @@ describe('ContainerCustomsPanel', () => {
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 wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('文件不可用')
@@ -40,7 +40,7 @@ const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefun
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: '报关已完成',
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
function documentCustomers(document: ContainerCustomsDocument) {
@@ -64,7 +64,7 @@ async function load() {
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
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('整柜报关已开始') }
@@ -75,11 +75,11 @@ async function start() {
async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = completionFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应完成文件'); return }
if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
if (finalConfirmation) {
try {
await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
} catch { return }
}
actionLoading.value = true; actionError.value = null
@@ -88,8 +88,8 @@ async function uploadCompletion(document: ContainerCustomsDocument) {
completionFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') }
ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
finally { actionLoading.value = false }
}
@@ -113,9 +113,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '源资料' : '完成文件'}下载已开始`)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '源资料' : '完成文件'}下载失败`)
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
@@ -168,17 +168,17 @@ onMounted(load)
<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>
<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>
<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>
<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>
@@ -188,5 +188,5 @@ onMounted(load)
</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}
.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:200px}.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>
@@ -363,7 +363,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<AdminPage class="container-detail-page">
<section class="container-overview surface-panel">
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待录入' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
</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">
@@ -233,7 +233,7 @@ describe('ContainersView', () => {
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('初次录入请使用“关联已放舱订舱”;后续纠错请进入预装清单。')
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')
@@ -251,7 +251,7 @@ describe('ContainersView', () => {
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 () => {
it('normalizes an optional booking-link container number', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -278,6 +278,36 @@ describe('ContainersView', () => {
wrapper.unmount()
})
it('links a released booking while leaving the real container number pending until pickup', 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 = ''
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
})
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('真实集装箱号可待司机提柜时录入')
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
expect(source).toContain("row.containerNo || '待补录'")
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')
@@ -394,16 +394,16 @@ async function openLink(source: object) {
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const containerNo = normalizedContainerNo(linkContainerNo.value)
linkContainerNo.value = containerNo
const normalized = normalizedContainerNo(linkContainerNo.value)
const containerNo = normalized || null
linkContainerNo.value = normalized
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true
linkError.value = null
try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变')
ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
@@ -538,8 +538,8 @@ onMounted(async () => {
<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 && ['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>
<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>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
@@ -564,9 +564,9 @@ onMounted(async () => {
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<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="请输入港口取得的真实集装箱号" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @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="真实集装箱号(可选)"><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>
@@ -576,7 +576,7 @@ onMounted(async () => {
<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.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="暂无可用国内仓库"><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>
@@ -60,6 +60,15 @@ const plan = {
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00',
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('GlobalSmartLoadingView', () => {
beforeEach(() => {
@@ -67,7 +76,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, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate])
})
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
@@ -146,7 +155,7 @@ describe('GlobalSmartLoadingView', () => {
const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入')
expect(option.text()).toContain('集装箱号:待补录')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
@@ -199,6 +208,47 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(': candidate.availablePackageCount')
})
it('filters candidates by expected arrival range and order status', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
{ ...candidate, inboundId: '52', inboundCode: 'IN-WRONG-STATUS', orderStatusSnapshot: 'LOADING', expectedArrivalAt: '2026-09-11T08:00:00' },
{ ...candidate, inboundId: '53', inboundCode: 'IN-WRONG-DATE', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-20T08:00:00' },
])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.getComponent(QueryToolbar).vm.$emit('search', {
customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', expectedArrivalRange: ['2026-09-10', '2026-09-12'],
})
await flushPromises()
const candidates = wrapper.get('.candidate-pane').text()
expect(candidates).toContain('IN-MATCH')
expect(candidates).not.toContain('IN-WRONG-STATUS')
expect(candidates).not.toContain('IN-WRONG-DATE')
})
it('paginates large inbound candidate results with twenty rows by default', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(Array.from({ length: 25 }, (_, index) => ({
...candidate,
inboundId: String(100 + index),
orderId: String(200 + index),
inboundCode: `IN-${String(index + 1).padStart(3, '0')}`,
})))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const pane = wrapper.get('.candidate-pane')
expect(pane.text()).toContain('IN-020')
expect(pane.text()).not.toContain('IN-021')
expect(pane.text()).toContain('共 25 条 · 第 1 / 2 页')
await pane.findAll('button').find((button) => button.attributes('aria-label') === '下一页')!.trigger('click')
await flushPromises()
expect(pane.text()).toContain('IN-021')
expect(pane.text()).not.toContain('IN-001')
})
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 },
@@ -260,13 +310,13 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
})
it('keeps only customer and inbound code filters', async () => {
it('keeps the confirmed candidate filters without unrelated customs fields', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode'])
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode', 'expectedArrivalRange', 'orderStatus'])
expect(source).not.toContain('目的仓')
expect(wrapper.text()).not.toContain('税率')
@@ -529,6 +579,9 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
expect(source).toContain('grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr)')
expect(source).toContain('min-height: 112px')
expect(source).toContain("label: '预计到货时间'")
expect(source).toContain("label: '订单状态'")
expect(source).toContain(':page-sizes="[20, 50, 100]"')
})
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
@@ -548,10 +601,8 @@ describe('GlobalSmartLoadingView', () => {
expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
expect(source).not.toContain('推送预装')
expect(source).toContain('缺少自动生成的预装计划')
expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { Plus, RefreshCw, Save, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -16,11 +16,9 @@ import {
adjustSmartLoadingItem,
assignSmartLoadingGroup,
assignSmartLoadingItem,
createSmartLoadingPlan,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
removeSmartLoadingGroup,
removeSmartLoadingItem,
@@ -56,14 +54,14 @@ 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 query = ref<QueryModel>({ customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
const candidatePage = ref(1)
const candidatePageSize = ref(20)
let selectionRequest = 0
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
@@ -175,9 +173,23 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const candidateOrderStatusLabels: Record<string, string> = {
DRAFT: '草稿', SUBMITTED: '待审核', PENDING_REVIEW: '待审核', APPROVED: '已审核', ACCEPTED: '已受理',
REJECTED: '已驳回', PROCESSING: '处理中', WAIT_MEASURE: '待测量数据', WAIT_SPLIT: '待分柜',
LOADING: '装柜中', LOADED: '装柜完成', COMPLETED: '已完成', DELIVERED: '派送完成', CANCELLED: '已取消',
}
function candidateOrderStatusLabel(status: string) {
return candidateOrderStatusLabels[status] || '未知状态'
}
const candidateOrderStatusOptions = computed(() => [...new Set(candidates.value
.map((candidate) => candidate.orderStatusSnapshot?.trim().toUpperCase() || '')
.filter(Boolean))]
.map((status) => ({ value: status, label: candidateOrderStatusLabel(status) })))
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 300 },
{ key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 200, options: candidateOrderStatusOptions.value },
])
const filteredContainers = computed(() => containers.value.filter((item) => (
@@ -188,13 +200,27 @@ const filteredContainers = computed(() => containers.value.filter((item) => (
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const orderStatus = String(appliedQuery.value.orderStatus || '').trim().toUpperCase()
const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
: []
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return displayedCandidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false
return !inboundCode || item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))
if (inboundCode && !item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))) return false
if (orderStatus && !item.sourceCandidates.some((member) => member.orderStatusSnapshot?.trim().toUpperCase() === orderStatus)) return false
if (arrivalRange.length === 2 && !item.sourceCandidates.some((member) => {
const arrivalDate = member.expectedArrivalAt?.slice(0, 10) || ''
return arrivalDate && arrivalDate >= arrivalRange[0] && arrivalDate <= arrivalRange[1]
})) return false
return true
})
})
const pagedCandidates = computed(() => {
const start = (candidatePage.value - 1) * candidatePageSize.value
return filteredCandidates.value.slice(start, start + candidatePageSize.value)
})
function preferredContainerId() {
const visible = filteredContainers.value
@@ -245,7 +271,7 @@ function hasRealContainerNo(container: ContainerBookingResource | null | undefin
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入'
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -277,6 +303,7 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
clearSelectionData()
if (!container) return
if (container.planStatus === 'COMPLETED') return
candidatePage.value = 1
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
@@ -355,17 +382,13 @@ async function selectChannel(channelCode: string) {
}
async function applyFilters(model: QueryModel) {
appliedQuery.value = { ...model }
candidatePage.value = 1
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) {
selectedContainerId.value = preferredContainerId()
await loadSelectedData()
}
}
async function createPlan() {
const container = selectedContainer.value
if (!container) return
await mutate(async () => acceptPlan(await createSmartLoadingPlan(container.id, null)), '创建装柜计划失败')
}
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
@@ -488,18 +511,6 @@ async function removeAll() {
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true })
}
async function pushPreload() {
if (!plan.value) return
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('预装清单已生成,请继续完成集装箱号和预装确认')
}, '推送预装清单失败')
}
async function mutate(
action: () => Promise<void>,
fallback: string,
@@ -569,9 +580,13 @@ onMounted(() => { void load(); void loadCustomers() })
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
v-model:page="candidatePage"
v-model:page-size="candidatePageSize"
:data="pagedCandidates"
:columns="candidateColumns"
:loading="loading || selectionLoading || mutating"
:total="filteredCandidates.length"
:page-sizes="[20, 50, 100]"
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
@@ -628,7 +643,6 @@ onMounted(() => { void load(); void loadCustomers() })
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canDelete && editable && plan?.items.some((item) => item.canRemove)" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />移除未拣</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</header>
@@ -642,9 +656,7 @@ onMounted(() => { void load(); void loadCustomers() })
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
</section>
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<el-empty v-else-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan">
<!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
@@ -659,7 +671,6 @@ onMounted(() => { void load(); void loadCustomers() })
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
/>
<!-- 推送预装的流程说明已由操作确认框承载,不在方案区重复展示。 -->
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<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>
@@ -94,15 +94,12 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).not.toContain('排柜操作失败')
})
it('uses bordered row actions and keeps push independent from containerNo', () => {
it('uses bordered row actions and has no manual preload push', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" type="danger" plain')
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('canPush')
expect(source).not.toContain('推送预装')
expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,8 +11,8 @@ 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, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
@@ -37,10 +37,8 @@ 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'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
@@ -180,7 +178,7 @@ function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
}
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入'
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待补录'
}
function syncDrafts(preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -222,13 +220,6 @@ function openOperationalList() {
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
})
}
async function createPlan() {
mutating.value = true
mutationError.value = null
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)
if (candidateBlocked(candidate)) return
@@ -271,7 +262,7 @@ async function recommend() {
if (!plan.value) throw error
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
}
ElMessage.success('智能建议已生成,请人工核对后再推送')
ElMessage.success('智能建议已生成,请人工核对明细')
}
catch (error) { await handleMutationError(error, '智能推荐失败') }
finally { mutating.value = false }
@@ -319,16 +310,6 @@ async function remove(source: object) {
finally { mutating.value = false }
})
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { await acceptPlanWithGroups(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
@@ -364,7 +345,6 @@ onMounted(load)
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</div>
</section>
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
@@ -379,7 +359,7 @@ onMounted(load)
<template v-else>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
<el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan">
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<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 || '请刷新计划获取最新作业状态。'" />
+3 -1
View File
@@ -69,7 +69,7 @@ export interface ContainerSimulationCreateRequest {
export interface ContainerOceanBookingLinkRequest {
oceanBookingId: string
requirementId: string
containerNo: string
containerNo: string | null
expectedVersion: number
}
@@ -235,6 +235,8 @@ export interface SmartLoadingCandidate {
orderId: string
orderNo: string | null
inboundCode: string | null
orderStatusSnapshot?: string | null
expectedArrivalAt?: string | null
warehouseId: string
channelCode: string | null
channelName: string | null
@@ -117,6 +117,9 @@ describe('CustomerDetailView', () => {
expect(document.querySelector('.overview-strip')).toBeNull()
expect(document.body.textContent).toContain('merchant_admin')
expect(document.body.textContent).toContain('企业管理员')
expect(document.body.textContent).toContain('公司联系电话')
expect(document.body.textContent).toContain('登录手机号')
expect(document.body.textContent).toContain('修改会影响登录')
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
expect(document.body.textContent).toContain('已替换')
@@ -365,7 +365,7 @@ watch(orderPage, (page, previousPage) => {
<section class="detail-panel contact-panel">
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
<dl class="info-grid">
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
<div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div>
@@ -373,13 +373,14 @@ watch(orderPage, (page, previousPage) => {
</dl>
</section>
<section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户的登录账号</p></div><UsersRound :size="19" /></div>
<div class="panel-title"><div><h2>企业用户</h2><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap">
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead>
<table><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
<td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td>
<td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td>
<td><div class="member-contact"><span>{{ displayText(member.phone) }}</span><small>{{ displayText(member.email) }}</small></div></td>
<td>{{ displayText(member.phone) }}</td>
<td>{{ displayText(member.email) }}</td>
<td><div v-if="member.roleNames?.length" class="role-list"><el-tag v-for="role in member.roleNames" :key="role" size="small" effect="plain">{{ role }}</el-tag></div><span v-else>—</span></td>
<td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
</tr></tbody>
+182 -16
View File
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
approveEnterpriseReview,
archivePlatformTenant,
createPlatformTenant,
downloadTenantQualification,
createPlatformTenantImpersonation,
getPlatformTenant,
@@ -15,6 +16,7 @@ import {
listTenantQualifications,
rejectEnterpriseReview,
resetPlatformTenantOwnerPassword,
submitPlatformTenantReview,
updatePlatformTenant,
uploadPlatformTenantQualification,
uploadPlatformTenantRequiredQualificationPackage,
@@ -104,6 +106,12 @@ function tableButtonByText(text: string) {
.find((button) => button.textContent?.trim() === text)
}
function selectQualificationFile(index: number, file: File) {
const input = document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')[index]
Object.defineProperty(input, 'files', { configurable: true, value: [file] })
input.dispatchEvent(new Event('change', { bubbles: true }))
}
async function clickMoreAction(text: string) {
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')?.click()
await flushPromises()
@@ -139,6 +147,10 @@ describe('CustomersView', () => {
vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(listEnterpriseReviews).mockResolvedValue([])
vi.mocked(createPlatformTenant).mockResolvedValue({
tenant: fullTenant, administratorUsername: 'merchant_owner', temporaryPassword: 'Temporary#2026',
})
vi.mocked(submitPlatformTenantReview).mockResolvedValue({ ...fullTenant, status: 'PENDING' })
vi.mocked(updatePlatformTenant).mockResolvedValue(fullTenant)
vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({
qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png',
@@ -187,7 +199,7 @@ describe('CustomersView', () => {
await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')
const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
expect(phone?.value).toBe(fullTenant.contactPhone)
expect(email?.value).toBe(fullTenant.contactEmail)
@@ -212,19 +224,83 @@ describe('CustomersView', () => {
expect(preview?.src).toBe('blob:qualification-preview')
})
it('marks only the business license and legal-person ID as required qualifications', async () => {
it('marks only the business license as a required file when creating a customer', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:create']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '新增客户')?.click()
await flushPromises()
const labels = [...document.querySelectorAll<HTMLElement>('.qualification-label')]
expect(labels.map((label) => label.textContent?.replace(/\s/g, ''))).toEqual([
'*营业执照',
'*法人身份证',
'法人身份证选填',
'货代资质证书选填',
'银行开户许可证选填',
])
expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项')
})
it('creates a customer with a business license and no legal-person ID', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:create']
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '新增客户')?.click()
await flushPromises()
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName)
await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative)
await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName)
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
tenantName: fullTenant.tenantName,
contactPhone: fullTenant.contactPhone,
}))
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, false, false,
)
})
it('keeps the company contact phone editable without changing a member login phone', async () => {
const activeTenant = { ...fullTenant, status: 'ACTIVE' as const }
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant)
vi.mocked(updatePlatformTenant).mockResolvedValue(activeTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null,
}])
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const phone = wrapper.get('input[placeholder="请输入公司联系电话"]')
expect(phone.attributes('disabled')).toBeUndefined()
await phone.setValue('0755-12345678')
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
expect(customersSource).not.toContain('联系电话(登录用户名)')
})
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
@@ -272,11 +348,11 @@ describe('CustomersView', () => {
await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')?.value).toBe(fullTenant.contactPhone)
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe(fullTenant.contactPhone)
expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' })
})
it('saves both required files and the credit code through one package action', async () => {
it('saves the business license, credit code and optional legal-person ID through one package action', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null,
@@ -288,13 +364,8 @@ describe('CustomersView', () => {
const licenseFile = new File(['license'], 'license.png', { type: 'image/png' })
const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' })
const qualificationInputs = document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')
const licenseInput = qualificationInputs[0]
const legalPersonInput = qualificationInputs[1]
Object.defineProperty(licenseInput, 'files', { configurable: true, value: [licenseFile] })
licenseInput?.dispatchEvent(new Event('change', { bubbles: true }))
Object.defineProperty(legalPersonInput, 'files', { configurable: true, value: [legalPersonFile] })
legalPersonInput?.dispatchEvent(new Event('change', { bubbles: true }))
selectQualificationFile(0, licenseFile)
selectQualificationFile(1, legalPersonFile)
await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('button')]
@@ -314,6 +385,101 @@ describe('CustomersView', () => {
expect(uploadPlatformTenantQualification).not.toHaveBeenCalled()
})
it('replaces only the business license while preserving an existing legal-person ID', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([
{ id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null },
{ id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null },
])
vi.mocked(uploadPlatformTenantRequiredQualificationPackage).mockResolvedValueOnce({
businessLicenseQualificationId: 'qualification-business-new', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license-new'], 'license-new.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, true, false,
)
expect(uploadPlatformTenantQualification).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('法人身份证可按需补充')
})
it('uploads an optional legal-person ID independently when the required business license already exists', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const legalPersonFile = new File(['legal-person'], 'legal-person.png', { type: 'image/png' })
selectQualificationFile(1, legalPersonFile)
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).not.toHaveBeenCalled()
expect(uploadPlatformTenantQualification).toHaveBeenCalledWith(
fullTenant.id, 'LEGAL_PERSON_ID', legalPersonFile, undefined, undefined, false,
)
})
it('retains a selected business license after an upload failure so the operator can retry', async () => {
vi.mocked(uploadPlatformTenantRequiredQualificationPackage)
.mockRejectedValueOnce(new Error('upload unavailable'))
.mockResolvedValueOnce({
businessLicenseQualificationId: 'qualification-business', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/files/license-new.png', legalPersonIdDocumentUrl: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
const licenseFile = new File(['license-new'], 'retry-license.png', { type: 'image/png' })
selectQualificationFile(0, licenseFile)
await flushPromises()
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('保存资料和资质'))
saveButton?.click()
await flushPromises()
expect(document.body.textContent).toContain('retry-license.png')
saveButton?.click()
await flushPromises()
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledTimes(2)
})
it('submits a customer for review with a business license and no legal-person ID', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '提交审核')?.click()
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(submitPlatformTenantReview).toHaveBeenCalledWith(fullTenant.id)
})
it('submits the current filters to the backend export service without loading every customer page', async () => {
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -398,7 +564,7 @@ describe('CustomersView', () => {
.find((button) => button.textContent?.trim() === '审核通过')?.click()
await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)')
expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请立即保存以下一次性凭证')
expect(document.body.textContent).toContain('merchant_8_owner')
@@ -459,7 +625,7 @@ describe('CustomersView', () => {
.find((button) => button.textContent?.trim() === '审核通过')?.click()
await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)')
expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('注册账号继续作为企业管理员')
expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证')
+56 -57
View File
@@ -9,7 +9,6 @@ import {
approveEnterpriseReview,
archivePlatformTenant,
changePlatformTenantStatus,
checkPlatformTenantContactPhone,
createPlatformTenant,
createPlatformTenantImpersonation,
downloadTenantQualification,
@@ -148,11 +147,14 @@ const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }],
unifiedSocialCreditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
],
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符,联系电话将作为登录用户名', trigger: 'blur' },
{ required: true, message: '请输入公司联系电话', trigger: 'blur' },
{ pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符', trigger: 'blur' },
],
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }],
@@ -171,7 +173,7 @@ const decisionRules: FormRules = {
function newQualificationDrafts(): QualificationDraft[] {
const definitions: Array<Pick<QualificationDraft, 'type' | 'label' | 'required'>> = [
{ type: 'BUSINESS_LICENSE', label: '营业执照', required: true },
{ type: 'LEGAL_PERSON_ID', label: '法人身份证', required: true },
{ type: 'LEGAL_PERSON_ID', label: '法人身份证', required: false },
{ type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false },
{ type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false },
]
@@ -200,7 +202,7 @@ const customerColumns: DataTableColumn[] = [
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
{ key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'contactPhone', prop: 'contactPhone', label: '公司联系电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
@@ -221,23 +223,16 @@ const uploadingQualifications = computed(() => qualifications.value.some((item)
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file)
const selectedLegalPersonId = computed(() => qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')?.file)
const requiredPackageChangeSelected = computed(() => Boolean(selectedBusinessLicense.value || selectedLegalPersonId.value))
const requiredPackageFilesSelected = computed(() => Boolean(selectedBusinessLicense.value && selectedLegalPersonId.value))
const creditCodeReadyForSave = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value && (creditCodeEstablished.value
|| unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || ''))
: creditCodeEstablished.value)
const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value
&& (requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value
: qualifications.value.filter((item) => item.required).every((item) => item.uploaded)))
const businessLicenseReadyForSave = computed(() => Boolean(selectedBusinessLicense.value
|| qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.uploaded))
const creditCodeReadyForSave = computed(() => creditCodeEstablished.value
|| unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || ''))
const requiredQualificationPackageComplete = computed(() => businessLicenseReadyForSave.value && creditCodeReadyForSave.value)
const missingQualificationPackageItems = computed(() => {
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
const missing: string[] = []
if (requiredPackageChangeSelected.value ? !businessLicense?.file : !businessLicense?.uploaded) missing.push('营业执照')
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随必填资质包保存)')
if (requiredPackageChangeSelected.value ? !legalPersonId?.file : !legalPersonId?.uploaded) missing.push('法人身份证')
if (!businessLicense?.file && !businessLicense?.uploaded) missing.push('营业执照')
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随营业执照保存)')
return missing
})
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
@@ -512,27 +507,38 @@ async function uploadSelectedQualifications(tenantId: string) {
const failures: string[] = []
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
if (requiredPackageChangeSelected.value && businessLicense?.file && legalPersonId?.file) {
businessLicense.uploading = true; legalPersonId.uploading = true
businessLicense.error = ''; legalPersonId.error = ''
const businessLicenseFile = businessLicense?.file || null
const legalPersonIdFile = legalPersonId?.file || null
if (businessLicense && businessLicenseFile) {
businessLicense.uploading = true
if (legalPersonIdFile && legalPersonId) legalPersonId.uploading = true
businessLicense.error = ''
if (legalPersonId) legalPersonId.error = ''
try {
const uploaded = await uploadPlatformTenantRequiredQualificationPackage(
tenantId, businessLicense.file, legalPersonId.file, form.value.unifiedSocialCreditCode,
businessLicense.uploaded, legalPersonId.uploaded,
tenantId, businessLicenseFile, legalPersonIdFile, form.value.unifiedSocialCreditCode,
businessLicense.uploaded, Boolean(legalPersonIdFile && legalPersonId?.uploaded),
)
businessLicense.uploaded = true; legalPersonId.uploaded = true
if (legalPersonIdFile && !uploaded.legalPersonIdDocumentUrl) throw new Error('法人身份证上传结果缺少文件地址')
businessLicense.uploaded = true
creditCodeEstablished.value = true
businessLicense.uploadedName = businessLicense.file.name
legalPersonId.uploadedName = legalPersonId.file.name
businessLicense.uploadedName = businessLicenseFile.name
businessLicense.documentUrl = uploaded.businessLicenseDocumentUrl
legalPersonId.documentUrl = uploaded.legalPersonIdDocumentUrl
businessLicense.file = null; legalPersonId.file = null
businessLicense.file = null
if (legalPersonIdFile && legalPersonId) {
legalPersonId.uploaded = true
legalPersonId.uploadedName = legalPersonIdFile.name
legalPersonId.documentUrl = uploaded.legalPersonIdDocumentUrl || ''
legalPersonId.file = null
}
} catch (error) {
const message = getApiErrorMessage(error, '必填资质包上传失败,请重试')
businessLicense.error = message; legalPersonId.error = message
failures.push('必填资质包')
const message = getApiErrorMessage(error, '营业执照及统一社会信用代码保存失败,请重试')
businessLicense.error = message
if (legalPersonIdFile && legalPersonId) legalPersonId.error = message
failures.push('营业执照资质')
} finally {
businessLicense.uploading = false; legalPersonId.uploading = false
businessLicense.uploading = false
if (legalPersonId) legalPersonId.uploading = false
}
}
for (const draft of failures.length ? [] : qualifications.value.filter((item) => !item.required && item.file)) {
@@ -556,26 +562,15 @@ async function uploadSelectedQualifications(tenantId: string) {
async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (!editingId.value) {
try {
const available = await checkPlatformTenantContactPhone(form.value.contactPhone.trim())
if (!available) {
ElMessage.error('联系电话已被其他客户使用,请更换后再提交')
return
}
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '联系电话唯一性校验失败,请稍后重试'))
return
}
}
const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode)
if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) {
if (!requiredQualificationPackageComplete.value) {
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
return
}
saving.value = true
try {
const hadSelectedQualifications = hasSelectedQualifications.value
const hadBusinessLicense = Boolean(selectedBusinessLicense.value)
const hadLegalPersonId = Boolean(selectedLegalPersonId.value)
let saved: PlatformTenantResource
if (editingId.value) {
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
@@ -599,8 +594,12 @@ async function saveCustomer() {
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadBusinessLicense && hadLegalPersonId) {
ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核')
} else if (hadBusinessLicense) {
ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核')
} else if (hadSelectedQualifications) {
ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
} else {
ElMessage.success('客户资料已保存,尚未提交审核')
}
@@ -619,7 +618,7 @@ async function submitReview() {
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) {
ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证')
ElMessage.warning('请先完整保存营业执照和18位统一社会信用代码;法人身份证为选填项')
return
}
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
@@ -857,7 +856,7 @@ async function openDecision(row: PlatformTenantResource, type: 'approve' | 'reje
}
selectedReview.value = review
decision.type = type
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
decision.comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
decisionOpen.value = true
await loadReviewQualifications()
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
@@ -1006,7 +1005,7 @@ watch(
)
watch(() => decision.type, (type) => {
if (!decisionOpen.value) return
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
decision.comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
})
onBeforeUnmount(() => {
@@ -1064,7 +1063,7 @@ onMounted(() => { void loadCustomers() })
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><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>
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item>
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
@@ -1072,8 +1071,8 @@ onMounted(() => { void loadCustomers() })
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
<FormSection class="qualification-section" title="企业认证必填业务项" description="营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照、法人身份证共同保存。' }}</small></el-form-item>
<FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -1127,13 +1126,13 @@ onMounted(() => { void loadCustomers() })
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div>
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div>
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div>
<div><dt>公司联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '未填写' }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
</dl>
</section>
<section class="review-dialog-section review-dialog-card">
<div class="review-section-heading"><div><h3>资质文件</h3><span>共 {{ reviewQualifications.length }} 份,逐项查看后再作决定</span></div><FileCheck2 :size="18" /></div>
<div class="review-section-heading"><div><h3>资质文件</h3><span>营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</span></div><FileCheck2 :size="18" /></div>
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
<div v-else class="review-document-table" role="table" aria-label="待审核客户资质">
@@ -4,6 +4,7 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type KnownExportTaskType =
| 'CUSTOMER_LIST'
| 'DOMESTIC_WAREHOUSE_ORDER_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LEDGER'
| 'FINANCE_FEE_SOURCE_LIST'
@@ -56,7 +57,7 @@ export interface CreatePlatformExportTaskRequest {
exportType: ExportTaskType
format: 'XLSX'
taskName: string
parameters: Record<string, string | number | boolean | null | undefined>
parameters: Record<string, string | number | boolean | null | undefined | string[]>
}
const basePath = '/platform/v1/files/export-tasks'
@@ -27,6 +27,7 @@ type DownloadFilters = QueryModel & {
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' },
@@ -64,7 +64,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).not.toContain('class="detail-toolbar"')
expect(page).not.toContain('<AppDrawer')
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
for (const code of ['RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT', 'PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
for (const code of ['RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT',
'SUPPLIER_DIRECT_PAID', 'SUPPLIER_ON_ACCOUNT_PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
for (const removed of ['FEE_INCLUDED', 'FEE_EXCLUDED', '纳入结算', '排除结算']) expect(page).not.toContain(removed)
for (const obsolete of ["value: 'FEE_SOURCE'", "value: 'RECONCILIATION'", "value: 'SETTLEMENT'"]) expect(page).not.toContain(obsolete)
})
@@ -77,7 +77,9 @@ const columns: DataTableColumn[] = [
const ledgerQueryFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
{ label: '收款', value: 'RECEIPT' }, { label: '付款', value: 'PAYMENT' },
{ label: '收款', value: 'RECEIPT' },
{ label: '供应商线下直付', value: 'SUPPLIER_DIRECT_PAID' },
{ label: '供应商账期付款', value: 'SUPPLIER_ON_ACCOUNT_PAYMENT' },
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
+2
View File
@@ -32,6 +32,8 @@ describe('finance status presentation', () => {
expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单')
expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效')
expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款')
expect(financeBusinessTypeLabel('SUPPLIER_DIRECT_PAID')).toBe('供应商线下直付')
expect(financeBusinessTypeLabel('SUPPLIER_ON_ACCOUNT_PAYMENT')).toBe('供应商账期付款')
expect(financeBusinessTypeLabel('VOID')).toBe('作废')
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
+2
View File
@@ -66,6 +66,8 @@ const financeBusinessTypeLabels: Record<string, string> = {
SETTLEMENT_ISSUED: '结算单生效',
RECEIPT: '收款',
PAYMENT: '付款',
SUPPLIER_DIRECT_PAID: '供应商线下直付',
SUPPLIER_ON_ACCOUNT_PAYMENT: '供应商账期付款',
ADJUSTMENT: '账务调整',
VOID: '作废',
// The following values are returned by current Finance read models.
+21 -1
View File
@@ -377,7 +377,7 @@ describe('platform IAM roles', () => {
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {})
})
it('uploads both required files and the credit code in one package request', async () => {
it('uploads the business license, credit code and optional legal-person ID in one package request', async () => {
const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: '92',
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf',
@@ -400,6 +400,26 @@ describe('platform IAM roles', () => {
expect(form.get('replaceLegalPersonId')).toBe('true')
})
it('omits the optional legal-person ID parts when only the business license is uploaded', async () => {
const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
tenantStatus: 'PROFILE_REQUIRED', businessLicenseDocumentUrl: '/license.pdf',
legalPersonIdDocumentUrl: null,
}
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
const businessLicense = new File(['%PDF-1.4'], '营业执照.pdf', { type: 'application/pdf' })
await expect(uploadPlatformTenantRequiredQualificationPackage(
'8', businessLicense, null, '91440300MA5ABCDE12', true, false,
)).resolves.toEqual(result)
const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
expect(form.get('businessLicense')).toBe(businessLicense)
expect(form.get('legalPersonId')).toBeNull()
expect(form.get('replaceBusinessLicense')).toBe('true')
expect(form.get('replaceLegalPersonId')).toBeNull()
})
it('preserves long review identifiers and returns one-time administrator credentials', async () => {
const reviewId = '190000000000000001'
const approval = {
+4 -4
View File
@@ -128,10 +128,10 @@ export interface PlatformQualificationUploadResponse {
export interface PlatformRequiredQualificationPackageUploadResponse {
businessLicenseQualificationId: number | string
legalPersonIdQualificationId: number | string
legalPersonIdQualificationId: number | string | null
tenantStatus: PlatformTenantStatus
businessLicenseDocumentUrl: string
legalPersonIdDocumentUrl: string
legalPersonIdDocumentUrl: string | null
}
export interface EnterpriseApprovalResponse {
@@ -429,14 +429,14 @@ export async function uploadPlatformTenantQualification(
export async function uploadPlatformTenantRequiredQualificationPackage(
tenantId: number | string,
businessLicense: File,
legalPersonId: File,
legalPersonId: File | null,
unifiedSocialCreditCode: string,
replaceBusinessLicense = false,
replaceLegalPersonId = false,
) {
const form = new FormData()
form.append('businessLicense', businessLicense)
form.append('legalPersonId', legalPersonId)
if (legalPersonId) form.append('legalPersonId', legalPersonId)
form.append('unifiedSocialCreditCode', unifiedSocialCreditCode)
if (replaceBusinessLicense) form.append('replaceBusinessLicense', 'true')
if (replaceLegalPersonId) form.append('replaceLegalPersonId', 'true')
@@ -108,6 +108,26 @@ describe('InboundTaskList measurement scope', () => {
expect(source).not.toContain("name: 'inbound-order-detail', params: { inboundId: row.id }")
})
it('closes only after navigating an approved review to the measurement tab', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const handler = source.slice(source.indexOf('async function handleTaskChanged'), source.indexOf('function handleReviewSelectionChange'))
expect(handler).toContain("name: 'unified-order-detail-by-inbound-code'")
expect(handler).toContain('params: { inboundCode }')
expect(handler).toContain("query: { tab: 'measurement', from: 'measurement-review' }")
expect(handler).toContain('await router.push')
expect(handler.indexOf('await router.push')).toBeLessThan(handler.lastIndexOf('reviewDialogOpen.value = false'))
expect(handler).toContain("getApiErrorDetails(error, '订单详情打开失败,请重试')")
expect(handler).toContain("name: 'unified-order-detail'")
expect(handler).toContain("query: { tenantId: target.tenantId, tab: 'measurement', from: 'measurement-review' }")
expect(source).toContain(':post-approve-loading="reviewNavigationLoading"')
expect(source).toContain(':approval-completed="reviewApproved"')
expect(source).toContain(':close-on-click-modal="!reviewNavigationLoading"')
expect(source).toContain('reviewNavigationError')
expect(source).toContain('重新打开订单详情')
expect(source).not.toContain('params: { inboundId: target.id }')
})
it('shows authoritative measurement totals with operation first and no internal IDs as primary columns', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const columnsSource = source.slice(source.indexOf('const columns'), source.indexOf('function text'))
@@ -45,6 +45,9 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const reviewNavigationLoading = ref(false)
const reviewApproved = ref(false)
const reviewNavigationError = ref<ApiErrorDetails | null>(null)
const relationshipDialogOpen = ref(false)
const relationshipDialogInboundCode = ref('')
const batchReviewLoading = ref(false)
@@ -184,7 +187,11 @@ function resetFilters() {
function openTask(row: InboundOrderSummary) {
selectedInbound.value = row
if (isReview.value) reviewDialogOpen.value = true
if (isReview.value) {
reviewApproved.value = false
reviewNavigationError.value = null
reviewDialogOpen.value = true
}
else measurementDialogOpen.value = true
}
@@ -200,10 +207,47 @@ function openRelationship(row: InboundOrderSummary) {
relationshipDialogOpen.value = true
}
function handleTaskChanged() {
reviewDialogOpen.value = false
selectedInbound.value = null
void loadTasks()
async function handleTaskChanged() {
const target = selectedInbound.value
if (!isReview.value) {
reviewDialogOpen.value = false
selectedInbound.value = null
void loadTasks()
return
}
if (!target || reviewNavigationLoading.value) return
reviewApproved.value = true
reviewNavigationError.value = null
const inboundCode = target.inboundCode?.trim()
const orderId = target.orderId?.trim()
if (!inboundCode && !orderId) {
reviewNavigationError.value = { message: '审核已通过,但未找到对应订单,请关闭弹框后刷新列表重试' }
return
}
reviewNavigationLoading.value = true
try {
if (inboundCode) {
await router.push({
name: 'unified-order-detail-by-inbound-code',
params: { inboundCode },
query: { tab: 'measurement', from: 'measurement-review' },
})
} else {
await router.push({
name: 'unified-order-detail',
params: { orderId },
query: { tenantId: target.tenantId, tab: 'measurement', from: 'measurement-review' },
})
}
reviewDialogOpen.value = false
selectedInbound.value = null
} catch (error) {
reviewNavigationError.value = getApiErrorDetails(error, '订单详情打开失败,请重试')
} finally {
reviewNavigationLoading.value = false
}
}
function handleReviewSelectionChange(rows: InboundOrderSummary[]) {
@@ -266,6 +310,9 @@ function handleMeasurementSubmitted() {
function clearSelectedInbound() {
selectedInbound.value = null
reviewApproved.value = false
reviewNavigationError.value = null
reviewNavigationLoading.value = false
}
watch([page, pageSize], () => { void loadTasks() })
@@ -367,9 +414,16 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
top="4vh"
destroy-on-close
append-to-body
:close-on-click-modal="!reviewNavigationLoading"
:close-on-press-escape="!reviewNavigationLoading"
class="inbound-task-dialog"
@closed="clearSelectedInbound"
>
<el-alert v-if="reviewNavigationError" type="error" :closable="false" show-icon class="review-navigation-error">
<template #title>{{ reviewNavigationError.message }}</template>
<ApiErrorMeta :details="reviewNavigationError" />
<el-button v-if="selectedInbound?.inboundCode || selectedInbound?.orderId" link type="danger" :loading="reviewNavigationLoading" @click="handleTaskChanged">重新打开订单详情</el-button>
</el-alert>
<MeasurementReviewPanel
v-if="selectedInbound"
:tenant-id="selectedInbound.tenantId"
@@ -377,6 +431,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
:customer-name="selectedInbound.customerName || selectedInbound.companyName"
:channel-name="selectedInbound.channelName"
:channel-code="selectedInbound.channelCode"
:post-approve-loading="reviewNavigationLoading"
:approval-completed="reviewApproved"
@changed="handleTaskChanged"
/>
</el-dialog>
@@ -398,6 +454,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<style scoped>
.inbound-task-page { min-width: 0; }
.state-stack { display: grid; gap: 12px; }
.review-navigation-error { margin-bottom: 14px; }
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.row-actions .el-button { margin: 0; }
.primary-cell { display: grid; gap: 3px; min-width: 0; }
@@ -152,7 +152,7 @@ describe('InboundFeePanel', () => {
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
'实际重量',
'实际体积',
'折算量',
'体积重量',
'取大后的计费量',
])
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
+2 -2
View File
@@ -376,13 +376,13 @@ defineExpose({ reload: loadFee })
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际量与折算量取大后计费,金额以服务端快照为准。</p></div>
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
</div>
<div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div><span>体积重量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div>
<div class="amount-equation" aria-label="费用计算公式">
@@ -195,11 +195,73 @@ describe('MeasurementReviewPanel', () => {
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
await flushPromises()
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(3)
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(2)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 8 })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('emits only after approval succeeds and blocks duplicate submissions', async () => {
let finishApproval: (() => void) | undefined
vi.mocked(approveMeasurement).mockImplementation(() => new Promise((resolve) => {
finishApproval = () => resolve({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'CALCULATED', initialPricingSucceeded: true, initialPricingMessage: null, initialFee: null })
}))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!
await approveButton.trigger('click')
await approveButton.trigger('click')
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toBeUndefined()
finishApproval?.()
await flushPromises()
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(1)
})
it('keeps the review panel open and does not emit when approval fails', async () => {
vi.mocked(approveMeasurement).mockRejectedValue(new Error('审核写入失败'))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
await flushPromises()
expect(wrapper.emitted('changed')).toBeUndefined()
expect(wrapper.find('.review-panel').exists()).toBe(true)
expect(wrapper.findAll('button').some((button) => button.text().includes('通过并进入待分柜'))).toBe(true)
})
it('blocks approval while the order detail navigation is loading', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, {
props: { customerId: '10001', inboundId: '90001', postApproveLoading: true },
global: { plugins: [ElementPlus] },
})
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
expect(approveMeasurement).not.toHaveBeenCalled()
})
it('does not submit approval again after approval succeeded but navigation needs retrying', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, {
props: { customerId: '10001', inboundId: '90001', approvalCompleted: true },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('审核已通过'))!
expect(approveButton.attributes('disabled')).toBeDefined()
await approveButton.trigger('click')
expect(approveMeasurement).not.toHaveBeenCalled()
})
it('shows historical rejected data read-only without new reject or release operations', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '历史审核说明' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
@@ -21,6 +21,8 @@ const props = defineProps<{
customerName?: string | null
channelName?: string | null
channelCode?: string | null
postApproveLoading?: boolean
approvalCompleted?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
@@ -129,7 +131,7 @@ async function handleWeightChanged() {
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try {
await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
@@ -147,7 +149,6 @@ async function confirmApprove() {
})
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
@@ -183,7 +184,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : '通过并进入待分柜' }}</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
@@ -93,26 +93,26 @@ describe('inbound weight OCR API', () => {
data: { records, page: '1', size: '20', total: '2', pages: '1' },
})
await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({
await expect(getWeightPalletOptions('10001', '20001', { page: 1, size: 50 })).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' } },
{ params: { tenantId: '10001', page: 1, size: 50, keyword: undefined } },
)
})
it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => {
it('passes remote page, keyword and abort signal through the frozen pallet endpoint', 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)
await getWeightPalletOptions('10001', '20001', { page: 2, size: 50, keyword: ' 木托盘 ', signal: controller.signal })
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' }, signal: controller.signal },
{ params: { tenantId: '10001', page: 2, size: 50, keyword: '木托盘' }, signal: controller.signal },
)
})
+11 -2
View File
@@ -74,10 +74,19 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) {
return unwrapApiResponse(response)
}
export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) {
export async function getWeightPalletOptions(tenantId: string, inboundId: string, query: {
page?: number
size?: number
keyword?: string
signal?: AbortSignal
} = {}) {
const keyword = query.keyword?.trim()
const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
{ params: { tenantId }, ...(signal ? { signal } : {}) },
{
params: { tenantId, page: query.page ?? 1, size: query.size ?? 50, keyword: keyword || undefined },
...(query.signal ? { signal: query.signal } : {}),
},
)
const page = unwrapApiResponse(response)
return {
@@ -1,5 +1,6 @@
import ElementPlus from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -221,7 +222,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
await flushPromises()
await openWeightDialog(wrapper)
const dialogText = document.body.textContent || ''
const dialogText = document.body.querySelector('.el-dialog')?.textContent || ''
expect(dialogText).toContain('当前托盘皮重')
expect(dialogText).toContain('12.000 KG')
expect(dialogText).toContain('OCR 识别毛重')
@@ -360,6 +361,24 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).not.toContain('可选托盘加载失败')
})
it('remote search keyword uses page 1 and size 50', async () => {
const wrapper = mountPanel()
await flushPromises()
vi.useFakeTimers()
try {
await wrapper.find('.pallet-picker .el-select').trigger('click')
await wrapper.find('.pallet-picker input').setValue(' 木托盘 ')
await vi.advanceTimersByTimeAsync(350)
await flushPromises()
expect(getWeightPalletOptions).toHaveBeenLastCalledWith('10001', '20001', expect.objectContaining({
page: 1, size: 50, keyword: '木托盘', signal: expect.any(AbortSignal),
}))
} finally {
vi.useRealTimers()
}
})
it('shows pallet loading failures without hiding existing weight results', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
const wrapper = mountPanel()
@@ -369,6 +388,17 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
})
it('shows a network error as a pallet candidate load failure without hiding historical snapshots', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new AxiosError('Network Error', 'ERR_NETWORK'))
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.text()).toContain('可选托盘加载失败')
expect(wrapper.findAll('.weight-card')).toHaveLength(1)
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
it('does not load weight or pallet data without measurement view permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
@@ -378,13 +408,14 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => {
it('keeps historical palletCode snapshots visible 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('TP-001')
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('12.000 KG')
})
@@ -424,7 +455,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
}))
const wrapper = mountPanel()
await flushPromises()
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]?.signal
wrapper.unmount()
resolveOptions?.(palletPage([{
@@ -45,6 +45,7 @@ const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null)
const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('')
const palletKeyword = ref('')
const palletLoading = ref(false)
const palletError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
@@ -61,6 +62,7 @@ const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB
let loadSequence = 0
let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
@@ -225,7 +227,7 @@ async function loadWorkspace() {
}
}
async function loadPalletOptions() {
async function loadPalletOptions(keyword = palletKeyword.value) {
if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad()
palletOptions.value = []
@@ -237,10 +239,16 @@ async function loadPalletOptions() {
const sequence = ++palletLoadSequence
const controller = new AbortController()
palletAbortController = controller
palletKeyword.value = keyword.trim()
palletLoading.value = true
palletError.value = null
try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal)
const result = await getWeightPalletOptions(customerId.value, props.inboundId, {
page: 1,
size: PALLET_OPTION_PAGE_SIZE,
keyword: palletKeyword.value || undefined,
signal: controller.signal,
})
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
@@ -257,6 +265,14 @@ async function loadPalletOptions() {
}
}
function searchPalletOptions(keyword: string) {
void loadPalletOptions(keyword)
}
function reloadPalletOptions() {
void loadPalletOptions()
}
function cancelPalletOptionsLoad() {
++palletLoadSequence
palletAbortController?.abort()
@@ -417,6 +433,7 @@ watch(
workspace.value = null
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null
weightDrafts.value = {}
loadError.value = null
@@ -433,6 +450,7 @@ watch(
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions()
},
@@ -472,6 +490,8 @@ defineExpose({ reload: loadWorkspace })
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
remote
:remote-method="searchPalletOptions"
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
/>
@@ -519,7 +539,7 @@ defineExpose({ reload: loadWorkspace })
:loading="palletLoading"
compact
class="weight-action-error"
@retry="loadPalletOptions"
@retry="reloadPalletOptions"
>
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
@@ -531,7 +551,7 @@ defineExpose({ reload: loadWorkspace })
class="weight-action-error"
title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
>
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
@@ -566,6 +586,7 @@ defineExpose({ reload: loadWorkspace })
@click="openPreview(row)"
><strong>{{ cardWeight(row) }}</strong></button>
<dl class="weight-card__facts">
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
@@ -30,7 +30,6 @@ const preloadSummary = {
createdAt: '2026-08-10T10:00:00',
version: 3,
readiness: {
preloadPushed: true,
containerNoReady: true,
preloadLinkCount: 1,
confirmedPreloadCount: 0,
@@ -54,7 +53,7 @@ const detail = {
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
}],
histories: [{ historyId: 21, itemId: 11, actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
histories: [{ historyId: 21, itemId: 11, operationId: 'transport-21', actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
}
describe('platform loading lists API', () => {
@@ -78,7 +77,7 @@ describe('platform loading lists API', () => {
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' })
expect(result.summary.containerBookingId).toBe('1900000000000000004')
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' })
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' })
})
it('updates transport with expectedVersion, warehouse and idempotency key', async () => {
@@ -9,9 +9,7 @@ export interface LoadingCustomer {
export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED'
export type PreloadBlockingReason =
| 'PRELOAD_NOT_PUSHED'
| 'LOADING_PLAN_NOT_EDITING'
| 'CONTAINER_NO_MISSING'
| 'PRELOAD_LINK_MISSING'
| 'OCEAN_BOOKING_NOT_LINKED'
| 'OCEAN_BOOKING_NOT_RELEASED'
@@ -19,7 +17,6 @@ export type PreloadBlockingReason =
| 'PRELOAD_ALREADY_CONFIRMED'
export interface PreloadReadiness {
preloadPushed: boolean
containerNoReady: boolean
preloadLinkCount: number
confirmedPreloadCount: number
@@ -115,6 +112,7 @@ export interface LoadingItem {
export interface LoadingHistory {
historyId: string
itemId: string | null
operationId: string
actionType: string
fromStatus: string | null
toStatus: string | null
@@ -251,6 +249,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
...raw,
historyId: requiredId(raw.historyId),
itemId: optionalId(raw.itemId),
operationId: requiredId(raw.operationId),
operatorId: requiredId(raw.operatorId),
}
}
@@ -9,7 +9,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
const props = defineProps<{
mode: LoadingListMode
@@ -29,7 +29,7 @@ const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
@@ -56,10 +56,46 @@ const summary = computed<LoadingListSummary | null>(() => props.detail?.summary
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || ''))
const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)
const volatileSnapshotFields = new Set(['requestHash', 'version', 'updatedAt', 'operatorId', 'operatorName', 'occurredAt'])
function stableSnapshotValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableSnapshotValue)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter(([key]) => !volatileSnapshotFields.has(key))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, stableSnapshotValue(nested)]))
}
function normalizedSnapshot(value: string | null) {
if (!value) return null
try { return stableSnapshotValue(JSON.parse(value) as unknown) } catch { return value }
}
function isPureStatusNoop(history: LoadingHistory) {
return history.actionType === 'STATUS_UPDATE'
&& history.fromStatus === history.toStatus
&& JSON.stringify(normalizedSnapshot(history.beforeSnapshot)) === JSON.stringify(normalizedSnapshot(history.afterSnapshot))
}
function historyEventKey(history: LoadingHistory) {
if (history.operationId) return `operation:${history.operationId}`
return history.historyId ? `history:${history.historyId}` : null
}
const visibleHistories = computed(() => {
const seen = new Set<string>()
return (props.detail?.histories || []).filter((history) => {
if (isPureStatusNoop(history)) return false
const eventKey = historyEventKey(history)
if (!eventKey) return true
if (seen.has(eventKey)) return false
seen.add(eventKey)
return true
})
})
const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
CONTAINER_NO_MISSING: '尚未录入集装箱号',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
@@ -127,14 +163,14 @@ function formatNumber(value?: number | null, digits = 3) {
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />纠正集装箱号 / 航次</el-button>
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</template>
</div>
</header>
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待补录' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
@@ -188,10 +224,10 @@ function formatNumber(value?: number | null, digits = 3) {
<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>
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-empty v-if="visibleHistories.length === 0" description="暂无操作历史" :image-size="72" />
<el-timeline v-else>
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<el-timeline-item v-for="history in visibleHistories" :key="historyEventKey(history) || history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<div class="history-entry">
<strong>{{ actionLabel(history.actionType) }}</strong>
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
@@ -96,9 +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('纠正集装箱号 / 航次')
expect(detailSource).toContain("summary.containerNo || '待补录'")
expect(detailSource).toContain('v-if="canEditTransport"')
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')")
expect(preloadDetailSource).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
})
it('refreshes the detail version on A0409 while preserving transport input', () => {
@@ -121,7 +121,6 @@ describe('LoadingListsView', () => {
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
@@ -240,4 +239,19 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain('customsDocument')
expect(detailSource).not.toContain('报关资料')
})
it('filters pure status no-ops and counts identity-distinct visible history events', () => {
for (const marker of ['visibleHistories', 'STATUS_UPDATE', 'operationId', 'PUSH_PRELOAD']) expect(detailSource).toContain(marker)
expect(detailSource).toContain('visibleHistories.length }} 条')
expect(detailSource).toContain('v-for="history in visibleHistories"')
expect(detailSource).not.toContain('detail.histories.length }} 条')
expect(detailSource).not.toContain('v-for="history in detail.histories"')
expect(detailSource).toContain('seen.has(eventKey)')
expect(detailSource).toContain('history.operationId')
const sameWording = [
{ historyId: '4', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
{ historyId: '5', operationId: '', actionType: 'ADJUST_ITEM', operationReason: '相同文案' },
]
expect(new Set(sameWording.map((history) => history.historyId)).size).toBe(2)
})
})
@@ -79,7 +79,7 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
vi.mocked(getPreloadPlan).mockResolvedValue({
summary: {
status: 'LOADING', containerNo: null,
readiness: { preloadPushed: true, pdaTaskStatus: null },
readiness: { pdaTaskStatus: null },
},
items: [], histories: [],
} as never)
@@ -61,20 +61,22 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain(':confirm-loading="remarkSaving"')
})
it('keeps preload as a correction-only entry before PDA claim and does not imply that preload directly opens PDA', () => {
it('supports first entry and reasoned correction before PDA claim without blocking earlier flow', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("summary.status === 'EDITING'")
expect(source).toContain("summary?.status === 'EDITING'")
expect(source).toContain("(!pdaStatus || pdaStatus === 'PENDING')")
expect(source).not.toContain('summary?.containerNo && summary.status')
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain("const editDialogTitle = computed(() => '纠正集装箱号 / 航次')")
expect(source).toContain("{ required: true, message: '请输入操作原因'")
expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
expect(source).toContain("String(value || '').trim().toUpperCase()")
expect(source).toContain('operationReason: editForm.operationReason.trim()')
expect(source).toContain('operationReason: editForm.operationReason.trim() || null')
expect(detailSource).toContain('v-if="canEditTransport"')
expect(detailSource).not.toContain("preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(detailSource).toContain("summary.containerNo || '待补录'")
expect(source).toContain('由服务端按真实条件创建唯一 PDA 任务')
expect(source).toContain('真实集装箱号未录入时不能确认')
expect(source).toContain('PDA 领取或作业开始后集装箱号只读')
expect(source).toContain('缺号不阻断此前流程')
expect(source).toContain('PDA 确认装柜完成前由服务端执行最终门禁')
expect(source).not.toContain('真实集装箱号未录入时不能确认')
})
it('uses a flat blocking note instead of repeated colored page alerts', () => {
@@ -51,17 +51,19 @@ const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
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'))
return Boolean(canEdit.value && summary?.status === 'EDITING'
&& (!pdaStatus || pdaStatus === 'PENDING'))
})
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary
if (!summary?.containerNo) return '真实集装箱号尚未录入;请返回货柜管理,通过“关联已放舱订舱”完成初次录入。'
if (!summary) return ''
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,保存时必须填写操作原因。'
if (!summary.containerNo) return '真实集装箱号待补录;可在预装详情首次录入,PDA 领取或作业开始后只读。'
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
})
const editDialogTitle = computed(() => '纠正集装箱号 / 航次')
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
const editRules: FormRules = {
containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
@@ -69,7 +71,7 @@ const editRules: FormRules = {
],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [
{ required: true, message: '请输入操作原因', trigger: 'blur' },
{ validator: (_rule, value, callback) => callback(isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因') : undefined), trigger: 'blur' },
{ max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' },
],
}
@@ -135,6 +137,7 @@ function openEdit() {
async function saveTransport() {
if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return
const correcting = isContainerCorrection.value
editForm.containerNo = normalizedContainerNo(editForm.containerNo)
editSaving.value = true
editError.value = null
@@ -142,11 +145,11 @@ async function saveTransport() {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim(),
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
ElMessage.success('运输信息已更新')
ElMessage.success(correcting ? '运输信息已纠正' : '真实集装箱号已补录')
} catch (cause) {
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
@@ -247,9 +250,9 @@ watch([planId, warehouseId], () => void loadDetail())
<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="请输入港口取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></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="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item>
</el-form>
</AppDialog>
@@ -265,11 +268,11 @@ 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 任务;当前页面不会伪造 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>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
</el-descriptions>
</div>
</AppDialog>
+1 -1
View File
@@ -44,7 +44,7 @@ export interface OceanBookingContainerRequirement {
export interface OceanBookingAllocatedContainer {
containerBookingId: string
cabinetNo: string
containerNo: string
containerNo: string | null
requirementId: string
containerType: OceanBookingContainerType
containerKind: string
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OrderReferenceText from './OrderReferenceText.vue'
describe('OrderReferenceText', () => {
@@ -23,4 +23,15 @@ describe('OrderReferenceText', () => {
expect(wrapper.text()).toBe('LMO-260827-0001')
expect(wrapper.attributes('title')).toBe('LMO-260827-0001')
})
it('copies the exact server short number without frontend formatting', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-9A7', copyable: true } })
await wrapper.get('el-button').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('LMO-9A7')
expect(wrapper.attributes('title')).toBe('LMO-9A7')
vi.unstubAllGlobals()
})
})
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import CopyButton from '@/shared/components/CopyButton.vue'
const props = withDefaults(defineProps<{
value?: string | null
emptyText?: string
}>(), { value: null, emptyText: '—' })
copyable?: boolean
copyLabel?: string
}>(), { value: null, emptyText: '—', copyable: false, copyLabel: '尾程单号' })
const displayValue = computed(() => {
const value = String(props.value ?? '')
@@ -15,16 +18,23 @@ const fullTitle = computed(() => displayValue.value === props.emptyText ? undefi
</script>
<template>
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span>
<span class="order-reference-text" :title="fullTitle"><span class="order-reference-text__value">{{ displayValue }}</span><CopyButton v-if="copyable && fullTitle" class="order-reference-text__copy" :value="displayValue" :label="copyLabel" button-text="" /></span>
</template>
<style scoped>
.order-reference-text {
display: inline-block;
display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: bottom;
}
.order-reference-text__value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.order-reference-text__copy { flex: 0 0 auto; margin: 0; padding-inline: 6px; }
</style>
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation'
import { formatOverseasDateTime, lastMileOrderReference, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('formats overseas timestamps with zero-padded seconds', () => {
@@ -32,6 +32,14 @@ describe('last-mile order presentation adapter', () => {
}).owner).toBe('—')
})
it('preserves the exact server LMO short number without padding, prefixing or deriving it from id', () => {
const serverOrderNo = 'LMO-9A7'
const row = toLastMileOrderListItem({ id: '2092873463568596994', customerId: '100', orderNo: serverOrderNo })
expect(row.orderNo).toBe(serverOrderNo)
expect(lastMileOrderReference(serverOrderNo)).toBe(serverOrderNo)
expect(row.orderNo).not.toContain(row.id)
})
it('preserves real metrics and distinguishes zero from unknown fields and times', () => {
const estimatedArrivalAt = '2026-08-27T12:30:00Z'
const row = toLastMileOrderListItem({
@@ -45,6 +45,11 @@ export function lastMileOrderTypeLabel(value: unknown) {
return orderTypeLabels[code] || text(code)
}
export function lastMileOrderReference(value: unknown, emptyLabel = '—') {
const serverValue = value == null ? '' : String(value)
return serverValue || emptyLabel
}
export function formatOverseasDateTime(value: unknown) {
const raw = String(value ?? '').trim()
if (!raw) return '—'
@@ -71,7 +76,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
id: String(dto.id),
customerId: String(dto.customerId),
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: text(dto.orderNo),
orderNo: lastMileOrderReference(dto.orderNo),
warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
rejectedAt: '—',
@@ -11,7 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
@@ -39,7 +39,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
const referenceValue = computed(() => text(detail.value?.order.orderNo || '订单编码待补充'))
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -151,7 +151,7 @@ onMounted(load)
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" /></template>
<template #reference><OrderReferenceText :value="referenceValue" copyable /></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
@@ -410,7 +410,7 @@ onMounted(() => { void load() })
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -128,6 +128,16 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).not.toContain('海外派送')
})
it('renders and copies the server-provided tail-leg short number in list and detail', () => {
const list = source('LastMileOrdersView.vue')
const detail = source('LastMileOrderDetailView.vue')
expect(list).toContain('<OrderReferenceText :value="String(row.orderNo || \'\')" copyable />')
expect(detail).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(detail).toContain('<OrderReferenceText :value="referenceValue" copyable />')
expect(list).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
expect(detail).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
})
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
const tailLeg = source('LastMileOrderDetailView.vue')
expect(tailLeg).toContain('getLastMileOrder')
@@ -137,6 +147,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('label="费用明细"')
expect(tailLeg).toContain('label="操作记录"')
expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan')
expect(inbound).toContain('sourceOrderId')
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentCandidates, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
@@ -8,9 +8,18 @@ describe('truck appointment API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('normalizes page values and maps filters', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, containerBookingId: 3, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } })
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', containerBookingId: '3', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', attachmentCount: 2 }] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } })
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } })
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', oceanBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', attachmentCount: 2 }] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', oceanBookingId: '77' } })
})
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
total: 1,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
})
it('uses detail, create, and status contracts', async () => {
@@ -18,18 +27,36 @@ describe('truck appointment API', () => {
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', { expectedVersion: 2 })).resolves.toMatchObject({ status: 'DISPATCHED' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
})
it('keeps pickup booking-scoped without sending container identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS' } })
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
expectedVersion: 3, reason: null,
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
})
it.each(['SUBMIT', 'DISPATCH', 'PICKUP'] as const)('sends %s without any container field', async (action) => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PENDING_DISPATCH' } })
await performTruckAppointmentAction('9001', action, { expectedVersion: 4, reason: '演示流转' })
const body = vi.mocked(http.post).mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(body).toEqual({ expectedVersion: 4, reason: '演示流转' })
expect(body).not.toHaveProperty('containerNo')
expect(body).not.toHaveProperty('containerBookingId')
expect(body).not.toHaveProperty('loadingPlanId')
})
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, customerId: 'legacy-customer', customerName: '旧客户快照' }
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: '500', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
await createTruckAppointment(payload as never)
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0,
carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
})
})
@@ -69,4 +96,16 @@ describe('truck appointment API', () => {
expect(form.get('file')).toBe(file)
expect(form.get('attachmentType')).toBe('DISPATCH_ORDER')
})
it('never forwards a legacy booking document type into truck attachments', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: 'legacy.pdf' } })
await referenceTruckAppointmentAttachment('77', { fileId: '2', fileName: 'legacy.pdf', attachmentType: 'BILL_OF_LADING' as never })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '2', fileName: 'legacy.pdf' })
const file = new File(['legacy'], 'legacy.pdf', { type: 'application/pdf' })
await uploadTruckAppointmentAttachment('77', file, 'BILL_OF_LADING' as never)
const form = vi.mocked(http.post).mock.calls.at(-1)?.[1] as FormData
expect(form.get('file')).toBe(file)
expect(form.has('attachmentType')).toBe(false)
})
})
+52 -10
View File
@@ -1,10 +1,14 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, isTruckAttachmentType, normalizeTruckAppointmentCurrency } from '../types'
import type {
SaveTruckAppointmentRequest,
TruckAppointmentPage,
TruckAppointmentQuery,
TruckAppointmentResource,
TruckAppointmentActionRequest,
TruckAppointmentCandidate,
TruckAppointmentCandidatePage,
TruckAppointmentCandidateQuery,
TruckAppointmentStatusHistory,
TruckAttachmentReference,
TruckAttachmentType,
@@ -17,7 +21,7 @@ function operationId(action: string, id: string | number) {
function dtoPayload(payload: SaveTruckAppointmentRequest) {
return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => {
const value = payload[key]
const value = key === 'currency' ? normalizeTruckAppointmentCurrency(payload[key]) : payload[key]
return value === undefined ? [] : [[key, value === '' ? null : value]]
})) as unknown as SaveTruckAppointmentRequest
}
@@ -37,7 +41,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
}
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const normalized = { ...resource } as Record<string, unknown>
for (const key of idKeys) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
@@ -52,13 +56,46 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
...query,
keyword: query.keyword?.trim() || undefined,
status: query.status || undefined,
containerBookingId: query.containerBookingId || undefined,
containerType: query.containerType || undefined,
oceanBookingId: query.oceanBookingId || undefined,
},
})
return normalizePage(unwrapApiResponse(response))
}
type CandidatePagePayload = Omit<TruckAppointmentCandidatePage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
records?: Array<TruckAppointmentCandidate & Record<string, unknown>>
page: number | string
size: number | string
total: number | string
pages?: number | string
}
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
const normalized = { ...candidate } as Record<string, unknown>
for (const key of ['oceanBookingId', 'warehouseId']) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
}
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
return normalized as unknown as TruckAppointmentCandidate
}
export async function listTruckAppointmentCandidates(query: TruckAppointmentCandidateQuery = {}) {
const response = await http.get<ApiEnvelope<CandidatePagePayload>, ApiEnvelope<CandidatePagePayload>>(`${basePath}/candidates`, {
params: { keyword: query.keyword?.trim() || undefined, page: query.page || 1, size: query.size || 200 },
})
const payload = unwrapApiResponse(response)
const page = Number(payload.page || 1)
const size = Number(payload.size || 200)
const total = Number(payload.total || 0)
return {
records: (payload.records || []).map(normalizeCandidate),
page,
size,
total,
pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)),
} satisfies TruckAppointmentCandidatePage
}
export async function getTruckAppointment(id: string | number) {
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
return normalizeResource(unwrapApiResponse(response))
@@ -77,14 +114,16 @@ export async function listTruckAppointmentAttachments(id: string | number) {
}
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload)
const { attachmentType, ...rest } = payload
const body = { ...rest, ...(isTruckAttachmentType(attachmentType) ? { attachmentType } : {}) }
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, body)
return unwrapApiResponse(response)
}
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
const form = new FormData()
form.append('file', file)
if (attachmentType) form.append('attachmentType', attachmentType)
if (isTruckAttachmentType(attachmentType)) form.append('attachmentType', attachmentType)
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
return unwrapApiResponse(response)
}
@@ -108,13 +147,16 @@ export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE'
export async function performTruckAppointmentAction(
id: string | number,
action: TruckAppointmentAction,
expectedVersion: number,
reason?: string,
payload: TruckAppointmentActionRequest,
idempotencyKey?: string,
requestHash?: string,
) {
const operationKey = idempotencyKey || operationId(action, id)
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
const body = {
expectedVersion: payload.expectedVersion,
reason: payload.reason || null,
}
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
})
return unwrapApiResponse(response)
@@ -3,62 +3,61 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentForm from './TruckAppointmentForm.vue'
import type { SaveTruckAppointmentRequest } from '../types'
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { getOceanBooking } from '@/modules/ocean-bookings'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
}))
vi.mock('@/modules/ocean-bookings', () => ({
listOceanBookings: vi.fn(),
getOceanBooking: vi.fn(),
}))
vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() }))
const model: SaveTruckAppointmentRequest = {
expectedVersion: 0,
oceanBookingId: null,
containerBookingId: null,
loadingPlanId: null,
carrierId: null,
plannedLoadingAt: null,
}
const bookings = [
{ id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' },
{ id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' },
{ id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' },
const candidates = [
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
]
function detail(id: string) {
const booking = bookings.find((item) => item.id === id)!
const candidate = candidates.find((item) => item.oceanBookingId === id)!
return {
...booking,
id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName,
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
containers: id === 'booking-1'
? [
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
]
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
containers: [],
}
}
function resource(id: string, bookingId: string): TruckAppointmentResource {
const candidate = candidates.find((item) => item.oceanBookingId === bookingId)!
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName }
}
describe('TruckAppointmentForm booking-only behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never)
vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
})
it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => {
it('selects only server-approved released bookings and never exposes container identity', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(getOceanBooking).toHaveBeenCalledWith('booking-1')
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-X'))).toBe(false)
expect(labels.some((label) => label.includes('OB-1') && label.includes('BL-1'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
expect(candidates[0]).not.toHaveProperty('containerBookingId')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1')
@@ -67,31 +66,78 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('clears a selected container when switching to another booking', async () => {
it('keeps only the latest candidate response when option reloads overlap', async () => {
let resolveSlow!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
let resolveFast!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
vi.mocked(listTruckAppointmentCandidates)
.mockResolvedValueOnce({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSlow = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveFast = resolve }))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { loadOptions: () => Promise<void> }
const slow = vm.loadOptions()
const fast = vm.loadOptions()
resolveFast({ records: [candidates[1]!], page: 1, size: 200, total: 1, pages: 1 })
await fast
resolveSlow({ records: [candidates[0]!], page: 1, size: 200, total: 1, pages: 1 })
await slow
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
wrapper.unmount()
})
it('replaces frozen booking snapshots when switching the selected booking', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
selects[1]!.vm.$emit('update:modelValue', 'container-1')
await flushPromises()
expect((wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest).containerBookingId).toBe('container-1')
selects[0]!.vm.$emit('update:modelValue', 'booking-2')
await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.containerBookingId).toBeNull()
expect(emitted.billOfLadingNo).toBe('BL-2')
expect(emitted.warehouseId).toBe('WH-2')
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true)
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
wrapper.unmount()
})
it('ignores stale current-booking details when a cached edit route changes', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
else resolveSecond = resolve as typeof resolveSecond
}) as never)
const firstResource = resource('appointment-1', 'booking-1')
const secondResource = resource('appointment-2', 'booking-2')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource })
await flushPromises()
resolveSecond(detail('booking-2'))
await flushPromises()
resolveFirst({ ...detail('booking-1'), documents: [] })
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
const current = resource('appointment-1', 'booking-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('订舱版本冲突')
expect(wrapper.text()).toContain('A0409')
@@ -100,26 +146,30 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('blocks a released booking that has no bill of lading number', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const current = resource('appointment-1', 'booking-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱缺少有效提单号')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.billOfLadingNo).toBeNull()
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('rejects a booking that becomes unreleased before its detail is loaded', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => {
const wrapper = mount(TruckAppointmentForm, {
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } },
global: { plugins: [ElementPlus] },
})
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
expect(currencySelect.props('modelValue')).toBe('RMB')
currencySelect.vm.$emit('update:modelValue', 'USD')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱尚未放舱')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.containerBookingId).toBeNull()
expect(emitted.currency).toBe('USD')
expect(wrapper.text()).toContain('USD')
expect(emitted).not.toHaveProperty('receivableAmount')
wrapper.unmount()
})
})
@@ -3,31 +3,31 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => {
it('requires a released booking before a real-numbered allocated container', () => {
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
it('requires one server-approved released booking without exposing a container selector', () => {
expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'")
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"'))
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
expect(source).not.toContain('prop="containerBookingId"')
expect(source).not.toContain('label="柜次"')
expect(source).not.toContain('关联订舱(可选)')
expect(source).not.toContain('关联柜次(可选)')
})
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
expect(source).toContain('getOceanBooking(bookingId)')
expect(source).toContain('containers.value = booking.containers || []')
expect(source).toContain('bookingDocuments.value = booking.documents || []')
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)')
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次')
it('uses only server-owned booking candidates and never rebuilds availability from booking detail', () => {
expect(source).toContain('candidates.value = results[1].value.records')
expect(source).toContain('new Map<string')
expect(source).not.toContain('booking.containers')
expect(source).not.toContain('listOceanBookings')
expect(source).not.toContain('isIso6346ContainerNumber')
expect(source).not.toContain('listContainers')
})
it('clears an incompatible container whenever the booking changes', () => {
expect(source).toContain('draft.containerBookingId = null')
expect(source).toContain('draft.loadingPlanId = null')
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))')
it('does not write legacy container or loading-plan fields', () => {
expect(source).not.toContain('draft.containerBookingId')
expect(source).not.toContain('draft.loadingPlanId')
expect(typesSource).not.toContain("'containerBookingId', 'loadingPlanId'")
})
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
@@ -46,16 +46,33 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('所选订舱缺少有效提单号')
expect(source).toContain("booking.status !== 'RELEASED'")
expect(source).toContain('bookingContainersError')
expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')")
expect(source).toContain("getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')")
expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />')
expect(source).toContain('重新加载选项')
expect(source).toContain('重新加载')
expect(source).toContain('请选择有效的合作中拖车供应商')
})
it('labels the generated cabinet reference and physical container number separately', () => {
expect(source).toContain('柜次号:${item.cabinetNo')
expect(source).toContain('集装箱号:${item.containerNo}')
it('freezes the current edit booking and marks its fallback option as non-selectable', () => {
expect(source).toContain('const relationLocked = computed(() => Boolean(props.resource))')
expect(source).toContain('(当前绑定,不可重选)')
expect(source).toContain(':disabled="relationLocked"')
expect(source).not.toContain('selectedContainerFallback')
})
it('guards current booking detail loads against stale route reuse responses', () => {
expect(source).toContain('const sequence = ++bookingLoadSequence')
expect(source).toContain("sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId")
expect(source).toContain('const sequence = ++optionsLoadSequence')
expect(source).toContain('if (sequence !== optionsLoadSequence) return')
expect(source).toContain("watch(() => props.resource?.id")
})
it('keeps container identity out of the booking-scoped form', () => {
expect(source).not.toContain('柜次')
expect(source).not.toContain('装柜计划')
expect(source).not.toContain('集装箱号')
expect(source).toContain('货柜资料在货柜与预装流程维护')
})
it('keeps the form usable at 390px', () => {
@@ -68,4 +85,22 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain(':deep(.el-select__selected-item)')
expect(source).toContain('text-overflow:ellipsis')
})
it('uses payable-only fees with a fixed dynamic currency selector', () => {
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']")
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
expect(source).toContain('placeholder="请选择币种"')
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
})
it('keeps the 1920 desktop contract compact without horizontal form overflow', () => {
expect(source).toContain('@media (min-width: 1200px)')
expect(source).toContain('max-width:25%; flex:0 0 25%')
expect(source).toContain('class="relation-column"')
expect(source).toContain('class="wide-column"')
expect(source).toContain('max-width:50%; flex-basis:50%')
expect(source).not.toContain('overflow-x:auto')
})
})
@@ -2,22 +2,23 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { isIso6346ContainerNumber } from '../iso6346'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const relationWarning = ref('')
const bookingContainersLoading = ref(false)
const bookingContainersError = ref<ApiErrorDetails | null>(null)
const bookingDocuments = ref<OceanBookingDocument[]>([])
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
const relationLocked = computed(() => Boolean(props.resource))
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
@@ -32,96 +33,76 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
}, trigger: 'blur' })
const rules: FormRules = {
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱'))
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
}, trigger: 'change' }],
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')],
}
const carriers = ref<Supplier[]>([])
const containers = ref<OceanBookingAllocatedContainer[]>([])
const bookings = ref<OceanBookingResource[]>([])
const candidates = ref<TruckAppointmentCandidate[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
let bookingLoadSequence = 0
let optionsLoadSequence = 0
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE'
)))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)))
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo)))
const selectedContainerFallback = computed(() => {
const id = draft.containerBookingId
const snapshot = props.resource
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
return {
id: String(id),
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
}
const bookings = computed(() => {
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
id: item.oceanBookingId,
bookingNo: item.oceanBookingNo,
billOfLadingNo: item.billOfLadingNo,
warehouseId: item.warehouseId,
warehouseName: item.warehouseName,
bookingDocumentReady: item.bookingDocumentReady,
})
return [...result.values()]
})
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
const bookingDocumentReady = computed(() => relationLocked.value
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
const selectedBookingFallback = computed(() => {
const id = draft.oceanBookingId
const snapshot = props.resource
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
})
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
function selectContainer(containerId: string) {
const item = selectableContainers.value.find((entry) => entry.containerBookingId === containerId)
draft.containerBookingId = item?.containerBookingId || null
relationWarning.value = ''
if (!item) {
draft.loadingPlanId = null
return
}
draft.loadingPlanId = null
}
async function loadBookingContainers(bookingId: string) {
async function loadCurrentBookingContext(bookingId: string) {
const sequence = ++bookingLoadSequence
bookingContainersLoading.value = true
bookingContainersError.value = null
try {
const booking = await getOceanBooking(bookingId)
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
if (booking.status !== 'RELEASED') {
containers.value = []
bookingDocuments.value = []
draft.containerBookingId = null
draft.loadingPlanId = null
bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' }
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
return
}
const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id)
if (bookingIndex >= 0) bookings.value[bookingIndex] = booking
else bookings.value.push(booking)
draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo
containers.value = booking.containers || []
bookingDocuments.value = booking.documents || []
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
draft.containerBookingId = null
draft.loadingPlanId = null
}
} catch (cause) {
containers.value = []
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
bookingDocuments.value = []
draft.containerBookingId = null
draft.loadingPlanId = null
bookingContainersError.value = getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
} finally {
bookingContainersLoading.value = false
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
}
}
async function selectBooking(bookingId: string) {
bookingLoadSequence += 1
const item = bookings.value.find((entry) => entry.id === bookingId)
draft.oceanBookingId = item?.id || null
draft.containerBookingId = null
draft.loadingPlanId = null
containers.value = []
bookingDocuments.value = []
relationWarning.value = ''
if (!item) {
@@ -131,37 +112,48 @@ async function selectBooking(bookingId: string) {
}
draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo
await loadBookingContainers(item.id)
}
async function loadOptions() {
const sequence = ++optionsLoadSequence
optionsLoading.value = true; optionsError.value = ''
candidates.value = []
const results = await Promise.allSettled([
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }),
relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
])
if (sequence !== optionsLoadSequence) return
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED')
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId))
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
else if (draft.oceanBookingId) {
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId)
if (candidate) {
draft.warehouseId = candidate.warehouseId
draft.billOfLadingNo = candidate.billOfLadingNo
} else {
draft.oceanBookingId = null
}
}
}
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
defineExpose({ validate, loadOptions })
onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">订舱(必填)</el-divider></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择可创建拖车单的已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
@@ -184,20 +176,19 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form>
</template>
@@ -214,6 +205,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
@media (min-width: 1200px) {
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
}
@media (max-width: 480px) {
.truck-form { padding-inline: 2px; }
.relation-note { align-items:flex-start; flex-direction:column; }
@@ -0,0 +1,118 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentCreateView from './TruckAppointmentCreateView.vue'
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, replace: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRouter: () => ({ replace: routing.replace, push: routing.push }) }))
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, oceanBookingId: 'booking-1', attachments: [],
})
const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
const formStub = {
name: 'TruckAppointmentForm',
props: ['modelValue', 'submitting', 'submitBlocked'],
emits: ['update:modelValue', 'submit', 'attachments'],
template: '<div data-form :data-blocked="String(submitBlocked)"><button data-submit @click="$emit(\'submit\')">保存</button></div>',
}
type ViewModel = {
form: SaveTruckAppointmentRequest
submit: () => Promise<void>
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
existingAppointment: TruckAppointmentResource | null
existingLookupError: { message: string } | null
checkedOceanBookingId: string
}
function mountView() {
return mount(TruckAppointmentCreateView, { global: { plugins: [ElementPlus], stubs: { TruckAppointmentForm: formStub, TruckAttachmentDialog: true } } })
}
describe('TruckAppointmentCreateView existing appointment reuse', () => {
beforeEach(() => {
vi.clearAllMocks()
for (const key of Object.keys(routing.query)) delete routing.query[key]
routing.replace.mockResolvedValue(undefined)
routing.push.mockResolvedValue(undefined)
vi.mocked(listTruckAppointments).mockResolvedValue(page([]))
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
})
it('automatically reuses a DRAFT appointment for a query-prefilled booking', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
const wrapper = mountView()
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ oceanBookingId: 'booking-1', page: 1, size: 20 })
expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
const wrapper = mountView()
await flushPromises()
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
const wrapper = mountView()
await flushPromises()
expect(wrapper.text()).toContain('预约查询失败')
expect(wrapper.text()).toContain('req-existing')
expect(wrapper.text()).toContain('trace-existing')
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignores a stale lookup response after the selected booking changes', async () => {
let resolveFirst!: (value: ReturnType<typeof page>) => void
let resolveSecond!: (value: ReturnType<typeof page>) => void
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
const { oceanBookingId } = query
if (oceanBookingId === 'booking-1') resolveFirst = resolve
else resolveSecond = resolve
}))
const wrapper = mountView()
const vm = wrapper.vm as unknown as ViewModel
vm.form.oceanBookingId = 'booking-1'
await wrapper.vm.$nextTick()
vm.form.oceanBookingId = 'booking-2'
await wrapper.vm.$nextTick()
resolveSecond(page([]))
await flushPromises()
resolveFirst(page([existing('DRAFT')]))
await flushPromises()
expect(vm.checkedOceanBookingId).toBe('booking-2')
expect(vm.existingAppointment).toBeNull()
expect(routing.replace).not.toHaveBeenCalled()
wrapper.unmount()
})
it('suppresses duplicate create clicks while the first request is pending', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
let resolveCreate!: (value: TruckAppointmentResource) => void
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
const wrapper = mountView()
await flushPromises()
const vm = wrapper.vm as unknown as ViewModel
const first = vm.submit()
const second = vm.submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
resolveCreate(existing('DRAFT'))
await Promise.all([first, second])
wrapper.unmount()
})
})
@@ -5,22 +5,36 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('starts with required booking and container choices empty until the user selects them', () => {
it('starts with only the required booking choice empty until the user selects it', () => {
expect(source).toContain('carrierId: null')
expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null')
expect(source).not.toContain('initialContainerBookingId')
expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
expect(source).not.toContain('receivableAmount')
expect(source).toContain('createTruckAppointment({ ...form })')
})
it('keeps the form visible and exposes the real backend error details', () => {
expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('<TruckAppointmentForm v-model="form"')
expect(source).toContain('<TruckAppointmentForm :model-value="form"')
expect(source).toContain('@update:model-value="updateForm"')
})
it('suppresses duplicate submits while the first create request is pending', () => {
expect(source).toContain('if (saving.value) return')
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
})
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
expect(source).toContain('该订舱已有拖车单')
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
expect(source).toContain("router.replace({ name: 'trucking-detail'")
expect(source).toContain('serial !== lookupSerial')
})
it('places the return action beside the relation note instead of in a separate header row', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -10,17 +10,70 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest } from '../types'
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
const existingAppointment = ref<TruckAppointmentResource | null>(null)
const existingLookupLoading = ref(false)
const existingLookupError = ref<ApiErrorDetails | null>(null)
const checkedOceanBookingId = ref('')
const navigatingToExisting = ref(false)
let lookupSerial = 0
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
const existingCanEdit = computed(() => ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(existingAppointment.value?.status || ''))
const submitBlocked = computed(() => existingLookupLoading.value || Boolean(existingLookupError.value) || Boolean(existingAppointment.value))
function updateForm(value: SaveTruckAppointmentRequest) { Object.assign(form, value) }
async function openExisting() {
if (!existingAppointment.value || navigatingToExisting.value) return
navigatingToExisting.value = true
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
finally { navigatingToExisting.value = false }
}
async function lookupExistingAppointment(oceanBookingId: string, autoNavigate = false) {
const normalizedId = oceanBookingId.trim()
const serial = ++lookupSerial
existingAppointment.value = null
existingLookupError.value = null
checkedOceanBookingId.value = ''
if (!normalizedId) { existingLookupLoading.value = false; return }
existingLookupLoading.value = true
try {
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
if (autoNavigate && existingAppointment.value) await openExisting()
} catch (cause) {
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
} finally {
if (serial === lookupSerial) existingLookupLoading.value = false
}
}
async function submit() {
if (saving.value || existingLookupLoading.value) return
const oceanBookingId = String(form.oceanBookingId || '')
if (oceanBookingId && checkedOceanBookingId.value !== oceanBookingId) await lookupExistingAppointment(oceanBookingId)
if (String(form.oceanBookingId || '') !== oceanBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
saving.value = true; error.value = null
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
finally { saving.value = false }
}
watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialOceanBookingId && String(value || '') === initialOceanBookingId)) }, { immediate: true })
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
</script>
<template>
<AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约">
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout>
</AdminPage>
@@ -32,18 +32,19 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("statuses.indexOf(status) === index")
})
it('initializes the required booking and container associations explicitly', () => {
expect(source).toContain("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
it('initializes the required booking association explicitly', () => {
expect(source).toContain("carrierId: null, oceanBookingId: null, plannedLoadingAt: null")
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('customerId')
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
expect(source).not.toContain('Object.assign(form, { ...result')
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
it('blocks dispatch until the released booking and frozen documents are present', () => {
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).not.toContain('关联柜次')
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
@@ -51,6 +52,13 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
})
it('keeps pickup booking-scoped and leaves real container maintenance to container/preload flows', () => {
expect(source).not.toContain('ElMessageBox.prompt')
expect(source).not.toContain('normalizeIso6346ContainerNumber')
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 })')
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
})
it('keeps the main detail available when attachments or history fail', () => {
expect(source).toContain('Promise.allSettled([')
expect(source).toContain("attachments.status === 'fulfilled'")
@@ -59,6 +67,14 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain('v-if="relatedError"')
})
it('never exposes container identity or operator database ids', () => {
expect(source).not.toContain('cabinetNo')
expect(source).not.toContain('containerNo')
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('loadingPlanId')
expect(source).not.toContain('item.operatorId')
})
it('reloads a cached detail after an error without showing stale resource data', () => {
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
expect(source).toContain('onActivated(() => {')
@@ -13,15 +13,14 @@ import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookin
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { isIso6346ContainerNumber } from '../iso6346'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
const bookingReadiness = ref<OceanBookingDetail | null>(null)
const relatedError = ref('')
let activatedOnce = false
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.billOfLadingNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无订舱关联' : '')
async function loadRelated() {
if (!resource.value) return
relatedError.value = ''
@@ -35,7 +34,7 @@ async function loadRelated() {
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
}
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
@@ -50,16 +49,17 @@ const dispatchBlockReason = computed(() => {
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
return ''
})
async function confirmStatusChange(status: TruckAppointmentStatus) {
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
}
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status || statusSaving.value) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
try { await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
}
onMounted(load)
onActivated(() => {
@@ -77,7 +77,7 @@ onActivated(() => {
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.containerNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱与柜次信息缺失' : '查看拖车预约详情' }}</p></div>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div>
@@ -92,7 +92,7 @@ onActivated(() => {
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template>
</DetailPageLayout>
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking'
import type { TruckAppointmentResource } from '../types'
@@ -19,9 +20,9 @@ vi.mock('@/modules/iam', () => ({
}))
const row: TruckAppointmentResource = {
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', receivableAmount: '3800.00', attachmentCount: 2, attachments: [],
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1',
warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
}
describe('TruckAppointmentsView', () => {
@@ -37,15 +38,13 @@ describe('TruckAppointmentsView', () => {
it('loads the real list and renders relationship snapshots', async () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '', containerType: '' })
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '' })
expect(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('CONT-1')
expect(wrapper.text()).toContain('CAB-1')
expect(wrapper.text()).toContain('OB-1')
expect(wrapper.text()).toContain('BL-1')
expect(wrapper.text()).toContain('义乌仓')
expect(wrapper.text()).toContain('船公司A')
expect(wrapper.text()).toContain('代理A')
expect(wrapper.text()).not.toContain('77')
expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
@@ -64,10 +63,10 @@ describe('TruckAppointmentsView', () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 9002"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true)
const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label'))
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
expect(labels.every((label) => !label?.includes('9002'))).toBe(true)
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
})
@@ -93,6 +92,48 @@ describe('TruckAppointmentsView', () => {
expect(source.match(/empty-text=/g)).toHaveLength(1)
})
it('renders the server empty state', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 1 })
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('暂无符合筛选条件的拖车预约')
})
it('marks the table busy while the list request is pending', async () => {
let resolveLoad!: (value: Awaited<ReturnType<typeof listTruckAppointments>>) => void
vi.mocked(listTruckAppointments).mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve }))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('section[aria-busy="true"]').exists()).toBe(true)
resolveLoad({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('section[aria-busy="false"]').exists()).toBe(true)
})
it('keeps a recoverable load error and retries without losing the page', async () => {
vi.mocked(listTruckAppointments).mockRejectedValueOnce(new ApiResponseError('候选服务暂不可用', 'A0503', 503, undefined, 'req-truck', 'trace-truck'))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('候选服务暂不可用')
expect(wrapper.text()).toContain('req-truck')
vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await wrapper.get('button').trigger('click')
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('TA-9001')
})
it('shows create only with the independent create permission', async () => {
let wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('新增拖车')
wrapper.unmount()
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create']
wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('新增拖车')
})
it('shows the two frequent row actions directly without a more menu', () => {
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
@@ -125,10 +166,12 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain("key: 'customerName'")
expect(source).toContain("key: 'updatedAt'")
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'receivableAmount'"))
expect(source.indexOf("key: 'receivableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
expect(source).not.toContain("key: 'containerNo'")
expect(source).not.toContain("key: 'cabinetNo'")
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'")
})
@@ -16,7 +16,7 @@ import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointme
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
@@ -28,8 +28,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '—' },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
@@ -38,7 +37,6 @@ const columns: DataTableColumn[] = [
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
{ key: 'store', label: '门店', minWidth: 140, slotName: 'store' },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) },
@@ -52,16 +50,14 @@ function formatAmount(value?: string | null, currency?: string | null) {
return currency ? `${display} ${currency}` : display
}
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
const identifier = [row.appointmentNo, row.id]
.map((value) => String(value ?? '').trim())
.find(Boolean)
const identifier = String(row.appointmentNo ?? '').trim()
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
}
function statusLabel(value: unknown) {
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '' }; if (page.value === 1) void load(); else page.value = 1 }
onMounted(() => { void load() })
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script>
+46 -16
View File
@@ -52,7 +52,17 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
return truckAppointmentActionLabels[action] || '状态更新'
}
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
export const TRUCK_ATTACHMENT_TYPES = ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER'] as const
export type TruckAttachmentType = typeof TRUCK_ATTACHMENT_TYPES[number]
export function isTruckAttachmentType(value: unknown): value is TruckAttachmentType {
return TRUCK_ATTACHMENT_TYPES.includes(value as TruckAttachmentType)
}
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {
const normalized = String(value ?? '').trim().toUpperCase()
return TRUCK_APPOINTMENT_CURRENCIES.includes(normalized as TruckAppointmentCurrency) ? normalized as TruckAppointmentCurrency : 'RMB'
}
export interface TruckAttachmentReference {
id?: string
@@ -89,9 +99,6 @@ export interface TruckAppointmentResource {
outboundOrderId?: string | null
oceanBookingId?: string | null
oceanBookingNo?: string | null
containerBookingId?: string | null
cabinetNo?: string | null
loadingPlanId?: string | null
warehouseId?: string | null
warehouseName?: string | null
shippingCompanyId?: string | null
@@ -121,14 +128,11 @@ export interface TruckAppointmentResource {
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
currency?: TruckAppointmentCurrency | null
poNo?: string | null
remark?: string | null
orderId?: string | null
orderNo?: string | null
containerNo?: string | null
relatedContainerType?: string | null
entrustingPartyName?: string | null
attachments?: TruckAttachmentReference[] | null
attachmentCount?: number | string | null
@@ -140,8 +144,6 @@ export interface TruckAppointmentResource {
export interface SaveTruckAppointmentRequest {
outboundOrderId?: string | null
oceanBookingId: string | null
containerBookingId: string | null
loadingPlanId?: string | null
warehouseId?: string | null
carrierId: string | null
carrierName?: string | null
@@ -165,19 +167,18 @@ export interface SaveTruckAppointmentRequest {
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
currency?: TruckAppointmentCurrency | null
poNo?: string | null
remark?: string | null
expectedVersion: number
}
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId',
'outboundOrderId', 'oceanBookingId', 'warehouseId',
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'receivableAmount', 'currency',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'currency',
'poNo', 'remark', 'expectedVersion',
] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[]
@@ -186,8 +187,37 @@ export interface TruckAppointmentQuery {
size?: number
keyword?: string
status?: TruckAppointmentStatus | ''
containerBookingId?: string | ''
containerType?: string | ''
oceanBookingId?: string | ''
}
export interface TruckAppointmentCandidateQuery {
page?: number
size?: number
keyword?: string
}
export interface TruckAppointmentCandidate {
oceanBookingId: string
oceanBookingNo: string
billOfLadingNo: string | null
warehouseId: string
warehouseName: string | null
channelCode: string | null
channelName: string | null
bookingDocumentReady: boolean
}
export interface TruckAppointmentCandidatePage {
records: TruckAppointmentCandidate[]
page: number
size: number
total: number
pages: number
}
export interface TruckAppointmentActionRequest {
expectedVersion: number
reason?: string | null
}
export interface TruckAppointmentPage {
@@ -8,6 +8,7 @@ import {
downloadPlatformProductImage,
listPlatformUnifiedOrders,
listPlatformUnifiedOrderFeeDetails,
updatePlatformUnifiedOrderDeclaration,
} from './unifiedOrders'
vi.mock('@/shared/api/http', () => ({
@@ -147,6 +148,18 @@ describe('unified orders API contract', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } })
})
it('updates customer declaration fields through the versioned tenant-scoped contract', async () => {
const request = {
expectedVersion: 7, customerRemark: '易碎', expectedArrivalAt: '2026-09-06T10:00:00',
channelCode: 'US-SEA', channelName: '美西海运',
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
}
vi.mocked(http.put).mockResolvedValue({ data: { orderNo: 'FL-1', inboundCode: 'RK-1', previousVersion: 7, version: 8, channelChangedBefore: false, channelChanged: true, changes: [] } })
await expect(updatePlatformUnifiedOrderDeclaration('208', '90001', request)).resolves.toMatchObject({ version: 8, channelChanged: true })
expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/90001/declaration', request, { params: { tenantId: '208' } })
})
it('loads fee detail rows from the inbound fee-detail contract', async () => {
vi.mocked(http.get).mockResolvedValue({ data: [{ id: '1', inboundId: '88', reviewStatus: 'APPROVED', amount: 100 }] })
await expect(listPlatformUnifiedOrderFeeDetails('208', '88')).resolves.toHaveLength(1)
@@ -93,6 +93,7 @@ export interface UnifiedOrderResource {
customerName: string | null
channelCode: string | null
channelName: string | null
channelChanged?: boolean
receivingWarehouseId: number | string | null
receivingWarehouseName: string | null
destinationWarehouseId: number | string | null
@@ -102,6 +103,7 @@ export interface UnifiedOrderResource {
inboundCode: string | null
expectedShipDate: string | null
totalPackageCount: number | null
expectedVolumeCbm?: number | null
locationCodes: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
@@ -170,6 +172,7 @@ export interface UnifiedOrderTimelineEvent {
export interface UnifiedOrderDetailOrderSnapshot {
id: number | string | null
version: number | null
tenantId: number | string | null
orderNo: string | null
customerId: number | string | null
@@ -329,6 +332,58 @@ function id(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value)
}
export interface UnifiedOrderDeclarationCargo {
itemNo: number
cargoName: string
cargoNameEn?: string | null
skuNo?: string | null
model?: string | null
packageCount: number
totalQuantity: number
lengthCm: number
widthCm: number
heightCm: number
grossWeightKg: number
volumeCbm: number
netWeightKg?: number | null
hsCode?: string | null
customsHsCode?: string | null
unitsPerPackage?: number | null
unitDeclaredValue?: number | null
declaredValue?: number | null
currency?: string | null
material?: string | null
usage?: string | null
brand?: string | null
cargoRemark?: string | null
}
export interface UpdateUnifiedOrderDeclarationRequest {
expectedVersion: number
customerRemark?: string | null
expectedArrivalAt?: string | null
channelCode: string
channelName: string
cargoes: UnifiedOrderDeclarationCargo[]
}
export interface UnifiedOrderDeclarationChange {
itemNo: number | null
field: string
beforeValue: string | null
afterValue: string | null
}
export interface UpdateUnifiedOrderDeclarationResult {
orderNo: string | null
inboundCode: string | null
previousVersion: number
version: number
channelChangedBefore: boolean
channelChanged: boolean
changes: UnifiedOrderDeclarationChange[]
}
function optionalText(value: string | null | undefined) {
const normalized = value?.trim()
return normalized || null
@@ -357,6 +412,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
childCount: integer(raw.relation.childCount, 0),
} : null,
totalPackageCount: nullableInteger(raw.totalPackageCount),
expectedVolumeCbm: numberValue(raw.expectedVolumeCbm),
channelChanged: Boolean(raw.channelChanged),
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
totalPalletCount: nullableInteger(raw.totalPalletCount),
@@ -413,7 +470,26 @@ export async function getPlatformUnifiedOrder(tenantId: string, orderId: string)
{ params: { tenantId } },
)
const result = unwrapApiResponse(response)
return { ...result, summary: normalizeRow(result.summary) }
return {
...result,
summary: normalizeRow(result.summary),
order: {
...result.order,
order: result.order.order ? { ...result.order.order, version: nullableInteger(result.order.order.version) } : null,
},
}
}
export function updatePlatformUnifiedOrderDeclaration(
tenantId: string,
orderId: string,
request: UpdateUnifiedOrderDeclarationRequest,
) {
return http.put<ApiEnvelope<UpdateUnifiedOrderDeclarationResult>, ApiEnvelope<UpdateUnifiedOrderDeclarationResult>>(
`${unifiedOrdersApiPath}/${orderId}/declaration`,
request,
{ params: { tenantId } },
).then(unwrapApiResponse)
}
export function downloadPlatformProductImage(tenantId: string, fileId: string) {
@@ -0,0 +1,75 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders'
import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue'
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() }))
const detail = {
summary: {
orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE',
receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓',
channelCode: 'OLD', channelName: '原渠道', channelChanged: false,
expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注',
},
order: {
order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' },
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
parties: [], internalNotes: [], statusHistory: [],
},
timeline: [],
} as never
function createWrapper() {
return mount(OrderDeclarationEditorDialog, {
props: { modelValue: true, detail },
global: {
plugins: [ElementPlus],
stubs: {
AppDialog: {
props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" @click="$emit(\'confirm\')">保存</button></section>',
},
LoadErrorState: { props: ['title', 'message'], template: '<div class="load-error">{{ title }} {{ message }}<slot name="details" /></div>' },
ApiErrorMeta: true,
},
},
})
}
describe('OrderDeclarationEditorDialog', () => {
beforeEach(() => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
})
it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never)
const wrapper = createWrapper()
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
expect(wrapper.text()).toContain('RK-001')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('NEW')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object))
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' }))
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
})
it('preserves the editor and entered values when the versioned update fails', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试'))
const wrapper = createWrapper()
const inputs = wrapper.findAll('input')
await inputs[1]!.setValue('保留填写的渠道名')
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(wrapper.find('.load-error').exists()).toBe(true)
expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 {
updatePlatformUnifiedOrderDeclaration,
type UnifiedOrderDeclarationCargo,
type UnifiedOrderDetailResource,
} from '@/modules/unified-orders/api/unifiedOrders'
const props = defineProps<{
modelValue: boolean
detail: UnifiedOrderDetailResource
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: []
}>()
type EditableCargo = UnifiedOrderDeclarationCargo & { key: string }
const saving = ref(false)
const submitError = ref<ApiErrorDetails | null>(null)
const originalChannel = reactive({ code: '', name: '' })
const form = reactive({
expectedVersion: 0,
customerRemark: '',
expectedArrivalAt: '',
channelCode: '',
channelName: '',
cargoes: [] as EditableCargo[],
})
function text(row: Record<string, unknown>, key: string) {
const value = row[key]
return value === null || value === undefined ? '' : String(value)
}
function number(row: Record<string, unknown>, key: string, fallback = 0) {
const parsed = Number(row[key])
return Number.isFinite(parsed) ? parsed : fallback
}
function optionalNumber(row: Record<string, unknown>, key: string) {
const value = row[key]
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function resetForm() {
const snapshot = props.detail.order.order
form.expectedVersion = Number(snapshot?.version ?? 0)
form.customerRemark = snapshot?.customerRemark || props.detail.summary.customerRemark || ''
form.expectedArrivalAt = props.detail.summary.expectedArrivalAt || ''
form.channelCode = snapshot?.channelCode || props.detail.summary.channelCode || ''
form.channelName = snapshot?.channelName || props.detail.summary.channelName || ''
originalChannel.code = form.channelCode.trim()
originalChannel.name = form.channelName.trim()
form.cargoes = props.detail.order.cargoes.map((source, index) => {
const row = source as Record<string, unknown>
const itemNo = number(row, 'itemNo', index + 1)
return {
key: `${itemNo}-${index}`,
itemNo,
cargoName: text(row, 'cargoName'), cargoNameEn: text(row, 'cargoNameEn'),
skuNo: text(row, 'skuNo'), model: text(row, 'model'),
packageCount: number(row, 'packageCount'), totalQuantity: number(row, 'totalQuantity'),
lengthCm: number(row, 'lengthCm'), widthCm: number(row, 'widthCm'), heightCm: number(row, 'heightCm'),
grossWeightKg: number(row, 'grossWeightKg'), volumeCbm: number(row, 'volumeCbm'),
netWeightKg: optionalNumber(row, 'netWeightKg'),
hsCode: text(row, 'hsCode'), customsHsCode: text(row, 'customsHsCode'),
unitsPerPackage: optionalNumber(row, 'unitsPerPackage'),
unitDeclaredValue: optionalNumber(row, 'unitDeclaredValue'), declaredValue: optionalNumber(row, 'declaredValue'),
currency: text(row, 'currency'), material: text(row, 'material'), usage: text(row, 'usage'),
brand: text(row, 'brand'), cargoRemark: text(row, 'cargoRemark') || text(row, 'remark'),
}
})
submitError.value = null
}
watch(() => props.modelValue, (open) => { if (open) resetForm() }, { immediate: true })
function nullable(value: string | null | undefined) {
return value?.trim() || null
}
function validate() {
if (!form.channelCode.trim() || !form.channelName.trim()) return '渠道编码和渠道名称均不能为空'
if (!form.cargoes.length) return '订单缺少货品明细,不能保存'
for (const cargo of form.cargoes) {
if (!cargo.cargoName.trim()) return `第 ${cargo.itemNo} 项中文品名不能为空`
if (cargo.packageCount < 1 || cargo.totalQuantity < 1) return `第 ${cargo.itemNo} 项箱数和总数量必须大于 0`
if ([cargo.lengthCm, cargo.widthCm, cargo.heightCm, cargo.grossWeightKg, cargo.volumeCbm].some((value) => !(value > 0))) {
return `第 ${cargo.itemNo} 项尺寸、毛重和体积必须大于 0`
}
}
return ''
}
async function save() {
const validationMessage = validate()
if (validationMessage) { ElMessage.warning(validationMessage); return }
const channelChanged = form.channelCode.trim() !== originalChannel.code || form.channelName.trim() !== originalChannel.name
if (channelChanged) {
try {
await ElMessageBox.confirm(
'渠道变更后,系统会重新校验分柜、同柜及计费约束;现有入仓码和箱唛不会改变。确认继续吗?',
'确认变更渠道',
{ type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
)
} catch { return }
}
const snapshot = props.detail.order.order
if (!snapshot?.version && snapshot?.version !== 0) { ElMessage.error('订单版本缺失,请刷新详情后重试'); return }
saving.value = true
submitError.value = null
try {
await updatePlatformUnifiedOrderDeclaration(String(props.detail.summary.tenantId), String(props.detail.summary.orderId), {
expectedVersion: form.expectedVersion,
customerRemark: nullable(form.customerRemark),
expectedArrivalAt: nullable(form.expectedArrivalAt),
channelCode: form.channelCode.trim(),
channelName: form.channelName.trim(),
cargoes: form.cargoes.map(({ key: _key, ...cargo }) => ({
...cargo,
cargoName: cargo.cargoName.trim(), cargoNameEn: nullable(cargo.cargoNameEn),
skuNo: nullable(cargo.skuNo), model: nullable(cargo.model), hsCode: nullable(cargo.hsCode),
customsHsCode: nullable(cargo.customsHsCode), currency: nullable(cargo.currency)?.toUpperCase() || null,
material: nullable(cargo.material), usage: nullable(cargo.usage), brand: nullable(cargo.brand),
cargoRemark: nullable(cargo.cargoRemark),
})),
})
ElMessage.success('客户申报资料已更新')
emit('update:modelValue', false)
emit('updated')
} catch (error) {
submitError.value = getApiErrorDetails(error, '客户申报资料保存失败,请核对权限、订单状态或刷新版本后重试')
} finally {
saving.value = false
}
}
</script>
<template>
<AppDialog
:model-value="modelValue"
title="编辑客户申报资料"
width="min(1500px, calc(100vw - 36px))"
confirm-text="保存申报资料"
:confirm-loading="saving"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@confirm="save"
>
<div class="declaration-editor">
<el-alert type="warning" :closable="false" title="仅修改客户原始申报资料;仓库/PDA 实测数据不会被覆盖。国内收货仓和中转仓不可修改,如需变更请重新下单。" />
<LoadErrorState v-if="submitError" compact title="保存失败,当前填写内容已保留" :message="submitError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="submitError" /></template>
</LoadErrorState>
<el-descriptions :column="4" border class="readonly-facts">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="国内收货仓">{{ detail.summary.receivingWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="目的仓/中转仓">{{ detail.summary.destinationWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ form.expectedVersion }}</el-descriptions-item>
</el-descriptions>
<el-form label-position="top" class="order-fields">
<el-row :gutter="14">
<el-col :span="6"><el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit /></el-form-item></el-col>
</el-row>
</el-form>
<h3>货品申报明细 <small>不允许新增、删除或调整明细序号</small></h3>
<div class="cargo-list">
<section v-for="cargo in form.cargoes" :key="cargo.key" class="cargo-card">
<header>第 {{ cargo.itemNo }} 项</header>
<div class="cargo-grid">
<label><span>中文品名 *</span><el-input v-model="cargo.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargo.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargo.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargo.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargo.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargo.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargo.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargo.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargo.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargo.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargo.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargo.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargo.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargo.customsHsCode" maxlength="20" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargo.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargo.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargo.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>币种</span><el-input v-model="cargo.currency" maxlength="3" placeholder="USD" /></label>
<label><span>材质</span><el-input v-model="cargo.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargo.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargo.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargo.cargoRemark" maxlength="500" /></label>
</div>
</section>
</div>
</div>
</AppDialog>
</template>
<style scoped>
.declaration-editor { display: grid; gap: 14px; max-height: calc(100vh - 190px); overflow: auto; padding-right: 4px; }
.readonly-facts { margin-top: 2px; }
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
h3 { margin: 0; color: #344054; font-size: 15px; } h3 small { margin-left: 8px; color: #98a2b3; font-weight: 400; }
.cargo-list { display: grid; gap: 12px; }
.cargo-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.cargo-card > header { padding: 9px 12px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: #475467; font-weight: 600; }
.cargo-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px 12px; padding: 12px; }
.cargo-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-grid label.wide { grid-column: span 2; }
.cargo-grid :deep(.el-input-number), .cargo-grid :deep(.el-input) { width: 100%; }
@media (max-width: 1200px) { .cargo-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
</style>
@@ -1,6 +1,8 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import { createPlatformExportTask } from '@/modules/downloads'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({
@@ -11,6 +13,8 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
cancelPlatformCoLoadGroup: vi.fn(),
@@ -45,7 +49,7 @@ function mountView() {
AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-testid="data-table" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-actions" /></div>' },
'el-button': { template: '<button><slot /></button>' },
},
},
@@ -55,6 +59,8 @@ function mountView() {
describe('UnifiedOrdersView list request coordination', () => {
beforeEach(() => {
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '国内仓订单', taskNo: 'EXP-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it('loads once initially and once for query, reset, status tab and pagination', async () => {
@@ -135,4 +141,48 @@ describe('UnifiedOrdersView list request coordination', () => {
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ fullContainer: undefined }))
})
it('exports the selected business order numbers without loading other pages', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('selection-change', [
{ orderId: '1', orderNo: 'ORDER-1', tenantId: '8' },
{ orderId: '2', orderNo: 'ORDER-2', tenantId: '8' },
])
await wrapper.vm.$nextTick()
await wrapper.findAll('button').find((button) => button.text().includes('导出'))!.trigger('click')
await flushPromises()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST', format: 'XLSX', taskName: '国内仓订单-已选2票',
parameters: { selectedOrderNos: ['ORDER-1', 'ORDER-2'] },
})
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
})
it('exports every result matching the applied filters instead of the current page', async () => {
const wrapper = mountView()
await flushPromises()
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), inboundCode: ' IN-8 ', loaded: 'false', createdRange: ['2026-08-01', '2026-08-31'],
})
toolbar.vm.$emit('search')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text() === '导出')!.trigger('click')
await flushPromises()
expect(createPlatformExportTask).toHaveBeenCalledWith(expect.objectContaining({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST', taskName: '国内仓订单-当前筛选',
parameters: expect.objectContaining({
inboundCode: 'IN-8', loaded: false,
createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
}),
}))
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({
parameters: expect.objectContaining({ page: expect.anything(), size: expect.anything() }),
}))
})
})
@@ -98,7 +98,7 @@ describe('UnifiedOrdersView contract', () => {
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate',
'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
'expectedVolumeCbm', 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
'shipmentId', 'referenceId', 'containerNo', 'cabinetNo',
'effectiveSailingAt', 'effectivePortArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
]
@@ -117,7 +117,9 @@ describe('UnifiedOrdersView contract', () => {
expect(columnSource).not.toContain("label: '配载状态'")
expect(columnSource).not.toContain("key: 'recommendedPalletType'")
expect(columnSource).toContain("key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间'")
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(14)
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(13)
expect(columnSource).toContain("key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 160, slotName: 'field' }")
expect(columnSource).toContain("key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '预计体积(m³)', minWidth: 130, slotName: 'field' }")
expect(columnSource).toContain("key: 'effectiveSailingAt', prop: 'effectiveSailingAt', label: '开船时间', minWidth: 180, slotName: 'field' }")
expect(columnSource).toContain("key: 'effectivePortArrivalAt', prop: 'effectivePortArrivalAt', label: '到港时间', minWidth: 180, slotName: 'field' }")
expect(source).toContain('preference-key="unified-orders-table-v2"')
@@ -162,6 +164,15 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<SmartLoadingDialog v-model="smartLoadingOpen" />')
})
it('uses one asynchronous export entry for either selected orders or the applied filter snapshot', () => {
expect(source).toContain("exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST'")
expect(source).toContain("parameters: selectedOrderNos.length")
expect(source).toContain('? { selectedOrderNos }')
expect(source).toContain(': exportFilterParameters()')
expect(source).toContain("can('platform:order:export') && can('platform:download:list')")
expect(source).not.toContain('page: page.value,\n size: pageSize.value,\n })\n const task = await createPlatformExportTask')
})
it('states only the domestic warehouse and channel qualification without local destination or split blocking', () => {
const createSource = source.slice(source.indexOf('function openCoLoadCreate'), source.indexOf('async function batchApproveOrders'))
const dialogSource = source.slice(source.indexOf('<AppDialog v-model="coLoadCreateOpen"'), source.indexOf('<AppDrawer v-model="relationOpen"'))
@@ -352,7 +363,7 @@ describe('UnifiedOrdersView contract', () => {
})
it('renders order detail as a routed full page instead of a drawer', () => {
expect(source).toContain('<template v-if="orderId">')
expect(source).toContain('<template v-if="isDetailRoute">')
expect(source).toContain('class="order-detail-header"')
expect(source).toContain('aria-label="订单详情模块"')
expect(source).toContain("{ key: 'basic', label: '基本信息'")
@@ -401,6 +412,29 @@ describe('UnifiedOrdersView contract', () => {
expect(routeSource).not.toContain('unified-order-workflow')
})
it('only exposes declaration editing on approved editable details and marks channel changes', () => {
expect(source).toContain("detail.value.summary.mainStatus === 'WAIT_MEASURE'")
expect(source).toContain("can('platform:order:declaration:edit')")
expect(source).toContain('编辑客户申报资料')
expect(source).toContain('<OrderDeclarationEditorDialog')
expect(source).toContain('曾变更渠道')
expect(source).toContain('@updated="handleDeclarationUpdated"')
})
it('supports the measurement-review deep link with an inbound-code lookup and recoverable return path', () => {
expect(routeSource).toContain("name: 'unified-order-detail-by-inbound-code'")
expect(routeSource.indexOf("path: 'orders/inbound/:inboundCode'")).toBeLessThan(routeSource.indexOf("path: 'orders/:orderId'"))
expect(source).toContain("route.query.tab === 'measurement'")
expect(source).toContain("route.query.from === 'measurement-review'")
expect(source).toContain('getPlatformInboundCodeAssociation(inboundCode)')
expect(source).toContain("activeDetailTab.value = 'measurement'")
expect(source).toContain('await loadMeasurementDetails()')
expect(source).toContain("router.push({ name: 'inbound-measurement-reviews' })")
expect(source).toContain('router.back()')
expect(source).toContain('可能无权查看或目标已不存在')
expect(source).toContain('返回测量审核列表')
})
it('keeps detail tabs compact on one horizontally scrollable row', () => {
expect(source).toContain('.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden;')
expect(source).toContain('min-width: max-content; min-height: 56px; flex: 0 0 auto;')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -19,11 +19,14 @@ import {
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue'
import OrderDeclarationEditorDialog from '@/modules/unified-orders/components/OrderDeclarationEditorDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, type LoadingAssociationSummary, type OverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -106,6 +109,7 @@ const warehouseLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const declarationEditorOpen = ref(false)
const activeDetailTab = ref('basic')
const automaticFee = ref<AutomaticFeeSummary | null>(null)
const otherFeeSummary = ref<OtherFeeSummary>({ currency: 'CNY', approvedReceivable: 0, approvedPayable: 0 })
@@ -123,6 +127,7 @@ const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const batchApprovalSaving = ref(false)
const exportSubmitting = ref(false)
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
const coLoadCreateOpen = ref(false)
@@ -161,8 +166,10 @@ const overseasRelationship = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const overseasRelationshipError = ref<ApiErrorDetails | null>(null)
const subsequentFulfillmentLoading = ref(false)
const resolvedDetailTarget = ref<{ orderId: string; tenantId: string } | null>(null)
let detailLoadingKey = ''
let detailRequestSerial = 0
let detailAssociationRequestSerial = 0
let measurementRequestSerial = 0
let measurementPreviewRequestSerial = 0
let subsequentFulfillmentRequestSerial = 0
@@ -170,9 +177,15 @@ const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const businessInboundCode = computed(() => route.name === 'unified-order-detail-by-inbound-code' && typeof route.params.inboundCode === 'string'
? route.params.inboundCode.trim()
: '')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : resolvedDetailTarget.value?.orderId || '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : resolvedDetailTarget.value?.tenantId || '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
const isDetailRoute = computed(() => Boolean(orderId.value || businessInboundCode.value))
const requestedDetailTab = computed(() => route.query.tab === 'measurement' ? 'measurement' : 'basic')
const fromMeasurementReview = computed(() => route.query.from === 'measurement-review')
function normalizedIdentifier(value: unknown) {
const identifier = String(value ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
@@ -261,17 +274,18 @@ const queryFields = computed(() => showAdvancedFilters.value
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') ? [{
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') || can('platform:order:export') ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value,
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value && !exportSubmitting.value,
} satisfies DataTableColumn] : []),
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 260, slotName: 'inboundCode' },
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' },
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 180, slotName: 'channel' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
{ key: 'expectedVolumeCbm', prop: 'expectedVolumeCbm', label: '预计体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 100, slotName: 'field' },
{ key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 160, slotName: 'field' },
{ key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false },
@@ -282,7 +296,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'expectedShipDate', prop: 'expectedShipDate', label: '预计出运日期', minWidth: 150, slotName: 'field' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到货时间', minWidth: 160, slotName: 'field' },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'skuNos', prop: 'skuNos', label: 'SKU 编码', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'totalQuantity', prop: 'totalQuantity', label: '货物总数量', minWidth: 120, slotName: 'field', defaultVisible: false },
@@ -349,6 +363,19 @@ function can(permission: string) {
return access.can(permission)
}
const canEditDeclaration = computed(() => Boolean(
detail.value
&& detail.value.summary.mainStatus === 'WAIT_MEASURE'
&& detail.value.order.order?.version !== null
&& detail.value.order.order?.version !== undefined
&& can('platform:order:declaration:edit'),
))
async function handleDeclarationUpdated() {
activeDetailTab.value = 'basic'
await loadDetail(true)
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || '客户名称待补充'
}
@@ -1039,6 +1066,88 @@ function closeDetail() {
void router.push({ name: 'unified-orders' })
}
function returnToMeasurementReviews() {
if (window.history.length > 1) router.back()
else void router.push({ name: 'inbound-measurement-reviews' })
}
async function resolveBusinessDetailTarget() {
const inboundCode = businessInboundCode.value
const serial = ++detailAssociationRequestSerial
resolvedDetailTarget.value = null
detail.value = null
detailError.value = null
if (!inboundCode) {
detailError.value = { message: '入仓码缺失,无法定位对应订单' }
return
}
detailLoading.value = true
try {
const association = await getPlatformInboundCodeAssociation(inboundCode)
if (serial !== detailAssociationRequestSerial || inboundCode !== businessInboundCode.value) return
if (!association.orderId || !association.customerId) {
detailError.value = { message: `未找到入仓码 ${inboundCode} 对应的订单,请返回测量审核列表重新打开` }
return
}
resolvedDetailTarget.value = { orderId: String(association.orderId), tenantId: String(association.customerId) }
} catch (error) {
if (serial !== detailAssociationRequestSerial || inboundCode !== businessInboundCode.value) return
detailError.value = getApiErrorDetails(error, `入仓码 ${inboundCode} 的订单关联加载失败,可能无权查看或目标已不存在`)
} finally {
if (serial === detailAssociationRequestSerial && !resolvedDetailTarget.value) detailLoading.value = false
}
}
function exportFilterParameters() {
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
return {
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
}
}
async function exportOrders() {
if (exportSubmitting.value) return
const selectedOrderNos = selectedOrders.value.map((row) => row.orderNo?.trim() || '')
if (selectedOrderNos.some((orderNo) => !orderNo)) {
ElMessage.error('所选订单缺少订单号,请刷新列表后重试')
return
}
exportSubmitting.value = true
try {
const task = await createPlatformExportTask({
exportType: 'DOMESTIC_WAREHOUSE_ORDER_LIST',
format: 'XLSX',
taskName: selectedOrderNos.length ? `国内仓订单-已选${selectedOrderNos.length}票` : '国内仓订单-当前筛选',
parameters: selectedOrderNos.length
? { selectedOrderNos }
: exportFilterParameters(),
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '订单导出任务创建失败'))
} finally {
exportSubmitting.value = false
}
}
async function loadDetail(force = false) {
if (!orderId.value) return
const tenantId = detailTenantId.value
@@ -1064,6 +1173,10 @@ async function loadDetail(force = false) {
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = response
await loadCargoImagePreviews(response, serial)
if (requestedDetailTab.value === 'measurement') {
activeDetailTab.value = 'measurement'
await loadMeasurementDetails()
}
} catch (error) {
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = null
@@ -1075,13 +1188,18 @@ async function loadDetail(force = false) {
}
watch([page, pageSize], () => { void loadOrders() })
watch(businessInboundCode, (value) => {
detailAssociationRequestSerial += 1
resolvedDetailTarget.value = null
if (value) void resolveBusinessDetailTarget()
}, { immediate: true })
watch(detailRouteKey, (value) => {
if (value) {
activeDetailTab.value = 'basic'
activeDetailTab.value = requestedDetailTab.value
detail.value = null
detailError.value = null
void loadDetail()
} else {
} else if (!businessInboundCode.value) {
detailRequestSerial += 1
subsequentFulfillmentRequestSerial += 1
detail.value = null
@@ -1095,9 +1213,10 @@ watch(measurementPreviewOpen, (open) => {
onMounted(() => {
void loadCustomers()
void loadWarehouses()
if (!orderId.value) void loadOrders()
if (!isDetailRoute.value) void loadOrders()
})
onBeforeUnmount(() => {
detailAssociationRequestSerial += 1
measurementRequestSerial += 1
releaseMeasurementPreview()
releaseCargoImagePreviews()
@@ -1107,11 +1226,11 @@ onBeforeUnmount(() => {
<template>
<AdminPage>
<section class="business-page unified-orders-page">
<template v-if="orderId">
<template v-if="isDetailRoute">
<DetailPageLayout ariaLabel="统一订单详情">
<header class="order-detail-header">
<div class="order-detail-heading">
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
<el-button link class="back-button" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
<div class="order-title">
<h1>{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
@@ -1123,8 +1242,9 @@ onBeforeUnmount(() => {
</div>
</header>
<LoadErrorState v-if="detailError" class="detail-load-error" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail">
<LoadErrorState v-if="detailError" class="detail-load-error" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="businessInboundCode ? resolveBusinessDetailTarget() : loadDetail(true)">
<template #details><ApiErrorMeta :details="detailError" /></template>
<template v-if="fromMeasurementReview" #actions><el-button type="primary" plain :loading="detailLoading" @click="businessInboundCode ? resolveBusinessDetailTarget() : loadDetail(true)">重新加载</el-button><el-button @click="returnToMeasurementReviews">返回测量审核列表</el-button></template>
</LoadErrorState>
<div v-else v-loading="detailLoading" class="order-detail-content">
@@ -1156,7 +1276,7 @@ onBeforeUnmount(() => {
<div><dt>目的仓邮编</dt><dd>{{ detail.summary.destinationPostalCode || '—' }}</dd></div>
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }} <el-tag v-if="detail.summary.channelChanged" size="small" type="warning">曾变更渠道</el-tag></dd></div>
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
@@ -1208,7 +1328,7 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="activeDetailTab === 'cargo'">
<div class="panel-title"><div><h2>货品信息</h2><p>共 {{ detail.order.cargoes.length }} 条</p></div><PackageOpen :size="19" /></div>
<div class="panel-title"><div><h2>货品信息</h2><p>共 {{ detail.order.cargoes.length }} 条</p></div><el-button v-if="canEditDeclaration" type="primary" size="small" @click="declarationEditorOpen = true">编辑客户申报资料</el-button></div>
<div class="detail-table-wrap">
<el-table :data="detail.order.cargoes" border stripe empty-text="暂无货品信息">
<el-table-column prop="itemNo" label="#" width="64" />
@@ -1304,6 +1424,7 @@ onBeforeUnmount(() => {
<el-alert v-if="!can('platform:firstleg:inbound:measure:view')" title="当前账号缺少测量查看权限" type="warning" :closable="false" show-icon />
<LoadErrorState v-else-if="measurementError" title="测量数据暂时无法加载" :message="measurementError.message" :loading="measurementLoading" @retry="loadMeasurementDetails">
<template #details><ApiErrorMeta :details="measurementError" /></template>
<template v-if="fromMeasurementReview" #actions><el-button type="primary" plain :loading="measurementLoading" @click="loadMeasurementDetails">重新加载</el-button><el-button @click="returnToMeasurementReviews">返回测量审核列表</el-button></template>
</LoadErrorState>
<template v-else>
<section class="measurement-section">
@@ -1428,6 +1549,7 @@ onBeforeUnmount(() => {
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-actions>
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
<el-button type="primary" plain :loading="coLoadSaving" :disabled="loading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
@@ -1464,6 +1586,7 @@ onBeforeUnmount(() => {
</template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #channel="{ row }"><div class="channel-cell"><span>{{ (row as UnifiedOrderResource).channelName || '—' }}</span><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong></div></template>
@@ -1519,6 +1642,13 @@ onBeforeUnmount(() => {
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
</AppDialog>
<OrderDeclarationEditorDialog
v-if="detail"
v-model="declarationEditorOpen"
:detail="detail"
@updated="handleDeclarationUpdated"
/>
<AppDialog
v-model="measurementPreviewOpen"
:title="measurementPreviewTitle"
+11
View File
@@ -12,6 +12,17 @@ export const unifiedOrderRoutes = [
permissionAnyOf: ['platform:firstleg:inbound:list'],
},
},
{
path: 'orders/inbound/:inboundCode',
name: 'unified-order-detail-by-inbound-code',
component: () => import('./pages/UnifiedOrdersView.vue'),
meta: {
title: '订单详情',
description: '按入仓码查看订单测量数据和履约详情',
permission: 'platform:order:view',
permissionAnyOf: ['platform:order:list', 'platform:firstleg:inbound:view'],
},
},
{
path: 'orders/:orderId',
name: 'unified-order-detail',
@@ -13,6 +13,7 @@ describe('unified order status labels', () => {
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
expect(unifiedOrderHistoryActionLabel('DECLARATION_UPDATE')).toBe('客户申报资料变更')
})
it('never exposes unknown backend enums', () => {
+1
View File
@@ -47,6 +47,7 @@ const historyActionLabels: Record<string, string> = {
UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过',
UI_FIXTURE_INBOUND_BEGIN: '开始入库',
STATUS_UPDATE: '状态更新',
DECLARATION_UPDATE: '客户申报资料变更',
}
const fulfillmentRoleLabels: Record<string, string> = {
@@ -11,7 +11,6 @@ import {
getPicking,
getPreload,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
smartSplit,
uploadEvidenceFile,
@@ -131,7 +130,6 @@ describe('unified order workflow API contract', () => {
await recommendSmartLoading('91', 4)
await adjustSmartLoadingItem('91', '92', { packageCount: 2, expectedPlanVersion: 4, expectedItemVersion: 1 })
await deleteSmartLoadingItem('91', '92', { expectedPlanVersion: 4, expectedItemVersion: 1 })
await pushSmartLoadingPreload('91', 4)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans/91/assignments', {
inboundId: '71', inboundDetailId: '72', packageCount: 3, expectedVersion: 4,
operationId: expect.stringContaining('smart-loading-assign'),
@@ -145,8 +143,5 @@ describe('unified order workflow API contract', () => {
expect(http.delete).toHaveBeenCalledWith('/platform/v1/first-leg/smart-loading/plans/91/items/92', {
data: expect.objectContaining({ expectedPlanVersion: 4, expectedItemVersion: 1, operationId: expect.stringContaining('smart-loading-delete') }),
})
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/first-leg/smart-loading/plans/91/push-preload', { expectedVersion: 4 }, {
headers: { 'Idempotency-Key': expect.stringContaining('smart-loading-push') },
})
})
})
@@ -356,13 +356,6 @@ export async function getSmartLoadingPlanByBooking(bookingId: string | number) {
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string | number, remark?: string) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans`, { bookingId: String(bookingId), remark: remark || undefined },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string | number, request: Omit<AssignSmartLoadingItemRequest, 'operationId'>) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/assignments`,
@@ -403,14 +396,6 @@ export async function deleteSmartLoadingItem(
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string | number, expectedVersion: number) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/push-preload`, { expectedVersion },
{ headers: { 'Idempotency-Key': idempotencyKey('smart-loading-push', planId) } },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Check, Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -13,12 +13,10 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
deleteSmartLoadingItem,
getContainerBooking,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
type ContainerBooking,
type SmartLoadingCandidate,
@@ -45,7 +43,6 @@ const conflictRefreshed = ref(false)
const assignOpen = ref(false)
const adjustOpen = ref(false)
const removeOpen = ref(false)
const pushOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedItem = ref<SmartLoadingPlanItem | null>(null)
const packageCount = ref(1)
@@ -53,10 +50,8 @@ const filters = ref<QueryModel>({ keyword: '', eligibility: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const canView = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCreate = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:push'))
const editable = computed(() => plan.value?.status === 'EDITING')
const usedVolume = computed(() => plan.value?.items.reduce((sum, item) => sum + item.volumeCbm, 0) ?? 0)
const usedWeight = computed(() => plan.value?.items.reduce((sum, item) => sum + item.grossWeightKg, 0) ?? 0)
@@ -199,10 +194,6 @@ async function runAction(name: string, operation: () => Promise<SmartLoadingPlan
}
}
async function createPlan() {
await runAction('create', () => createSmartLoadingPlan(props.bookingId), '装柜草稿已创建')
}
async function recommend() {
if (!plan.value) return
await runAction('recommend', () => recommendSmartLoading(plan.value!.id, plan.value!.version), '智能建议已生成')
@@ -258,12 +249,6 @@ async function confirmRemove() {
if (success) removeOpen.value = false
}
async function confirmPush() {
if (!plan.value) return
const success = await runAction('push', () => pushSmartLoadingPreload(plan.value!.id, plan.value!.version), '装柜计划已推送预装')
if (success) pushOpen.value = false
}
function applyFilters(model: QueryModel) {
appliedFilters.value = { keyword: model.keyword || '', eligibility: model.eligibility || '' }
}
@@ -278,9 +263,8 @@ onMounted(() => { void load() })
<div class="smart-loading__context"><span>当前柜次</span><strong>{{ container?.cabinetNo || container?.containerNo || '柜次信息待补充' }}</strong><small>{{ container?.warehouseName || '仓库待加载' }}</small></div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
<el-tag v-if="!plan" type="danger">自动预装计划缺失</el-tag>
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || Boolean(action)" @click="pushOpen = true"><Check :size="15" />推送预装</el-button>
</div>
</header>
@@ -352,10 +336,6 @@ onMounted(() => { void load() })
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="pushOpen" title="推送预装清单" confirm-type="primary" confirm-text="确认推送" :confirm-loading="action === 'push'" @confirm="confirmPush">
<p>推送后当前草稿明细不可再增删调整。真实集装箱号可在订舱放舱后补录,现场 PDA 任务将在条件齐备后开放。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
</section>
</template>
+50 -1
View File
@@ -73,7 +73,56 @@ describe('QueryToolbar layout preferences', () => {
expect(wrapper.find('.query-toolbar__tail .query-toolbar__actions').exists()).toBe(true)
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
expect(source).toContain('.query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; }')
expect(source).toContain('.query-toolbar__tail { display: flex; width: auto; max-width: 100%; min-width: 0; flex: 0 1 auto; align-items: flex-end; flex-wrap: wrap; gap: 10px; }')
expect(source).not.toContain('.query-toolbar__filters { display: contents; }')
})
it('derives compact widths by field type while explicit business widths win', () => {
const compactFields: QueryField[] = [
{ key: 'status', type: 'select', label: '短状态' },
{ key: 'date', type: 'date', label: '日期' },
{ key: 'keyword', type: 'input', label: '长关键词' },
{ key: 'range', type: 'daterange', label: '日期范围' },
{ key: 'warehouse', type: 'select', label: '业务显式宽度', width: 236 },
]
const wrapper = mountToolbar({
modelValue: { status: '', date: '', keyword: '', range: [], warehouse: '' },
fields: compactFields,
availableFields: compactFields,
})
const styles = wrapper.findAll('.query-toolbar__field').map((field) => field.attributes('style'))
expect(styles).toEqual([
'--query-field-width: 180px;',
'--query-field-width: 180px;',
'--query-field-width: 260px;',
'--query-field-width: 300px;',
'--query-field-width: 236px;',
])
})
it('keeps a short status and long keyword left aligned without stretching or desktop overflow', () => {
const mixedFields: QueryField[] = [
{ key: 'status', type: 'select', label: '状态' },
{ key: 'keyword', type: 'input', label: '订单号/入仓码/客户名称' },
]
const wrapper = mountToolbar({
modelValue: { status: '', keyword: '' },
fields: mixedFields,
availableFields: mixedFields,
})
const renderedFields = wrapper.findAll('.query-toolbar__field')
expect(renderedFields[0].attributes('style')).toBe('--query-field-width: 180px;')
expect(renderedFields[1].attributes('style')).toBe('--query-field-width: 260px;')
expect(renderedFields[1].classes()).toContain('query-toolbar__tail')
expect(renderedFields[1].find('.query-toolbar__actions').exists()).toBe(true)
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
expect(source).toContain('justify-content: flex-start; flex-wrap: wrap;')
expect(source).toContain('flex: 0 0 var(--query-field-width)')
expect(source).toContain('overflow-x: clip')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
expect(source).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
})
it('submits the current filters once when Enter is released in a text input', async () => {
+16 -8
View File
@@ -133,9 +133,16 @@ function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
const defaultFieldWidths: Record<QueryFieldType, number> = {
select: 180,
date: 180,
input: 260,
daterange: 300,
}
function fieldStyle(field: QueryField) {
if (field.width === undefined) return undefined
const width = typeof field.width === 'number' ? `${field.width}px` : field.width
const fieldWidth = field.width ?? defaultFieldWidths[field.type]
const width = typeof fieldWidth === 'number' ? `${fieldWidth}px` : fieldWidth
return { '--query-field-width': width }
}
@@ -245,12 +252,13 @@ defineExpose({ search, reset })
</template>
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
.query-toolbar__field, .query-toolbar__control { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
.query-toolbar { box-sizing: border-box; display: flex; width: 100%; min-width: 0; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; overflow-x: clip; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.query-toolbar__filters { display: flex; width: 100%; min-width: 0; flex: 1 1 100%; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 12px 16px; }
.query-toolbar__field { display: grid; width: var(--query-field-width); max-width: 100%; min-width: 0; flex: 0 0 var(--query-field-width); gap: 6px; }
.query-toolbar__control { display: grid; width: var(--query-field-width); max-width: 100%; min-width: 0; flex: 0 0 var(--query-field-width); gap: 6px; }
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
.query-toolbar__field :deep(.el-input__inner), .query-toolbar__field :deep(.el-select__selected-item), .query-toolbar__field :deep(.el-date-editor), .query-toolbar__actions :deep(.el-button) { font-size: var(--cc-list-query-font-size); }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions { display: flex; max-width: 100%; min-width: 0; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
@@ -260,6 +268,6 @@ defineExpose({ search, reset })
.query-toolbar__field, .query-toolbar__control { gap: 4px; }
.query-toolbar__actions { gap: 6px; }
}
@media (min-width: 721px) { .query-toolbar__filters { display: contents; } .query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__tail { display: contents; } .query-toolbar__control { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
@media (min-width: 721px) { .query-toolbar__tail { display: flex; width: auto; max-width: 100%; min-width: 0; flex: 0 1 auto; align-items: flex-end; flex-wrap: wrap; gap: 10px; } .query-toolbar__tail > .query-toolbar__control { flex: 0 0 var(--query-field-width); } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; flex: 0 0 auto; } .query-toolbar__tail { display: contents; } .query-toolbar__control { width: 100%; flex: 0 0 auto; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>