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/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 } } 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 diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index be23d528..2a1ebc29 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -4,9 +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` | `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 | +| 尾程整柜转入仓计划 | `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 018f9bb0..6deb5b33 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,15 +1,20 @@ 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, 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 () => { - 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', 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', { params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' }, }) @@ -47,10 +52,41 @@ 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', + }) + }) + + 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', + }) + }) + + 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 18dad487..9c16d449 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 @@ -150,6 +152,32 @@ export interface TransferDestinationPayload { export interface ConvertInboundPlanPayload { orderId: string + 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 } export interface LastMileOrderDetailDto { @@ -195,3 +223,18 @@ 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, + ) + return unwrapApiResponse(response) +} 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..dafe7d12 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) { @@ -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)), @@ -69,11 +70,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) + + +
- + +
@@ -199,9 +276,24 @@ onMounted(() => { void 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..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 @@ -19,11 +20,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