Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -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/` |
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 || '请刷新计划获取最新作业状态。'" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('请立即保存以下一次性凭证')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('结算单')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user