From 24575223a0b1340238a117fbcd7f910df42d9150 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 13:50:41 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbounds/weight/api/weightOcr.test.ts | 21 +++++++--- src/modules/inbounds/weight/api/weightOcr.ts | 25 ++++++++++- .../WeightOcrPanel.behavior.test.ts | 42 +++++++++++++++---- .../weight/components/WeightOcrPanel.vue | 4 +- src/modules/inbounds/weight/types.ts | 8 ++++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/modules/inbounds/weight/api/weightOcr.test.ts b/src/modules/inbounds/weight/api/weightOcr.test.ts index e7ed3816..1e71a319 100644 --- a/src/modules/inbounds/weight/api/weightOcr.test.ts +++ b/src/modules/inbounds/weight/api/weightOcr.test.ts @@ -82,11 +82,20 @@ describe('inbound weight OCR API', () => { ) }) - it('loads same-warehouse pallet snapshots through Firstleg', async () => { - const options = [{ palletId: '11', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }] - vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: options }) + it('normalizes the formal paged pallet response returned by Firstleg', async () => { + const records = [ + { palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }, + { palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 }, + ] + vi.mocked(http.get).mockResolvedValue({ + code: '200', + message: 'success', + data: { records, page: '1', size: '20', total: '2', pages: '1' }, + }) - await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual(options) + await expect(getWeightPalletOptions('10001', '20001')).resolves.toEqual({ + records, page: 1, size: 20, total: 2, pages: 1, + }) expect(http.get).toHaveBeenCalledWith( '/platform/v1/first-leg/inbounds/20001/weight-pallet-options', { params: { tenantId: '10001' } }, @@ -94,7 +103,9 @@ describe('inbound weight OCR API', () => { }) it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => { - vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + 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) diff --git a/src/modules/inbounds/weight/api/weightOcr.ts b/src/modules/inbounds/weight/api/weightOcr.ts index 72324d19..927a3c76 100644 --- a/src/modules/inbounds/weight/api/weightOcr.ts +++ b/src/modules/inbounds/weight/api/weightOcr.ts @@ -6,6 +6,7 @@ import type { CreateWeightResultRequest, WeightImageUploadResult, WeightPalletOption, + WeightPalletOptionPage, WeightResultResource, WeightWorkspaceResource, } from '../types' @@ -15,10 +16,23 @@ const imageBasePath = '/platform/v1/files/measurements' const maxErrorEnvelopeBytes = 1024 * 1024 const confidenceScale = 100_000 +interface WeightPalletOptionPageResponse { + records: WeightPalletOption[] + page: number | string + size: number | string + total: number | string + pages: number | string +} + function segment(value: string) { return encodeURIComponent(value) } +function normalizedPageNumber(value: number | string, fallback: number, minimum: number) { + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) ? Math.max(minimum, Math.trunc(parsed)) : fallback +} + export function normalizeWeightConfidence(value: number | null) { if (value == null || !Number.isFinite(value)) return null const clamped = Math.min(1, Math.max(0, value)) @@ -61,11 +75,18 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) { } export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) { - const response = await http.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`, { params: { tenantId }, ...(signal ? { signal } : {}) }, ) - return unwrapApiResponse(response) + const page = unwrapApiResponse(response) + return { + records: page.records, + page: normalizedPageNumber(page.page, 1, 1), + size: normalizedPageNumber(page.size, page.records.length, 0), + total: normalizedPageNumber(page.total, page.records.length, 0), + pages: normalizedPageNumber(page.pages, 0, 0), + } satisfies WeightPalletOptionPage } export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) { diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts index 78013526..12c44f01 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -72,6 +72,12 @@ const workspace: WeightWorkspaceResource = { weightResults: [pendingWeight], } +function palletPage(records = [{ + palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12, +}]) { + return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } +} + enableAutoUnmount(afterEach) const allWeightPermissions = [ @@ -122,9 +128,7 @@ describe('WeightOcrPanel confirmation behavior', () => { vi.clearAllMocks() document.body.innerHTML = '' vi.mocked(getWeightWorkspace).mockResolvedValue(workspace) - vi.mocked(getWeightPalletOptions).mockResolvedValue([{ - palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12, - }]) + vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage()) vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })) vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })) vi.stubGlobal('URL', { @@ -326,7 +330,7 @@ describe('WeightOcrPanel confirmation behavior', () => { }) it('shows an actionable empty state when the warehouse has no pallet options', async () => { - vi.mocked(getWeightPalletOptions).mockResolvedValueOnce([]) + vi.mocked(getWeightPalletOptions).mockResolvedValueOnce(palletPage([])) const wrapper = mountPanel() await flushPromises() @@ -334,6 +338,28 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true) }) + it('renders records from the formal paged response without showing a load failure', async () => { + vi.mocked(getWeightPalletOptions).mockResolvedValueOnce({ + records: [ + { palletId: '2092803000000000001', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }, + { palletId: '2092803000000000002', palletCode: 'TP-12', palletType: '塑料托盘', capacityCbm: 1.1, tareWeightKg: 10 }, + ], + page: 1, + size: 20, + total: 2, + pages: 1, + }) + const wrapper = mountPanel() + await flushPromises() + + await wrapper.find('.pallet-picker .el-select').trigger('click') + await flushPromises() + + expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³') + expect(document.body.textContent).toContain('塑料托盘 · 皮重 10.000 KG · 容量 1.1 m³') + expect(wrapper.text()).not.toContain('可选托盘加载失败') + }) + it('shows pallet loading failures without hiding existing weight results', async () => { vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503)) const wrapper = mountPanel() @@ -373,13 +399,13 @@ describe('WeightOcrPanel confirmation behavior', () => { }) it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => { - vi.mocked(getWeightPalletOptions).mockResolvedValue(Array.from({ length: 1_000 }, (_, index) => ({ + vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage(Array.from({ length: 1_000 }, (_, index) => ({ palletId: String(60_000 + index), palletCode: `TP-${index}`, palletType: `托盘类型 ${index}`, capacityCbm: 1.2, tareWeightKg: 12, - }))) + })))) const wrapper = mountPanel() await flushPromises() @@ -401,9 +427,9 @@ describe('WeightOcrPanel confirmation behavior', () => { const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2] wrapper.unmount() - resolveOptions?.([{ + resolveOptions?.(palletPage([{ palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10, - }]) + }])) await flushPromises() expect(signal?.aborted).toBe(true) diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index 732198be..a8e1daf7 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -242,8 +242,8 @@ async function loadPalletOptions() { try { const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal) if (sequence !== palletLoadSequence || controller.signal.aborted) return - palletOptions.value = result - if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = '' + palletOptions.value = result.records + if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = '' } catch (error) { if (sequence !== palletLoadSequence || controller.signal.aborted) return palletOptions.value = [] diff --git a/src/modules/inbounds/weight/types.ts b/src/modules/inbounds/weight/types.ts index 7209a57e..fa84ae19 100644 --- a/src/modules/inbounds/weight/types.ts +++ b/src/modules/inbounds/weight/types.ts @@ -43,6 +43,14 @@ export interface WeightPalletOption { tareWeightKg: number } +export interface WeightPalletOptionPage { + records: WeightPalletOption[] + page: number + size: number + total: number + pages: number +} + export interface WeightImageUploadResult { fileId: string fileName: string From 00045a97643db23a42501363b85eaf64848d6e9a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 15:02:41 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ContainerDetailView.test.ts | 87 +++++++++++++++++-- .../pages/ContainerDetailView.vue | 23 +++-- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 29e2c4b2..b6dcdefe 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -2,7 +2,7 @@ import ElementPlus, { ElMessageBox } from 'element-plus' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' -import { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' +import { completeContainerClearance, downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' @@ -47,6 +47,13 @@ function mountView(customs = true, outbound = true) { } function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! } function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} +type ClearanceVm = { clearanceOpen: boolean; clearanceFile: File | null; clearanceError: { code?: string; requestId?: string } | null; documentRefreshWarning: unknown; selectClearanceFile: (file: { raw: File }) => void; uploadClearance: () => Promise; completeClearance: () => Promise } +function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm } describe('ContainerDetailView', () => { beforeEach(() => { @@ -163,18 +170,26 @@ describe('ContainerDetailView', () => { wrapper.unmount() }) - it('uses the independent destination document version and immediately replaces the table row after update', async () => { + it('closes immediately after a valid destination upload response, clears dialog state and refreshes after optimistic table update', async () => { const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 } + const refresh = deferred() + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never) vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never) const wrapper = mountView(); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' }) - ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) - await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + const request = clearanceVm(wrapper).uploadClearance(); await flushPromises() expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料') + expect(clearanceVm(wrapper).clearanceOpen).toBe(false) + expect(clearanceVm(wrapper).clearanceFile).toBeNull() + expect(clearanceVm(wrapper).clearanceError).toBeNull() expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf') + refresh.resolve(overview) + await request; await flushPromises() + expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull() wrapper.unmount() }) @@ -196,17 +211,75 @@ describe('ContainerDetailView', () => { it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => { const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 } - vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never).mockResolvedValueOnce(server as never) vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409')) const wrapper = mountView(); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) - ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) - await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + await clearanceVm(wrapper).uploadClearance(); await flushPromises() expect(uploadContainerClearance).toHaveBeenCalledOnce() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-clearance-409' }) expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf') expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('request-clearance-409') + clearanceVm(wrapper).clearanceOpen = false; await flushPromises() + await documentButton(wrapper, '目的港清关资料', '更新附件').trigger('click'); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBeNull() + expect(clearanceVm(wrapper).clearanceError).toBeNull() + expect(wrapper.text()).not.toContain('request-clearance-409') + wrapper.unmount() + }) + + it('keeps the destination dialog and selected file for network and empty-response upload failures', async () => { + const existing = { id: '501', documentType: 'DESTINATION_CLEARANCE', fileId: '502', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(uploadContainerClearance) + .mockRejectedValueOnce(new ApiResponseError('网络请求失败', 'NETWORK_ERROR', undefined, undefined, 'request-network', 'trace-network')) + .mockResolvedValueOnce(null) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + await clearanceVm(wrapper).uploadClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(wrapper.text()).toContain('request-network') + await clearanceVm(wrapper).uploadClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(uploadContainerClearance).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + + it('closes after successful destination clearance completion but keeps the dialog open on completion failure', async () => { + const existing = { id: '601', documentType: 'DESTINATION_CLEARANCE', fileId: '602', fileName: 'clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + const completed = { ...existing, completedBy: '4', completedByName: '复核员', completedAt: '2026-08-27T12:00:00', version: 2 } + const refresh = deferred() + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(completeContainerClearance).mockResolvedValueOnce(completed as never) + let wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const request = clearanceVm(wrapper).completeClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(false) + expect(clearanceVm(wrapper).clearanceError).toBeNull() + expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('已完成') + refresh.resolve(overview); await request; await flushPromises() + wrapper.unmount() + + vi.mocked(getContainerOverview).mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(completeContainerClearance).mockRejectedValueOnce(new ApiResponseError('完成失败', 'A0409', undefined, undefined, 'request-complete-409', 'trace-complete-409')) + wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + await clearanceVm(wrapper).completeClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-complete-409' }) + expect(wrapper.text()).toContain('trace-complete-409') wrapper.unmount() }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 8f2386d3..5f2a9743 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -303,7 +303,7 @@ async function saveException() { } async function openClearance() { - clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true + clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true try { clearanceDocument.value = await getContainerClearance(containerId.value) } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } finally { clearanceLoading.value = false } @@ -312,17 +312,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw function removeClearanceFile() { clearanceFile.value = null } async function uploadClearance() { if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } - clearanceSaving.value = true; clearanceError.value = null + clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null try { const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料') if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息') clearanceDocument.value = uploaded replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE'))) + clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null + ElMessage.success('目的港清关资料已上传') try { const refreshed = await getContainerOverview(containerId.value) overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] } } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') } - clearanceFile.value = null; ElMessage.success('目的港清关资料已上传') } catch (cause) { const details = getApiErrorDetails(cause, '目的港清关资料上传失败') if (details.code === 'A0409') { @@ -334,8 +335,20 @@ async function uploadClearance() { } async function completeClearance() { if (!clearanceDocument.value) return - clearanceSaving.value = true; clearanceError.value = null - try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } + clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null + try { + const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成') + if (!completed) throw new Error('确认清关完成但未返回附件信息') + clearanceDocument.value = completed + const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE') + replaceOverviewDocument(optimistic) + clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null + ElMessage.success('已完成清关') + try { + const refreshed = await getContainerOverview(containerId.value) + overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] } + } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') } + } catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') } finally { clearanceSaving.value = false } } From 690f386bff9638b8c00ab7c98b8138968cc6fea2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 15:56:13 +0800 Subject: [PATCH 3/7] fix(admin): unblock tailleg inbound planning --- .../API_INTEGRATION_MATRIX.md | 4 +- .../api/lastMileOrders.test.ts | 14 +-- .../overseas-warehouse/api/lastMileOrders.ts | 1 + .../components/OrderReferenceText.test.ts | 26 +++++ .../components/OrderReferenceText.vue | 30 ++++++ .../components/OverseasDetailShell.vue | 6 +- .../components/SmartDispatchDialog.vue | 10 +- .../lastMilePresentation.test.ts | 24 ++++- .../lastMilePresentation.ts | 14 +-- .../pages/CustomerVisitsView.vue | 4 +- .../pages/ExceptionOrdersView.vue | 7 +- .../pages/LastMileOrderDetailView.vue | 2 + .../pages/LastMileOrdersView.behavior.test.ts | 102 ++++++++++++++++++ .../pages/LastMileOrdersView.vue | 15 ++- .../pages/realApiMount.test.ts | 6 ++ src/modules/overseas-warehouse/types.ts | 10 +- 16 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/OrderReferenceText.test.ts create mode 100644 src/modules/overseas-warehouse/components/OrderReferenceText.vue create mode 100644 src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index be23d528..b977165e 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -4,9 +4,9 @@ | 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 | |---|---|---|---| -| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | +| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | | 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 | -| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string`,不传客户 | `...:inbound-order:create`;不接补托/PDA | +| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA | | 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 | | 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 | | FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 018f9bb0..19b38bbc 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -8,8 +8,9 @@ describe('last-mile order overseas API', () => { beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) it('loads all platform orders without a customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } }) - await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 }) + const record = { id: '190000000000000099', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } }) + await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' }, }) @@ -47,10 +48,11 @@ describe('last-mile order overseas API', () => { expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] })) }) - it('uses the inbound-plan conversion endpoint without customerId', async () => { + it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => { vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) - await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { orderId: '190000000000000099' }) - expect(JSON.stringify(vi.mocked(overseasHttp.post).mock.calls[0]?.[1])).not.toContain('customerId') + await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' }) + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { + orderId: '190000000000000099', customerId: '190000000000000001', + }) }) }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 18dad487..329a53fb 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -150,6 +150,7 @@ export interface TransferDestinationPayload { export interface ConvertInboundPlanPayload { orderId: string + customerId: string } export interface LastMileOrderDetailDto { diff --git a/src/modules/overseas-warehouse/components/OrderReferenceText.test.ts b/src/modules/overseas-warehouse/components/OrderReferenceText.test.ts new file mode 100644 index 00000000..81855ab4 --- /dev/null +++ b/src/modules/overseas-warehouse/components/OrderReferenceText.test.ts @@ -0,0 +1,26 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import OrderReferenceText from './OrderReferenceText.vue' + +describe('OrderReferenceText', () => { + it.each([ + ['desktop-1920', '170px'], + ['narrow', '88px'], + ])('keeps the complete server order number in %s layouts', (_viewport, width) => { + const orderNo = 'LEGACY-LAST-MILE-ORDER-202608270000000001' + const wrapper = mount({ + components: { OrderReferenceText }, + template: `
`, + }) + + const reference = wrapper.get('.order-reference-text') + expect(reference.text()).toBe(orderNo) + expect(reference.attributes('title')).toBe(orderNo) + }) + + it('renders the new short number in full and does not guess from another identifier', () => { + const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-260827-0001' } }) + expect(wrapper.text()).toBe('LMO-260827-0001') + expect(wrapper.attributes('title')).toBe('LMO-260827-0001') + }) +}) diff --git a/src/modules/overseas-warehouse/components/OrderReferenceText.vue b/src/modules/overseas-warehouse/components/OrderReferenceText.vue new file mode 100644 index 00000000..3f4576dc --- /dev/null +++ b/src/modules/overseas-warehouse/components/OrderReferenceText.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/modules/overseas-warehouse/components/OverseasDetailShell.vue b/src/modules/overseas-warehouse/components/OverseasDetailShell.vue index 9924aaec..5d7d73b2 100644 --- a/src/modules/overseas-warehouse/components/OverseasDetailShell.vue +++ b/src/modules/overseas-warehouse/components/OverseasDetailShell.vue @@ -22,7 +22,7 @@ defineEmits<{ back: [] }>() 返回

{{ title }}

-

{{ referenceLabel }} {{ referenceValue }}

+

{{ referenceLabel }} {{ referenceValue }}

{{ statusText }} @@ -37,8 +37,8 @@ defineEmits<{ back: [] }>() .overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; } .overseas-detail-title { min-width: 0; flex: 1; } .overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; } -.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } -.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; } +.overseas-detail-title p { display: flex; min-width: 0; align-items: baseline; gap: 4px; margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } +.overseas-detail-title strong { min-width: 0; max-width: 100%; color: var(--cc-text); } .overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; } .overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); } diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue index e1a414e0..30af8143 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue @@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches' import type { InboundOrderListItem } from '../types' +import OrderReferenceText from './OrderReferenceText.vue' type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number } interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] } @@ -211,7 +212,7 @@ watch([warehouseTab, () => query.orderType], () => {

出库单列表 ({{ orderGroups.length }}单)

- + @@ -225,7 +226,7 @@ watch([warehouseTab, () => query.orderType], () => {
-

托盘明细 {{ activePallets.length ? `(${activePallets.length}托)` : '' }}

订单号:{{ text(activePallets[0]?.orderNo) }}
+

托盘明细 {{ activePallets.length ? `(${activePallets.length}托)` : '' }}

订单号:
@@ -246,7 +247,7 @@ watch([warehouseTab, () => query.orderType], () => {
{{ container.vehicleLabel }}托数 {{ container.palletIds.length }}/{{ container.capacity }} · 重量 {{ number(containerWeightLb(container), 0) }}LB/{{ container.maxWeightLb }}LB 超限10%
-
{{ text(palletSnapshot(id)?.palletNo || id) }}{{ text(palletSnapshot(id)?.orderNo) }}移除
+
{{ text(palletSnapshot(id)?.palletNo || id) }}移除
拖入托盘,移入后立即锁定
@@ -276,6 +277,7 @@ watch([warehouseTab, () => query.orderType], () => { .dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; } .order-pane, .detail-pane, .container-pane { min-width: 0; } .order-pane header, .detail-pane header, .container-pane header { display: flex; min-height: 74px; align-items: center; justify-content: space-between; gap: 10px; } +.detail-order-reference { display: flex; min-width: 0; max-width: 240px; align-items: baseline; gap: 3px; } h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; } .order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; } .order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; } @@ -297,7 +299,7 @@ h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: .container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); } .container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; } .loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--cc-border); } -.loaded-pallet > span { display: flex; min-width: 0; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; } +.loaded-pallet > span { display: flex; min-width: 0; flex: 1; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; } .dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; } .dialog-footer > span { color: var(--cc-muted); font-size: 12px; } .vehicle-options { display: grid; gap: 14px; } diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index 04fa6365..2e8bffbf 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toLastMileOrderListItem } from './lastMilePresentation' +import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation' describe('last-mile order presentation adapter', () => { it('maps backend codes, associations and long ids without inventing data', () => { @@ -14,6 +14,28 @@ describe('last-mile order presentation adapter', () => { expect(row.rejectedAt).toBe('—') }) + it('shows the backend customerName and never guesses the owner from customerId', () => { + expect(toLastMileOrderListItem({ + id: '1', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', + }).owner).toBe('华远贸易') + expect(toLastMileOrderListItem({ + id: '2', customerId: '190000000000000002', customerName: null, orderNo: 'LMO-260827-0002', + }).owner).toBe('—') + }) + + it('preserves real metrics and distinguishes zero from unknown fields and times', () => { + const estimatedArrivalAt = '2026-08-27T12:30:00Z' + const row = toLastMileOrderListItem({ + id: '3', customerId: '190000000000000003', orderNo: 'LMO-260827-0003', + palletCount: 3, packageCount: 0, volumeCbm: 12.75, productQuantity: null, volumeWeightKg: 88.6, + estimatedArrivalAt, actualArrivalAt: null, estimatedWarehouseAt: null, + }) + expect(row).toMatchObject({ + pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6, + portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—', warehouseEta: '—', + }) + }) + it.each([ ['RELABEL_APPLICATION', '换标下单'], ['WAREHOUSE_TRANSFER', '仓储转运'], diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index f2f9a6a1..2588b24a 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -27,8 +27,8 @@ function text(value: unknown) { return normalized || '—' } -function count(value: number | null | undefined) { - return Number.isFinite(value) ? Number(value) : 0 +function metric(value: number | null | undefined): number | '—' { + return value != null && Number.isFinite(value) ? Number(value) : '—' } function yesNo(value: boolean | null | undefined) { @@ -69,11 +69,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO containerStatus: text(dto.containerStatus), abnormal: yesNo(dto.abnormal), held: yesNo(dto.detained), - pallets: count(dto.palletCount), - cartons: count(dto.packageCount), - volume: count(dto.volumeCbm), - quantity: count(dto.productQuantity), - volumeWeight: count(dto.volumeWeightKg), + pallets: metric(dto.palletCount), + cartons: metric(dto.packageCount), + volume: metric(dto.volumeCbm), + quantity: metric(dto.productQuantity), + volumeWeight: metric(dto.volumeWeightKg), portEta: formatOverseasDateTime(dto.estimatedArrivalAt), portAta: formatOverseasDateTime(dto.actualArrivalAt), warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt), diff --git a/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue b/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue index 6858ce23..ccac3c1a 100644 --- a/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue +++ b/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue @@ -23,6 +23,7 @@ import { type CustomerVisitRow, } from '../api/customerVisits' import { overseasWarehousePermissions as permission } from '../permissions' +import OrderReferenceText from '../components/OrderReferenceText.vue' const access = useAccessPreviewStore() const rows = ref([]) @@ -188,7 +189,7 @@ onMounted(load)
@@ -216,6 +217,7 @@ onMounted(load) .visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); } .inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; } .inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); } +.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: 3px; } .inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); } .file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; } .file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); } diff --git a/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue b/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue index 2506fcf6..284a59fb 100644 --- a/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue @@ -15,6 +15,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { formatOverseasDateTime } from '../lastMilePresentation' import { overseasWarehousePermissions as permission } from '../permissions' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders' +import OrderReferenceText from '../components/OrderReferenceText.vue' const rows = ref([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref(null) const access = useAccessPreviewStore() @@ -73,7 +74,7 @@ onMounted(load)
- +
@@ -87,13 +88,13 @@ onMounted(load)
- + diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 93c16e88..eca3d512 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -14,6 +14,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation' import { overseasWarehousePermissions as permission } from '../permissions' import OverseasDetailShell from '../components/OverseasDetailShell.vue' +import OrderReferenceText from '../components/OrderReferenceText.vue' interface DetailField { label: string; value: string | number } interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number } @@ -150,6 +151,7 @@ onMounted(load) + + +
+
diff --git a/src/modules/overseas-warehouse/pages/realApiMount.test.ts b/src/modules/overseas-warehouse/pages/realApiMount.test.ts index 2fffa4c0..e194e034 100644 --- a/src/modules/overseas-warehouse/pages/realApiMount.test.ts +++ b/src/modules/overseas-warehouse/pages/realApiMount.test.ts @@ -1,17 +1,21 @@ import ElementPlus from 'element-plus' +import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listPlatformTenants } from '@/modules/iam' import { getInboundPlan } from '../api/inboundPlans' import { listInventoryLedger } from '../api/inventory' +import { getLastMileOrder } from '../api/lastMileOrders' import InboundOrderDetailView from './InboundOrderDetailView.vue' import InventoryTransactionsView from './InventoryTransactionsView.vue' +import LastMileOrderDetailView from './LastMileOrderDetailView.vue' const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record, query: {} as Record } })) vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() })) vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() })) +vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals()) @@ -22,7 +26,9 @@ describe('overseas warehouse mounted real API pages', () => { vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] }) vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] }) }) it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') }) it('mounts inventory ledger as read-only facts', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') }) + it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') }) }) diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 8cd05959..295ce1d0 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -19,11 +19,11 @@ export interface LastMileOrderListItem { containerStatus: string abnormal: string held: string - pallets: number - cartons: number - volume: number - quantity: number - volumeWeight: number + pallets: number | '—' + cartons: number | '—' + volume: number | '—' + quantity: number | '—' + volumeWeight: number | '—' portEta: string portAta: string warehouseEta: string From a90d5fbe39c087a0525218462d970e931d2e8b01 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:34:32 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API_INTEGRATION_MATRIX.md | 3 +- .../api/lastMileOrders.test.ts | 13 ++- .../overseas-warehouse/api/lastMileOrders.ts | 13 +++ .../lastMileContainerPlanning.test.ts | 36 ++++++++ .../lastMileContainerPlanning.ts | 67 ++++++++++++++ .../lastMilePresentation.ts | 1 + .../pages/LastMileOrdersView.behavior.test.ts | 87 ++++++++++--------- .../pages/LastMileOrdersView.vue | 66 ++++++++++---- src/modules/overseas-warehouse/types.ts | 1 + 9 files changed, 226 insertions(+), 61 deletions(-) create mode 100644 src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts create mode 100644 src/modules/overseas-warehouse/lastMileContainerPlanning.ts diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index b977165e..c4140b6c 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -4,9 +4,10 @@ | 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 | |---|---|---|---| -| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | +| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—`;整柜操作要求列表响应携带稳定 `sourceContainerBookingId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | | 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 | | 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA | +| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功刷新待分配列表;计划数与整柜订单数不一致时不提示成功;错误保留 code/requestId/traceId | | 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 | | 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 | | FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 19b38bbc..dc63e82a 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { overseasHttp } from '@/shared/api/overseasHttp' -import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) @@ -8,7 +8,7 @@ describe('last-mile order overseas API', () => { beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) it('loads all platform orders without a customer scope', async () => { - const record = { id: '190000000000000099', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } + const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } }) await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 }) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { @@ -55,4 +55,13 @@ describe('last-mile order overseas API', () => { orderId: '190000000000000099', customerId: '190000000000000001', }) }) + + it('uses one atomic container request without looping individual conversions', async () => { + vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] }) + await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001' })).resolves.toEqual([]) + expect(overseasHttp.post).toHaveBeenCalledTimes(1) + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', { + sourceContainerBookingId: '2092873463568597001', + }) + }) }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 329a53fb..5142a982 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -1,5 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { InboundPlanDetailDto } from './inboundPlans' export interface OverseasPageResult { records: T[] @@ -41,6 +42,7 @@ export interface LastMileOrderQuery { export interface LastMileOrderSummaryDto { id: string customerId: string + sourceContainerBookingId?: string | null orderNo: string orderType?: string | null orderTypeName?: string | null @@ -153,6 +155,10 @@ export interface ConvertInboundPlanPayload { customerId: string } +export interface ConvertContainerInboundPlanPayload { + sourceContainerBookingId: string +} + export interface LastMileOrderDetailDto { order: LastMileOrderDto cargo: LastMileCargoDto[] @@ -196,3 +202,10 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP ) return unwrapApiResponse(response) } + +export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { + const response = await overseasHttp.post, ApiEnvelope>( + '/platform/v1/last-leg/inbound-plans/convert-container', payload, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts new file mode 100644 index 00000000..9e422fe1 --- /dev/null +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { groupPendingOrdersByContainer } from './lastMileContainerPlanning' +import type { LastMileOrderListItem } from './types' + +function order(overrides: Partial): LastMileOrderListItem { + return { + id: '1', customerId: '100', sourceContainerBookingId: 'BOOKING-1', orderNo: 'LMO-1', warning: '—', orderType: '头程订单', rejectedAt: '—', inboundCode: 'IN-1', + owner: '客户', warehouse: '—', transferWarehouse: '—', fbaWarehouse: '—', containerNo: 'CONT-1', containerStatus: '—', abnormal: '否', held: '否', + pallets: 1, cartons: 10, volume: 2.5, quantity: '—', volumeWeight: '—', portEta: '—', portAta: '—', warehouseEta: '—', inboundOrderNo: '—', outboundOrderNo: '—', dispatchOrderNo: '—', relabelOrderNo: '—', relabelStatus: '—', consolidated: '—', status: '待分配', internalRemark: '—', customerRemark: '—', warehouseRemark: '—', createdAt: '—', + ...overrides, + } +} + +describe('last-mile container planning groups', () => { + it('groups the whole container while preserving one independent plan per order', () => { + const [group] = groupPendingOrdersByContainer([ + order({ id: '1', customerId: '100', inboundCode: 'IN-1', cartons: 10, pallets: 1, volume: 2.5 }), + order({ id: '2', customerId: '200', inboundCode: 'IN-2', cartons: 5, pallets: 2, volume: 1.25 }), + ]) + expect(group).toMatchObject({ + containerNo: 'CONT-1', orderCount: 2, inboundCodeCount: 2, customerCount: 2, + totalCartons: 15, totalPallets: 3, totalVolumeCbm: 3.75, eligible: true, + planNotice: '将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业', + orderIds: ['1', '2'], customerIds: ['100', '200'], + }) + }) + + it('fails closed for missing container/customer facts and never treats unknown totals as zero', () => { + const groups = groupPendingOrdersByContainer([ + order({ id: '1', sourceContainerBookingId: '', containerNo: '—', cartons: '—' }), + order({ id: '2', containerNo: 'CONT-2', customerId: '', pallets: '—' }), + ]) + expect(groups.find((group) => group.id === 'missing:1')).toMatchObject({ eligible: false, totalCartons: '—' }) + expect(groups.find((group) => group.containerNo === 'CONT-2')).toMatchObject({ eligible: false, totalPallets: '—' }) + }) +}) diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts new file mode 100644 index 00000000..5a69a5ea --- /dev/null +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts @@ -0,0 +1,67 @@ +import type { LastMileOrderListItem } from './types' + +type DisplayTotal = number | '—' + +export interface LastMileContainerPlanRow { + id: string + sourceContainerBookingId: string + containerNo: string + orderCount: number + inboundCodeCount: number + customerCount: number + totalCartons: DisplayTotal + totalPallets: DisplayTotal + totalVolumeCbm: DisplayTotal + eligibility: string + planNotice: string + eligible: boolean + orderIds: string[] + customerIds: string[] +} + +function sumComplete(values: Array): DisplayTotal { + return values.every((value): value is number => typeof value === 'number') + ? values.reduce((sum, value) => sum + value, 0) + : '—' +} + +export function groupPendingOrdersByContainer(rows: LastMileOrderListItem[]): LastMileContainerPlanRow[] { + const groups = new Map() + rows.forEach((row) => { + const key = row.sourceContainerBookingId || `missing:${row.id}` + groups.set(key, [...(groups.get(key) || []), row]) + }) + + return [...groups.entries()].map(([key, orders]) => { + const sourceContainerBookingId = key.startsWith('missing:') ? '' : key + const containerValues = [...new Set(orders.map((order) => order.containerNo).filter((value) => value !== '—'))] + const containerNo = containerValues.length === 1 ? containerValues[0] : '—' + const customerIds = [...new Set(orders.map((order) => order.customerId.trim()).filter(Boolean))] + const inboundCodes = new Set(orders.map((order) => order.inboundCode).filter((value) => value !== '—')) + const hasCompleteCustomerScope = orders.every((order) => Boolean(order.customerId.trim())) + const eligible = Boolean(sourceContainerBookingId) && containerNo !== '—' && hasCompleteCustomerScope + const eligibility = !sourceContainerBookingId + ? '缺少来源柜身份,不可生成' + : containerNo === '—' + ? '柜号不一致或缺失,不可生成' + : !hasCompleteCustomerScope + ? '存在缺失客户,不可生成' + : '可生成' + return { + id: key, + sourceContainerBookingId, + containerNo, + orderCount: orders.length, + inboundCodeCount: inboundCodes.size, + customerCount: customerIds.length, + totalCartons: sumComplete(orders.map((order) => order.cartons)), + totalPallets: sumComplete(orders.map((order) => order.pallets)), + totalVolumeCbm: sumComplete(orders.map((order) => order.volume)), + eligibility, + planNotice: `将为 ${orders.length} 个尾程订单生成 ${orders.length} 个独立入库计划,PDA仍按同柜作业`, + eligible, + orderIds: orders.map((order) => order.id), + customerIds, + } + }).sort((left, right) => left.containerNo.localeCompare(right.containerNo)) +} diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 2588b24a..dafe7d12 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -56,6 +56,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO return { id: String(dto.id), customerId: String(dto.customerId), + sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(), orderNo: text(dto.orderNo), warning: dto.abnormal ? '异常' : '—', orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 564b5787..e09e981a 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -3,14 +3,14 @@ import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' -import DataTable from '@/shared/components/DataTable.vue' -import { convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), + convertLastMileContainerToInboundPlans: vi.fn(), convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), })) @@ -28,22 +28,25 @@ function memoryStorage(): Storage { enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -function selectedRow(customerId = '2092618717861048321') { - return { id: '2092873463568596994', customerId, orderNo: 'LMO-260827-0001' } -} - async function mountView() { const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() return wrapper } -async function selectAndConvert(wrapper: Awaited>, row = selectedRow()) { - wrapper.findComponent(DataTable).vm.$emit('selection-change', [row]) - await wrapper.vm.$nextTick() - const button = wrapper.findAll('button').find((item) => item.text().includes('生成入库计划')) - expect(button).toBeDefined() - await button!.trigger('click') +const containerRecords = [ + { id: '1', customerId: '100', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-1', inboundCode: 'IN-1', containerNo: 'CONT-1', packageCount: 10, palletCount: 1, volumeCbm: 2, currentStatus: 'PENDING_ASSIGNMENT' }, + { id: '2', customerId: '200', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-2', inboundCode: 'IN-2', containerNo: 'CONT-1', packageCount: 5, palletCount: 2, volumeCbm: 1, currentStatus: 'PENDING_ASSIGNMENT' }, +] + +function plan(id: string, customerId: string) { + return { id, planNo: `LIP-${id}`, customerId, sourceOrderId: id, containerNo: 'CONT-1', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, lines: [], pallets: [], allocations: [] } +} + +async function openPendingContainers(wrapper: Awaited>) { + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + expect(pendingTab).toBeDefined() + await pendingTab!.trigger('click') await flushPromises() } @@ -53,50 +56,48 @@ describe('LastMileOrdersView inbound-plan conversion', () => { vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([plan('1', '100'), plan('2', '200')]) }) - it('submits the selected long customer id without coercing it to a number', async () => { - const wrapper = await mountView() - await selectAndConvert(wrapper) - expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ - orderId: '2092873463568596994', customerId: '2092618717861048321', - }) - }) - - it('refreshes the list and displays INBOUND_PLANNED after a successful conversion', async () => { - const base = { - id: '2092873463568596994', customerId: '2092618717861048321', customerName: '华远贸易', - orderNo: 'LMO-260827-0001', orderType: 'FIRST_LEG', - } + it('calls the atomic container endpoint once and refreshes all affected orders', async () => { vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 }) - .mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'INBOUND_PLANNED', inboundOrderNo: 'IP-260827-0001' }], page: 1, size: 10, total: 1, pages: 1 }) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) const wrapper = await mountView() - expect(wrapper.text()).toContain('待分配') - await selectAndConvert(wrapper) - expect(listLastMileOrders).toHaveBeenCalledTimes(2) - expect(wrapper.text()).toContain('已生成入库计划') - expect(wrapper.text()).toContain('IP-260827-0001') - }) - - it('fails closed when the selected row has no customer scope', async () => { - const wrapper = await mountView() - await selectAndConvert(wrapper, selectedRow('')) + await openPendingContainers(wrapper) + expect(wrapper.text()).toContain('CONT-1') + expect(wrapper.text()).toContain('将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业') + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + expect(button).toBeDefined() + expect(button!.attributes('disabled')).toBeUndefined() + await button!.trigger('click') + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() - expect(wrapper.text()).toContain('所选尾程订单缺少客户标识') + expect(listLastMileOrders).toHaveBeenCalledTimes(3) + expect(document.body.textContent).toContain('已为2个尾程订单生成2个入库计划') }) it.each([ - ['A0404', 'Inbound order not found'], - ['A0409', 'Inbound order version conflict'], - ])('keeps %s request metadata visible after conversion failure', async (code, message) => { - vi.mocked(convertLastMileOrderToInboundPlan).mockRejectedValueOnce(new ApiResponseError( + ['A0404', 'Container orders not found'], + ['A0409', 'Container conversion conflict'], + ])('keeps %s request evidence after the atomic operation fails', async (code, message) => { + vi.mocked(listLastMileOrders) + .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) + vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( message, code, 200, undefined, `request-${code}`, `trace-${code}`, )) const wrapper = await mountView() - await selectAndConvert(wrapper) + await openPendingContainers(wrapper) + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + await button!.trigger('click') + await flushPromises() expect(wrapper.text()).toContain(code) expect(wrapper.text()).toContain(`request-${code}`) expect(wrapper.text()).toContain(`trace-${code}`) + expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index c1619e02..fae38324 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -12,11 +12,12 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' +import { groupPendingOrdersByContainer, type LastMileContainerPlanRow } from '../lastMileContainerPlanning' import type { LastMileOrderListItem } from '../types' const rows = ref([]) @@ -93,6 +94,19 @@ const columns: DataTableColumn[] = [ { key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, ] +const containerColumns: DataTableColumn[] = [ + { key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, + { key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 180 }, + { key: 'orderCount', prop: 'orderCount', label: '订单数', width: 90, align: 'right' }, + { key: 'inboundCodeCount', prop: 'inboundCodeCount', label: '入仓码数', width: 100, align: 'right' }, + { key: 'customerCount', prop: 'customerCount', label: '客户数', width: 90, align: 'right' }, + { key: 'totalCartons', prop: 'totalCartons', label: '总箱数', width: 100, align: 'right' }, + { key: 'totalPallets', prop: 'totalPallets', label: '总托数', width: 100, align: 'right' }, + { key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' }, + { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, + { key: 'planNotice', prop: 'planNotice', label: '生成说明', minWidth: 320 }, +] +const containerRows = computed(() => groupPendingOrdersByContainer(rows.value)) async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) @@ -132,7 +146,7 @@ async function load(model: QueryModel = filters.value) { function search(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) } -function changeStatusTab() { page.value = 1; void load() } +function changeStatusTab() { selectedRows.value = []; page.value = 1; void load() } function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] } function openTransfer() { if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } @@ -152,20 +166,30 @@ async function submitTransfer() { } catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') } finally { actionLoading.value = false } } -async function convertInboundPlan() { - if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } - const selected = selectedRows.value[0] - const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : '' - if (!customerId) { - actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' } +async function generateContainerInboundPlans(row: LastMileContainerPlanRow) { + if (!row.eligible) { + actionError.value = { message: row.eligibility } return } - actionLoading.value = true; actionError.value = null + if (!row.sourceContainerBookingId) { + actionError.value = { message: '所选整柜缺少稳定来源柜身份,无法安全生成入库计划' } + return + } + actionLoading.value = true + actionError.value = null try { - await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId }) - selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load() - } catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') } - finally { actionLoading.value = false } + const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: row.sourceContainerBookingId }) + if (plans.length !== row.orderCount) { + actionError.value = { message: `柜级接口返回 ${plans.length} 个计划,与所选整柜 ${row.orderCount} 个尾程订单不一致` } + return + } + ElMessage.success(`已为${row.orderCount}个尾程订单生成${plans.length}个入库计划`) + await load() + } catch (error) { + actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试') + } finally { + actionLoading.value = false + } } function openDetail(source: object) { @@ -195,8 +219,19 @@ onMounted(() => { void load() }) -
- +
+ + + + + +
+
+ @@ -215,4 +250,5 @@ onMounted(() => { void load() }) diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index 295ce1d0..3f69f128 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -6,6 +6,7 @@ export interface LastMileOrderListItem { id: string customerId: string + sourceContainerBookingId: string orderNo: string warning: string orderType: string From 0181656b4fc83e650d8f64c33da020e622bc7ebc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:42:32 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lastMileContainerPlanning.test.ts | 36 -------- .../lastMileContainerPlanning.ts | 49 ----------- .../pages/LastMileOrdersView.behavior.test.ts | 84 ++++--------------- .../pages/LastMileOrdersView.vue | 14 +++- 4 files changed, 25 insertions(+), 158 deletions(-) delete mode 100644 src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts deleted file mode 100644 index 9e422fe1..00000000 --- a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { groupPendingOrdersByContainer } from './lastMileContainerPlanning' -import type { LastMileOrderListItem } from './types' - -function order(overrides: Partial): LastMileOrderListItem { - return { - id: '1', customerId: '100', sourceContainerBookingId: 'BOOKING-1', orderNo: 'LMO-1', warning: '—', orderType: '头程订单', rejectedAt: '—', inboundCode: 'IN-1', - owner: '客户', warehouse: '—', transferWarehouse: '—', fbaWarehouse: '—', containerNo: 'CONT-1', containerStatus: '—', abnormal: '否', held: '否', - pallets: 1, cartons: 10, volume: 2.5, quantity: '—', volumeWeight: '—', portEta: '—', portAta: '—', warehouseEta: '—', inboundOrderNo: '—', outboundOrderNo: '—', dispatchOrderNo: '—', relabelOrderNo: '—', relabelStatus: '—', consolidated: '—', status: '待分配', internalRemark: '—', customerRemark: '—', warehouseRemark: '—', createdAt: '—', - ...overrides, - } -} - -describe('last-mile container planning groups', () => { - it('groups the whole container while preserving one independent plan per order', () => { - const [group] = groupPendingOrdersByContainer([ - order({ id: '1', customerId: '100', inboundCode: 'IN-1', cartons: 10, pallets: 1, volume: 2.5 }), - order({ id: '2', customerId: '200', inboundCode: 'IN-2', cartons: 5, pallets: 2, volume: 1.25 }), - ]) - expect(group).toMatchObject({ - containerNo: 'CONT-1', orderCount: 2, inboundCodeCount: 2, customerCount: 2, - totalCartons: 15, totalPallets: 3, totalVolumeCbm: 3.75, eligible: true, - planNotice: '将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业', - orderIds: ['1', '2'], customerIds: ['100', '200'], - }) - }) - - it('fails closed for missing container/customer facts and never treats unknown totals as zero', () => { - const groups = groupPendingOrdersByContainer([ - order({ id: '1', sourceContainerBookingId: '', containerNo: '—', cartons: '—' }), - order({ id: '2', containerNo: 'CONT-2', customerId: '', pallets: '—' }), - ]) - expect(groups.find((group) => group.id === 'missing:1')).toMatchObject({ eligible: false, totalCartons: '—' }) - expect(groups.find((group) => group.containerNo === 'CONT-2')).toMatchObject({ eligible: false, totalPallets: '—' }) - }) -}) diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts index 5a69a5ea..fded1bb7 100644 --- a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts @@ -1,5 +1,3 @@ -import type { LastMileOrderListItem } from './types' - type DisplayTotal = number | '—' export interface LastMileContainerPlanRow { @@ -18,50 +16,3 @@ export interface LastMileContainerPlanRow { orderIds: string[] customerIds: string[] } - -function sumComplete(values: Array): DisplayTotal { - return values.every((value): value is number => typeof value === 'number') - ? values.reduce((sum, value) => sum + value, 0) - : '—' -} - -export function groupPendingOrdersByContainer(rows: LastMileOrderListItem[]): LastMileContainerPlanRow[] { - const groups = new Map() - rows.forEach((row) => { - const key = row.sourceContainerBookingId || `missing:${row.id}` - groups.set(key, [...(groups.get(key) || []), row]) - }) - - return [...groups.entries()].map(([key, orders]) => { - const sourceContainerBookingId = key.startsWith('missing:') ? '' : key - const containerValues = [...new Set(orders.map((order) => order.containerNo).filter((value) => value !== '—'))] - const containerNo = containerValues.length === 1 ? containerValues[0] : '—' - const customerIds = [...new Set(orders.map((order) => order.customerId.trim()).filter(Boolean))] - const inboundCodes = new Set(orders.map((order) => order.inboundCode).filter((value) => value !== '—')) - const hasCompleteCustomerScope = orders.every((order) => Boolean(order.customerId.trim())) - const eligible = Boolean(sourceContainerBookingId) && containerNo !== '—' && hasCompleteCustomerScope - const eligibility = !sourceContainerBookingId - ? '缺少来源柜身份,不可生成' - : containerNo === '—' - ? '柜号不一致或缺失,不可生成' - : !hasCompleteCustomerScope - ? '存在缺失客户,不可生成' - : '可生成' - return { - id: key, - sourceContainerBookingId, - containerNo, - orderCount: orders.length, - inboundCodeCount: inboundCodes.size, - customerCount: customerIds.length, - totalCartons: sumComplete(orders.map((order) => order.cartons)), - totalPallets: sumComplete(orders.map((order) => order.pallets)), - totalVolumeCbm: sumComplete(orders.map((order) => order.volume)), - eligibility, - planNotice: `将为 ${orders.length} 个尾程订单生成 ${orders.length} 个独立入库计划,PDA仍按同柜作业`, - eligible, - orderIds: orders.map((order) => order.id), - customerIds, - } - }).sort((left, right) => left.containerNo.localeCompare(right.containerNo)) -} diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index e09e981a..2be8131a 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -2,102 +2,46 @@ import ElementPlus from 'element-plus' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiResponseError } from '@/shared/api/http' import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ - listLastMileOrders: vi.fn(), - convertLastMileContainerToInboundPlans: vi.fn(), - convertLastMileOrderToInboundPlan: vi.fn(), - transferLastMileOrders: vi.fn(), + listLastMileOrders: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), + convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): Storage { const values = new Map() return { - get length() { return values.size }, clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), + get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), } } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -async function mountView() { - const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) - await flushPromises() - return wrapper -} - -const containerRecords = [ - { id: '1', customerId: '100', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-1', inboundCode: 'IN-1', containerNo: 'CONT-1', packageCount: 10, palletCount: 1, volumeCbm: 2, currentStatus: 'PENDING_ASSIGNMENT' }, - { id: '2', customerId: '200', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-2', inboundCode: 'IN-2', containerNo: 'CONT-1', packageCount: 5, palletCount: 2, volumeCbm: 1, currentStatus: 'PENDING_ASSIGNMENT' }, -] - -function plan(id: string, customerId: string) { - return { id, planNo: `LIP-${id}`, customerId, sourceOrderId: id, containerNo: 'CONT-1', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, lines: [], pallets: [], allocations: [] } -} - -async function openPendingContainers(wrapper: Awaited>) { - const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) - expect(pendingTab).toBeDefined() - await pendingTab!.trigger('click') - await flushPromises() -} - -describe('LastMileOrdersView inbound-plan conversion', () => { +describe('LastMileOrdersView container boundary', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) - vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([plan('1', '100'), plan('2', '200')]) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset() + vi.mocked(convertLastMileOrderToInboundPlan).mockReset() }) - it('calls the atomic container endpoint once and refreshes all affected orders', async () => { - vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - const wrapper = await mountView() - await openPendingContainers(wrapper) - expect(wrapper.text()).toContain('CONT-1') - expect(wrapper.text()).toContain('将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业') - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - expect(button).toBeDefined() - expect(button!.attributes('disabled')).toBeUndefined() - await button!.trigger('click') + it('fails closed instead of grouping the current order page as a whole container', async () => { + const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) - expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() - expect(listLastMileOrders).toHaveBeenCalledTimes(3) - expect(document.body.textContent).toContain('已为2个尾程订单生成2个入库计划') - }) - - it.each([ - ['A0404', 'Container orders not found'], - ['A0409', 'Container conversion conflict'], - ])('keeps %s request evidence after the atomic operation fails', async (code, message) => { - vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) - vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( - message, code, 200, undefined, `request-${code}`, `trace-${code}`, - )) - const wrapper = await mountView() - await openPendingContainers(wrapper) - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - await button!.trigger('click') + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + await pendingTab!.trigger('click') await flushPromises() - expect(wrapper.text()).toContain(code) - expect(wrapper.text()).toContain(`request-${code}`) - expect(wrapper.text()).toContain(`trace-${code}`) + expect(wrapper.text()).toContain('已停止从订单分页本地聚合整柜') + expect(listLastMileOrders).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index fae38324..15c897f8 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -17,7 +17,7 @@ import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' -import { groupPendingOrdersByContainer, type LastMileContainerPlanRow } from '../lastMileContainerPlanning' +import type { LastMileContainerPlanRow } from '../lastMileContainerPlanning' import type { LastMileOrderListItem } from '../types' const rows = ref([]) @@ -29,6 +29,7 @@ const page = ref(1) const pageSize = ref(10) const total = ref(0) const selectedRows = ref([]) +const containerRows = ref([]) const actionLoading = ref(false) const actionError = ref(null) const transferOpen = ref(false) @@ -106,12 +107,19 @@ const containerColumns: DataTableColumn[] = [ { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, { key: 'planNotice', prop: 'planNotice', label: '生成说明', minWidth: 320 }, ] -const containerRows = computed(() => groupPendingOrdersByContainer(rows.value)) async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) loading.value = true loadError.value = null + if (statusTab.value === '待分配') { + rows.value = [] + containerRows.value = [] + total.value = 0 + loadError.value = { message: '柜级待分配摘要分页接口尚未在当前 test 后端提供,已停止从订单分页本地聚合整柜' } + loading.value = false + return + } try { const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : [] const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined @@ -124,7 +132,7 @@ async function load(model: QueryModel = filters.value) { inboundCode: String(filters.value.inboundCode || '').trim() || undefined, sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined, containerNo: String(filters.value.containerNo || '').trim() || undefined, - currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined, + currentStatus: String(filters.value.status || '').trim() || undefined, transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined, abnormal: booleanValue(filters.value.abnormal), detained: booleanValue(filters.value.detained), From 929f50dc55a37b5549100517161977e5408a02d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:16:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../API_INTEGRATION_MATRIX.md | 3 +- .../api/lastMileOrders.test.ts | 29 +++++- .../overseas-warehouse/api/lastMileOrders.ts | 29 ++++++ .../lastMileContainerPlanning.ts | 18 ---- .../pages/LastMileOrdersView.behavior.test.ts | 96 ++++++++++++++++--- .../pages/LastMileOrdersView.vue | 53 ++++++---- 7 files changed, 178 insertions(+), 52 deletions(-) delete mode 100644 src/modules/overseas-warehouse/lastMileContainerPlanning.ts diff --git a/AGENTS.md b/AGENTS.md index ef295bde..67650e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| -| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` | +| 尾程订单与待分配整柜 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 待分配页按 `sourceContainerBookingId` 消费服务端 `/orders/container-summaries` 柜级分页,不在浏览器拼柜;整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | | 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index c4140b6c..e6cae4f1 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -4,10 +4,11 @@ | 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 | |---|---|---|---| -| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—`;整柜操作要求列表响应携带稳定 `sourceContainerBookingId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | +| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | | 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 | | 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA | | 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功刷新待分配列表;计划数与整柜订单数不一致时不提示成功;错误保留 code/requestId/traceId | +| 待分配整柜摘要分页 | `GET /platform/v1/last-leg/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 服务端一柜一行并负责全量聚合;前端只消费摘要分页,不从订单分页本地聚合 | | 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 | | 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 | | FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 | diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index dc63e82a..6deb5b33 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { overseasHttp } from '@/shared/api/overseasHttp' -import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders } from './lastMileOrders' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) describe('last-mile order overseas API', () => { - beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) + beforeEach(() => { + vi.mocked(overseasHttp.get).mockReset() + vi.mocked(overseasHttp.put).mockReset() + vi.mocked(overseasHttp.post).mockReset() + }) it('loads all platform orders without a customer scope', async () => { const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } @@ -64,4 +68,25 @@ describe('last-mile order overseas API', () => { sourceContainerBookingId: '2092873463568597001', }) }) + + it('loads server-side pending container summaries with pagination and filters', async () => { + const record = { + sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', orderCount: 15, + inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30, + totalVolumeCbm: 45.5, eligible: true, + } + vi.mocked(overseasHttp.get).mockResolvedValue({ + code: '200', message: 'success', data: { records: [record], page: '2', size: '10', total: '21', pages: '3' }, + }) + await expect(listPendingContainerSummaries({ + page: 2, size: 10, containerNo: 'CONT-15', + createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59', + })).resolves.toEqual({ records: [record], page: 2, size: 10, total: 21, pages: 3 }) + expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries', { + params: { + page: 2, size: 10, containerNo: 'CONT-15', + createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59', + }, + }) + }) }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 5142a982..9c16d449 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -155,6 +155,27 @@ export interface ConvertInboundPlanPayload { customerId: string } +export interface PendingContainerSummaryQuery { + page: number + size: number + containerNo?: string + createdFrom?: string + createdTo?: string +} + +export interface PendingContainerSummaryDto { + sourceContainerBookingId: string + containerNo: string | null + orderCount: number + inboundCodeCount: number + customerCount: number + totalPackageCount: number + totalPalletCount: number + totalVolumeCbm: number + eligible: boolean + ineligibleReason?: string | null +} + export interface ConvertContainerInboundPlanPayload { sourceContainerBookingId: string } @@ -203,6 +224,14 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP return unwrapApiResponse(response) } +export async function listPendingContainerSummaries(query: PendingContainerSummaryQuery): Promise> { + const response = await overseasHttp.get>, ApiEnvelope>>( + '/platform/v1/last-leg/orders/container-summaries', + { params: query }, + ) + return normalizeOverseasPage(unwrapApiResponse(response)) +} + export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { const response = await overseasHttp.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/convert-container', payload, diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts deleted file mode 100644 index fded1bb7..00000000 --- a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts +++ /dev/null @@ -1,18 +0,0 @@ -type DisplayTotal = number | '—' - -export interface LastMileContainerPlanRow { - id: string - sourceContainerBookingId: string - containerNo: string - orderCount: number - inboundCodeCount: number - customerCount: number - totalCartons: DisplayTotal - totalPallets: DisplayTotal - totalVolumeCbm: DisplayTotal - eligibility: string - planNotice: string - eligible: boolean - orderIds: string[] - customerIds: string[] -} diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 2be8131a..acb5986c 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -2,14 +2,15 @@ import ElementPlus from 'element-plus' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' +import { ApiResponseError } from '@/shared/api/http' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ - listLastMileOrders: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), - convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), + listLastMileOrders: vi.fn(), listPendingContainerSummaries: vi.fn(), + convertLastMileContainerToInboundPlans: vi.fn(), convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } @@ -21,27 +22,96 @@ function memoryStorage(): Storage { } } +const summary = { + sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', orderCount: 15, + inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30, + totalVolumeCbm: 45.5, eligible: true, ineligibleReason: null, +} +function plans(count = 15) { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), planNo: `LIP-${index + 1}`, customerId: String(index % 3 + 1), sourceOrderId: String(index + 1), + containerNo: 'CONT-15', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, + lines: [], pallets: [], allocations: [], + })) +} + enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -describe('LastMileOrdersView container boundary', () => { +async function openPending() { + const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + await pendingTab!.trigger('click') + await flushPromises() + return wrapper +} + +describe('LastMileOrdersView server-side container summaries', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - vi.mocked(convertLastMileContainerToInboundPlans).mockReset() + vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 }) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue(plans()) vi.mocked(convertLastMileOrderToInboundPlan).mockReset() }) - it('fails closed instead of grouping the current order page as a whole container', async () => { - const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) - await flushPromises() - const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) - await pendingTab!.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('已停止从订单分页本地聚合整柜') + it('uses one server summary for a 15-order container that could span order pages', async () => { + const wrapper = await openPending() + expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined }) + expect(wrapper.text()).toContain('CONT-15') + expect(wrapper.text()).toContain('15') + expect(wrapper.text()).toContain('将为 15 个尾程订单生成 15 个独立入库计划') expect(listLastMileOrders).toHaveBeenCalledTimes(1) - expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + }) + + it('requests the next server summary page instead of deriving containers from order rows', async () => { + const wrapper = await openPending() + const nextPage = wrapper.find('button[aria-label="第 2 页"]') + expect(nextPage.exists()).toBe(true) + await nextPage.trigger('click') + await flushPromises() + expect(listPendingContainerSummaries).toHaveBeenLastCalledWith({ + page: 2, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined, + }) + expect(listLastMileOrders).toHaveBeenCalledTimes(1) + }) + + it('calls convert-container once, validates N plans and refreshes the server summary', async () => { + const wrapper = await openPending() + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + await button!.trigger('click') + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() + expect(listPendingContainerSummaries).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('已为15个尾程订单生成15个入库计划') + }) + + it('does not report success or refresh when N returned plans do not match N orders', async () => { + vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValueOnce(plans(14)) + const wrapper = await openPending() + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + await button!.trigger('click') + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + expect(listPendingContainerSummaries).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('返回 14 个计划') + expect(document.body.textContent).not.toContain('已为15个尾程订单生成14个入库计划') + }) + + it.each(['A0404', 'A0409'])('keeps %s request evidence visible', async (code) => { + vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( + '柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`, + )) + const wrapper = await openPending() + const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) + await button!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain(code) + expect(wrapper.text()).toContain(`request-${code}`) + expect(wrapper.text()).toContain(`trace-${code}`) }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 15c897f8..aac84027 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -12,12 +12,11 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { convertLastMileContainerToInboundPlans, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' -import type { LastMileContainerPlanRow } from '../lastMileContainerPlanning' import type { LastMileOrderListItem } from '../types' const rows = ref([]) @@ -28,15 +27,16 @@ const router = useRouter() const page = ref(1) const pageSize = ref(10) const total = ref(0) +const containerTotal = ref(0) const selectedRows = ref([]) -const containerRows = ref([]) +const containerRows = ref([]) const actionLoading = ref(false) const actionError = ref(null) const transferOpen = ref(false) const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) const statusTab = ref<'全部' | '待分配'>('全部') const filters = ref({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] }) -const fields = computed(() => [ +const allFields: QueryField[] = [ { key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' }, { key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [ @@ -59,7 +59,10 @@ const fields = computed(() => [ { key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'createdAt', type: 'daterange', label: '创建时间' }, -]) +] +const fields = computed(() => statusTab.value === '待分配' + ? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key)) + : allFields) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left' }, @@ -101,11 +104,11 @@ const containerColumns: DataTableColumn[] = [ { key: 'orderCount', prop: 'orderCount', label: '订单数', width: 90, align: 'right' }, { key: 'inboundCodeCount', prop: 'inboundCodeCount', label: '入仓码数', width: 100, align: 'right' }, { key: 'customerCount', prop: 'customerCount', label: '客户数', width: 90, align: 'right' }, - { key: 'totalCartons', prop: 'totalCartons', label: '总箱数', width: 100, align: 'right' }, - { key: 'totalPallets', prop: 'totalPallets', label: '总托数', width: 100, align: 'right' }, + { key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 100, align: 'right' }, + { key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', width: 100, align: 'right' }, { key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' }, { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, - { key: 'planNotice', prop: 'planNotice', label: '生成说明', minWidth: 320 }, + { key: 'planNotice', label: '生成说明', minWidth: 320, slotName: 'planNotice' }, ] async function load(model: QueryModel = filters.value) { @@ -114,10 +117,24 @@ async function load(model: QueryModel = filters.value) { loadError.value = null if (statusTab.value === '待分配') { rows.value = [] - containerRows.value = [] - total.value = 0 - loadError.value = { message: '柜级待分配摘要分页接口尚未在当前 test 后端提供,已停止从订单分页本地聚合整柜' } - loading.value = false + const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : [] + try { + const result = await listPendingContainerSummaries({ + page: page.value, + size: pageSize.value, + containerNo: String(filters.value.containerNo || '').trim() || undefined, + createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined, + createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined, + }) + containerRows.value = result.records + containerTotal.value = result.total + } catch (error) { + containerRows.value = [] + containerTotal.value = 0 + loadError.value = getApiErrorDetails(error, '柜级待分配摘要加载失败,请重试') + } finally { + loading.value = false + } return } try { @@ -174,9 +191,9 @@ async function submitTransfer() { } catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') } finally { actionLoading.value = false } } -async function generateContainerInboundPlans(row: LastMileContainerPlanRow) { +async function generateContainerInboundPlans(row: PendingContainerSummaryDto) { if (!row.eligible) { - actionError.value = { message: row.eligibility } + actionError.value = { message: row.ineligibleReason || '当前整柜不满足生成资格' } return } if (!row.sourceContainerBookingId) { @@ -229,13 +246,15 @@ onMounted(() => { void load() })
- + - + + +
From 44d05e77cb452c82dfe517c5e3e4785d060db712 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:49:05 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API_INTEGRATION_MATRIX.md | 4 +- .../pages/LastMileOrdersView.behavior.test.ts | 142 +++++++++------ .../pages/LastMileOrdersView.vue | 162 ++++++++++-------- 3 files changed, 182 insertions(+), 126 deletions(-) diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index e6cae4f1..2a1ebc29 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -7,8 +7,8 @@ | 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 | | 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 | | 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA | -| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功刷新待分配列表;计划数与整柜订单数不一致时不提示成功;错误保留 code/requestId/traceId | -| 待分配整柜摘要分页 | `GET /platform/v1/last-leg/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 服务端一柜一行并负责全量聚合;前端只消费摘要分页,不从订单分页本地聚合 | +| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功按接口返回计划数提示并刷新原待分配订单列表,不用当前订单页行数校验整柜总数;错误保留 code/requestId/traceId | +| 待分配整柜摘要分页 | `GET /platform/v1/last-leg/orders/container-summaries` | DTO:`sourceContainerBookingId,containerNo?,orderCount,inboundCodeCount,customerCount,totalPackageCount,totalPalletCount,totalVolumeCbm,eligible,ineligibleReason?`;query:`page,size,containerNo?,createdFrom?,createdTo?` | 仅供原待分配订单页的“按柜批量生成”选择弹框加载柜候选;不替换原订单表,也不从订单分页本地聚合 | | 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 | | 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 | | FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 | diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index acb5986c..c60b391f 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -3,14 +3,20 @@ import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' -import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries } from '../api/lastMileOrders' +import DataTable from '@/shared/components/DataTable.vue' +import { + convertLastMileContainerToInboundPlans, + convertLastMileOrderToInboundPlan, + listLastMileOrders, + listPendingContainerSummaries, +} from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), listPendingContainerSummaries: vi.fn(), - convertLastMileContainerToInboundPlans: vi.fn(), convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), + convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } @@ -22,94 +28,126 @@ function memoryStorage(): Storage { } } +const bookingId = '2092873463568597001' +const customerId = '2092618717861048321' const summary = { - sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', orderCount: 15, + sourceContainerBookingId: bookingId, containerNo: 'CONT-15', orderCount: 15, inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30, totalVolumeCbm: 45.5, eligible: true, ineligibleReason: null, } -function plans(count = 15) { - return Array.from({ length: count }, (_, index) => ({ - id: String(index + 1), planNo: `LIP-${index + 1}`, customerId: String(index % 3 + 1), sourceOrderId: String(index + 1), - containerNo: 'CONT-15', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, - lines: [], pallets: [], allocations: [], - })) +function selectedRow(overrides: Record = {}) { + return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides } } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -async function openPending() { +async function mountView() { const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() - const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + return wrapper +} + +async function openPending() { + const wrapper = await mountView() + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配') await pendingTab!.trigger('click') await flushPromises() return wrapper } -describe('LastMileOrdersView server-side container summaries', () => { +async function selectRows(wrapper: Awaited>, rows: object[]) { + wrapper.findComponent(DataTable).vm.$emit('selection-change', rows) + await wrapper.vm.$nextTick() +} + +function button(wrapper: Awaited>, text: string) { + return wrapper.findAll('button').find((item) => item.text().includes(text)) +} + +async function openContainerDialog(wrapper: Awaited>) { + await button(wrapper, '按柜批量生成')!.trigger('click') + await flushPromises() +} + +async function chooseContainer(wrapper: Awaited>) { + const selects = wrapper.findAllComponents({ name: 'ElSelect' }) + const containerSelect = selects[selects.length - 1] + expect(containerSelect).toBeDefined() + containerSelect.vm.$emit('update:modelValue', bookingId) + await wrapper.vm.$nextTick() +} + +describe('LastMileOrdersView pending order and container conversion', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 }) - vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue(plans()) - vi.mocked(convertLastMileOrderToInboundPlan).mockReset() + vi.mocked(listPendingContainerSummaries).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 }) + vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never) }) - it('uses one server summary for a 15-order container that could span order pages', async () => { + it('keeps the original pending order table and does not render a view switch or container table', async () => { const wrapper = await openPending() - expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined }) - expect(wrapper.text()).toContain('CONT-15') - expect(wrapper.text()).toContain('15') - expect(wrapper.text()).toContain('将为 15 个尾程订单生成 15 个独立入库计划') - expect(listLastMileOrders).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('尾程单号') + expect(wrapper.text()).toContain('海外仓') + expect(wrapper.text()).toContain('转仓') + expect(wrapper.text()).toContain('生成入库计划') + expect(wrapper.text()).toContain('按柜批量生成') + expect(wrapper.text()).not.toContain('按订单') + expect(wrapper.text()).not.toContain('待分配整柜') + expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' })) + expect(listPendingContainerSummaries).not.toHaveBeenCalled() }) - it('requests the next server summary page instead of deriving containers from order rows', async () => { + it('keeps single-order conversion isolated with long order and customer ids', async () => { const wrapper = await openPending() - const nextPage = wrapper.find('button[aria-label="第 2 页"]') - expect(nextPage.exists()).toBe(true) - await nextPage.trigger('click') + await selectRows(wrapper, [selectedRow()]) + await button(wrapper, '生成入库计划')!.trigger('click') await flushPromises() - expect(listPendingContainerSummaries).toHaveBeenLastCalledWith({ - page: 2, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined, - }) - expect(listLastMileOrders).toHaveBeenCalledTimes(1) + expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1) + expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId }) + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + expect(listPendingContainerSummaries).not.toHaveBeenCalled() }) - it('calls convert-container once, validates N plans and refreshes the server summary', async () => { + it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => { const wrapper = await openPending() - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - await button!.trigger('click') - await flushPromises() - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) - expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() - expect(listPendingContainerSummaries).toHaveBeenCalledTimes(2) - expect(document.body.textContent).toContain('已为15个尾程订单生成15个入库计划') - }) - - it('does not report success or refresh when N returned plans do not match N orders', async () => { - vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValueOnce(plans(14)) - const wrapper = await openPending() - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - await button!.trigger('click') - await flushPromises() - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + await openContainerDialog(wrapper) expect(listPendingContainerSummaries).toHaveBeenCalledTimes(1) - expect(wrapper.text()).toContain('返回 14 个计划') - expect(document.body.textContent).not.toContain('已为15个尾程订单生成14个入库计划') + expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined }) + expect(document.body.textContent).toContain('选择待分配柜') + expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划') + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() }) - it.each(['A0404', 'A0409'])('keeps %s request evidence visible', async (code) => { + it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => { + const wrapper = await openPending() + await openContainerDialog(wrapper) + await chooseContainer(wrapper) + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + expect(confirm.disabled).toBe(false) + confirm.click() + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId }) + expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划') + expect(listLastMileOrders).toHaveBeenCalledTimes(3) + }) + + it.each(['A0404', 'A0409'])('keeps %s request evidence visible after cabinet conversion failure', async (code) => { vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( '柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`, )) const wrapper = await openPending() - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - await button!.trigger('click') + await openContainerDialog(wrapper) + await chooseContainer(wrapper) + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + confirm.click() await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain(code) expect(wrapper.text()).toContain(`request-${code}`) expect(wrapper.text()).toContain(`trace-${code}`) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index aac84027..971e536b 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -12,7 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' -import { convertLastMileContainerToInboundPlans, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders' +import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders, listPendingContainerSummaries, transferLastMileOrders, type PendingContainerSummaryDto } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' @@ -27,13 +27,17 @@ const router = useRouter() const page = ref(1) const pageSize = ref(10) const total = ref(0) -const containerTotal = ref(0) const selectedRows = ref([]) -const containerRows = ref([]) const actionLoading = ref(false) const actionError = ref(null) const transferOpen = ref(false) const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) +const containerConvertOpen = ref(false) +const containerOptions = ref([]) +const containerOptionsLoading = ref(false) +const containerLoadError = ref(null) +const selectedContainerBookingId = ref('') +let containerOptionRequest = 0 const statusTab = ref<'全部' | '待分配'>('全部') const filters = ref({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] }) const allFields: QueryField[] = [ @@ -60,9 +64,8 @@ const allFields: QueryField[] = [ { key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { key: 'createdAt', type: 'daterange', label: '创建时间' }, ] -const fields = computed(() => statusTab.value === '待分配' - ? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key)) - : allFields) +const fields = computed(() => allFields) +const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value)) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left' }, @@ -98,45 +101,10 @@ const columns: DataTableColumn[] = [ { key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, ] -const containerColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, - { key: 'containerNo', prop: 'containerNo', label: '柜号', minWidth: 180 }, - { key: 'orderCount', prop: 'orderCount', label: '订单数', width: 90, align: 'right' }, - { key: 'inboundCodeCount', prop: 'inboundCodeCount', label: '入仓码数', width: 100, align: 'right' }, - { key: 'customerCount', prop: 'customerCount', label: '客户数', width: 90, align: 'right' }, - { key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 100, align: 'right' }, - { key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', width: 100, align: 'right' }, - { key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' }, - { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, - { key: 'planNotice', label: '生成说明', minWidth: 320, slotName: 'planNotice' }, -] - async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) loading.value = true loadError.value = null - if (statusTab.value === '待分配') { - rows.value = [] - const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : [] - try { - const result = await listPendingContainerSummaries({ - page: page.value, - size: pageSize.value, - containerNo: String(filters.value.containerNo || '').trim() || undefined, - createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined, - createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined, - }) - containerRows.value = result.records - containerTotal.value = result.total - } catch (error) { - containerRows.value = [] - containerTotal.value = 0 - loadError.value = getApiErrorDetails(error, '柜级待分配摘要加载失败,请重试') - } finally { - loading.value = false - } - return - } try { const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : [] const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined @@ -149,7 +117,7 @@ async function load(model: QueryModel = filters.value) { inboundCode: String(filters.value.inboundCode || '').trim() || undefined, sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined, containerNo: String(filters.value.containerNo || '').trim() || undefined, - currentStatus: String(filters.value.status || '').trim() || undefined, + currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined, transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined, abnormal: booleanValue(filters.value.abnormal), detained: booleanValue(filters.value.detained), @@ -171,7 +139,11 @@ async function load(model: QueryModel = filters.value) { function search(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) } -function changeStatusTab() { selectedRows.value = []; page.value = 1; void load() } +function changeStatusTab() { + selectedRows.value = [] + page.value = 1 + void load() +} function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] } function openTransfer() { if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } @@ -191,24 +163,69 @@ async function submitTransfer() { } catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') } finally { actionLoading.value = false } } -async function generateContainerInboundPlans(row: PendingContainerSummaryDto) { - if (!row.eligible) { - actionError.value = { message: row.ineligibleReason || '当前整柜不满足生成资格' } - return - } - if (!row.sourceContainerBookingId) { - actionError.value = { message: '所选整柜缺少稳定来源柜身份,无法安全生成入库计划' } +async function convertInboundPlan() { + if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } + const selected = selectedRows.value[0] + const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : '' + if (!customerId) { + actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' } return } actionLoading.value = true actionError.value = null try { - const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: row.sourceContainerBookingId }) - if (plans.length !== row.orderCount) { - actionError.value = { message: `柜级接口返回 ${plans.length} 个计划,与所选整柜 ${row.orderCount} 个尾程订单不一致` } - return - } - ElMessage.success(`已为${row.orderCount}个尾程订单生成${plans.length}个入库计划`) + await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId }) + selectedRows.value = [] + ElMessage.success('入库计划生成成功') + await load() + } catch (error) { + actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') + } finally { + actionLoading.value = false + } +} +async function loadContainerOptions(containerNo = '') { + const request = ++containerOptionRequest + containerOptionsLoading.value = true + containerLoadError.value = null + try { + const result = await listPendingContainerSummaries({ + page: 1, + size: 50, + containerNo: containerNo.trim() || undefined, + }) + if (request !== containerOptionRequest) return + containerOptions.value = result.records + } catch (error) { + if (request !== containerOptionRequest) return + containerOptions.value = [] + containerLoadError.value = getApiErrorDetails(error, '待分配柜列表加载失败,请重试') + } finally { + if (request === containerOptionRequest) containerOptionsLoading.value = false + } +} +function openContainerConversion() { + selectedContainerBookingId.value = '' + containerOptions.value = [] + containerLoadError.value = null + actionError.value = null + containerConvertOpen.value = true + void loadContainerOptions() +} +async function convertContainerInboundPlans() { + const container = selectedContainer.value + if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return } + if (!container.eligible) { + actionError.value = { message: container.ineligibleReason || '所选柜当前不满足生成资格' } + return + } + actionLoading.value = true + actionError.value = null + try { + const plans = await convertLastMileContainerToInboundPlans({ sourceContainerBookingId: container.sourceContainerBookingId }) + containerConvertOpen.value = false + selectedContainerBookingId.value = '' + ElMessage.success(`已按柜生成 ${plans.length} 个独立入库计划`) await load() } catch (error) { actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试') @@ -244,21 +261,8 @@ onMounted(() => { void load() }) -
- - - - - - - -
-
- +
+ @@ -272,10 +276,24 @@ onMounted(() => { void load() }) + +

将为所选柜内所有待分配尾程订单分别生成独立入库计划。

+ + + + + + + + + + + +