Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
wujinliang
2026-08-27 21:48:31 +08:00
24 changed files with 649 additions and 76 deletions
+1 -1
View File
@@ -63,7 +63,7 @@
| 关键词 | 页面/路由入口 | API 与联查 | | 关键词 | 页面/路由入口 | 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/` | | 入仓单、入仓计划、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 出库或派送 | | 排车查询与智能预排 | `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 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | | 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
@@ -2,7 +2,7 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue' 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 { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' 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 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 documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<void>; completeClearance: () => Promise<void> }
function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm }
describe('ContainerDetailView', () => { describe('ContainerDetailView', () => {
beforeEach(() => { beforeEach(() => {
@@ -163,18 +170,26 @@ describe('ContainerDetailView', () => {
wrapper.unmount() 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 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 updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
const refresh = deferred<typeof overview>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never) vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
const wrapper = mountView(); await flushPromises() const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' }) const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) clearanceVm(wrapper).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises() const request = clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料') 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') expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
refresh.resolve(overview)
await request; await flushPromises()
expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
@@ -196,17 +211,75 @@ describe('ContainerDetailView', () => {
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => { 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 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 } 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')) vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
const wrapper = mountView(); await flushPromises() const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) clearanceVm(wrapper).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises() await clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledOnce() 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(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-clearance-409') 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<typeof overview>()
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() wrapper.unmount()
}) })
@@ -303,7 +303,7 @@ async function saveException() {
} }
async function openClearance() { 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) } try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false } finally { clearanceLoading.value = false }
@@ -312,17 +312,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw
function removeClearanceFile() { clearanceFile.value = null } function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() { async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } 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 { try {
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料') const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息') if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
clearanceDocument.value = uploaded clearanceDocument.value = uploaded
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE'))) 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 { try {
const refreshed = await getContainerOverview(containerId.value) 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')] } 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, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') } } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
} catch (cause) { } catch (cause) {
const details = getApiErrorDetails(cause, '目的港清关资料上传失败') const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
if (details.code === 'A0409') { if (details.code === 'A0409') {
@@ -334,8 +335,20 @@ async function uploadClearance() {
} }
async function completeClearance() { async function completeClearance() {
if (!clearanceDocument.value) return if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null 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; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } 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, '清关完成失败') } catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false } finally { clearanceSaving.value = false }
} }
@@ -82,11 +82,20 @@ describe('inbound weight OCR API', () => {
) )
}) })
it('loads same-warehouse pallet snapshots through Firstleg', async () => { it('normalizes the formal paged pallet response returned by Firstleg', async () => {
const options = [{ palletId: '11', palletCode: 'TP-11', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12 }] const records = [
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: options }) { 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( expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-pallet-options', '/platform/v1/first-leg/inbounds/20001/weight-pallet-options',
{ params: { tenantId: '10001' } }, { 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 () => { 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() const controller = new AbortController()
await getWeightPalletOptions('10001', '20001', controller.signal) await getWeightPalletOptions('10001', '20001', controller.signal)
+23 -2
View File
@@ -6,6 +6,7 @@ import type {
CreateWeightResultRequest, CreateWeightResultRequest,
WeightImageUploadResult, WeightImageUploadResult,
WeightPalletOption, WeightPalletOption,
WeightPalletOptionPage,
WeightResultResource, WeightResultResource,
WeightWorkspaceResource, WeightWorkspaceResource,
} from '../types' } from '../types'
@@ -15,10 +16,23 @@ const imageBasePath = '/platform/v1/files/measurements'
const maxErrorEnvelopeBytes = 1024 * 1024 const maxErrorEnvelopeBytes = 1024 * 1024
const confidenceScale = 100_000 const confidenceScale = 100_000
interface WeightPalletOptionPageResponse {
records: WeightPalletOption[]
page: number | string
size: number | string
total: number | string
pages: number | string
}
function segment(value: string) { function segment(value: string) {
return encodeURIComponent(value) 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) { export function normalizeWeightConfidence(value: number | null) {
if (value == null || !Number.isFinite(value)) return null if (value == null || !Number.isFinite(value)) return null
const clamped = Math.min(1, Math.max(0, value)) 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) { export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<WeightPalletOption[]>, ApiEnvelope<WeightPalletOption[]>>( const response = await http.get<ApiEnvelope<WeightPalletOptionPageResponse>, ApiEnvelope<WeightPalletOptionPageResponse>>(
`${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`, `${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`,
{ params: { tenantId }, ...(signal ? { signal } : {}) }, { 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) { export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
@@ -72,6 +72,12 @@ const workspace: WeightWorkspaceResource = {
weightResults: [pendingWeight], 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) enableAutoUnmount(afterEach)
const allWeightPermissions = [ const allWeightPermissions = [
@@ -122,9 +128,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
vi.clearAllMocks() vi.clearAllMocks()
document.body.innerHTML = '' document.body.innerHTML = ''
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace) vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
vi.mocked(getWeightPalletOptions).mockResolvedValue([{ vi.mocked(getWeightPalletOptions).mockResolvedValue(palletPage())
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}])
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })) vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' })) vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
vi.stubGlobal('URL', { 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 () => { 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() const wrapper = mountPanel()
await flushPromises() await flushPromises()
@@ -334,6 +338,28 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.findAll('button').some((button) => button.text().includes('重新加载'))).toBe(true) 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 () => { it('shows pallet loading failures without hiding existing weight results', async () => {
vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503)) vi.mocked(getWeightPalletOptions).mockRejectedValueOnce(new ApiResponseError('托盘服务暂不可用', 'A0500', 503))
const wrapper = mountPanel() 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 () => { 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), palletId: String(60_000 + index),
palletCode: `TP-${index}`, palletCode: `TP-${index}`,
palletType: `托盘类型 ${index}`, palletType: `托盘类型 ${index}`,
capacityCbm: 1.2, capacityCbm: 1.2,
tareWeightKg: 12, tareWeightKg: 12,
}))) }))))
const wrapper = mountPanel() const wrapper = mountPanel()
await flushPromises() await flushPromises()
@@ -401,9 +427,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2] const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2]
wrapper.unmount() wrapper.unmount()
resolveOptions?.([{ resolveOptions?.(palletPage([{
palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10, palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10,
}]) }]))
await flushPromises() await flushPromises()
expect(signal?.aborted).toBe(true) expect(signal?.aborted).toBe(true)
@@ -242,8 +242,8 @@ async function loadPalletOptions() {
try { try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal) const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal)
if (sequence !== palletLoadSequence || controller.signal.aborted) return if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result palletOptions.value = result.records
if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = '' if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
} catch (error) { } catch (error) {
if (sequence !== palletLoadSequence || controller.signal.aborted) return if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = [] palletOptions.value = []
+8
View File
@@ -43,6 +43,14 @@ export interface WeightPalletOption {
tareWeightKg: number tareWeightKg: number
} }
export interface WeightPalletOptionPage {
records: WeightPalletOption[]
page: number
size: number
total: number
pages: number
}
export interface WeightImageUploadResult { export interface WeightImageUploadResult {
fileId: string fileId: string
fileName: string fileName: string
@@ -4,9 +4,11 @@
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 | | 页面/能力 | 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`;失败保留弹框,不提示成功 | | 尾程转目的仓 | `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` | 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` 字符串化 | | 入库详情 | `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 仓勾选 | | 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 仓勾选 |
@@ -1,15 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp' 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() } })) vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
describe('last-mile order overseas API', () => { 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 () => { 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' } }) const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' }
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 }) 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', { expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' }, 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'] })) 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: {} }) vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' }) await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { orderId: '190000000000000099' }) expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', {
expect(JSON.stringify(vi.mocked(overseasHttp.post).mock.calls[0]?.[1])).not.toContain('customerId') 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',
},
})
}) })
}) })
@@ -1,5 +1,6 @@
import { overseasHttp } from '@/shared/api/overseasHttp' import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { InboundPlanDetailDto } from './inboundPlans'
export interface OverseasPageResult<T> { export interface OverseasPageResult<T> {
records: T[] records: T[]
@@ -41,6 +42,7 @@ export interface LastMileOrderQuery {
export interface LastMileOrderSummaryDto { export interface LastMileOrderSummaryDto {
id: string id: string
customerId: string customerId: string
sourceContainerBookingId?: string | null
orderNo: string orderNo: string
orderType?: string | null orderType?: string | null
orderTypeName?: string | null orderTypeName?: string | null
@@ -150,6 +152,32 @@ export interface TransferDestinationPayload {
export interface ConvertInboundPlanPayload { export interface ConvertInboundPlanPayload {
orderId: string 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 { export interface LastMileOrderDetailDto {
@@ -195,3 +223,18 @@ export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundP
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function listPendingContainerSummaries(query: PendingContainerSummaryQuery): Promise<OverseasPageResult<PendingContainerSummaryDto>> {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<PendingContainerSummaryDto>>, ApiEnvelope<OverseasPagePayload<PendingContainerSummaryDto>>>(
'/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<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
)
return unwrapApiResponse(response)
}
@@ -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: `<div style="width:${width}"><OrderReferenceText value="${orderNo}" /></div>`,
})
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')
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
value?: string | null
emptyText?: string
}>(), { value: null, emptyText: '—' })
const displayValue = computed(() => {
const value = String(props.value ?? '')
return value || props.emptyText
})
const fullTitle = computed(() => displayValue.value === props.emptyText ? undefined : displayValue.value)
</script>
<template>
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span>
</template>
<style scoped>
.order-reference-text {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
</style>
@@ -22,7 +22,7 @@ defineEmits<{ back: [] }>()
<el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button> <el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
<div class="overseas-detail-title"> <div class="overseas-detail-title">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p> <p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
</div> </div>
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span> <span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
</header> </header>
@@ -37,8 +37,8 @@ defineEmits<{ back: [] }>()
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; } .overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
.overseas-detail-title { min-width: 0; flex: 1; } .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 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 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 { color: var(--cc-text); overflow-wrap: anywhere; } .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 { 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); } .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); }
</style> </style>
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches' import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import type { InboundOrderListItem } from '../types' import type { InboundOrderListItem } from '../types'
import OrderReferenceText from './OrderReferenceText.vue'
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number } type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] } interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] }
@@ -211,7 +212,7 @@ watch([warehouseTab, () => query.orderType], () => {
<header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header> <header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header>
<div v-loading="loading" class="order-table-wrap"> <div v-loading="loading" class="order-table-wrap">
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''"> <el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ text(row.first.orderNo) }}</template></el-table-column> <el-table-column label="订单号" min-width="170"><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template></el-table-column>
<el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column> <el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column>
<el-table-column label="重量(lb)" width="100" align="right"><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column> <el-table-column label="重量(lb)" width="100" align="right"><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column>
<el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column> <el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column>
@@ -225,7 +226,7 @@ watch([warehouseTab, () => query.orderType], () => {
</section> </section>
<section class="detail-pane" aria-label="入仓单托盘和箱号详情"> <section class="detail-pane" aria-label="入仓单托盘和箱号详情">
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span>订单号:{{ text(activePallets[0]?.orderNo) }}</span></header> <header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span></header>
<div class="pallet-list"> <div class="pallet-list">
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)"> <div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
<GripVertical :size="16" class="drag-handle" /> <GripVertical :size="16" class="drag-handle" />
@@ -246,7 +247,7 @@ watch([warehouseTab, () => query.orderType], () => {
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)"> <div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
<div class="container-head"><strong>{{ container.vehicleLabel }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div> <div class="container-head"><strong>{{ container.vehicleLabel }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
<div class="container-body"> <div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong>{{ text(palletSnapshot(id)?.orderNo) }}</span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div> <div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span> <span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div> </div>
</div> </div>
@@ -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; } .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, .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; } .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; } 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 { 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; } .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-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; } .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 { 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 { 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; } .dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
.vehicle-options { display: grid; gap: 14px; } .vehicle-options { display: grid; gap: 14px; }
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { toLastMileOrderListItem } from './lastMilePresentation' import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => { describe('last-mile order presentation adapter', () => {
it('maps backend codes, associations and long ids without inventing data', () => { 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('—') 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([ it.each([
['RELABEL_APPLICATION', '换标下单'], ['RELABEL_APPLICATION', '换标下单'],
['WAREHOUSE_TRANSFER', '仓储转运'], ['WAREHOUSE_TRANSFER', '仓储转运'],
@@ -27,8 +27,8 @@ function text(value: unknown) {
return normalized || '—' return normalized || '—'
} }
function count(value: number | null | undefined) { function metric(value: number | null | undefined): number | '—' {
return Number.isFinite(value) ? Number(value) : 0 return value != null && Number.isFinite(value) ? Number(value) : '—'
} }
function yesNo(value: boolean | null | undefined) { function yesNo(value: boolean | null | undefined) {
@@ -56,6 +56,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
return { return {
id: String(dto.id), id: String(dto.id),
customerId: String(dto.customerId), customerId: String(dto.customerId),
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: text(dto.orderNo), orderNo: text(dto.orderNo),
warning: dto.abnormal ? '异常' : '—', warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)), orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
@@ -69,11 +70,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
containerStatus: text(dto.containerStatus), containerStatus: text(dto.containerStatus),
abnormal: yesNo(dto.abnormal), abnormal: yesNo(dto.abnormal),
held: yesNo(dto.detained), held: yesNo(dto.detained),
pallets: count(dto.palletCount), pallets: metric(dto.palletCount),
cartons: count(dto.packageCount), cartons: metric(dto.packageCount),
volume: count(dto.volumeCbm), volume: metric(dto.volumeCbm),
quantity: count(dto.productQuantity), quantity: metric(dto.productQuantity),
volumeWeight: count(dto.volumeWeightKg), volumeWeight: metric(dto.volumeWeightKg),
portEta: formatOverseasDateTime(dto.estimatedArrivalAt), portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
portAta: formatOverseasDateTime(dto.actualArrivalAt), portAta: formatOverseasDateTime(dto.actualArrivalAt),
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt), warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
@@ -23,6 +23,7 @@ import {
type CustomerVisitRow, type CustomerVisitRow,
} from '../api/customerVisits' } from '../api/customerVisits'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import OrderReferenceText from '../components/OrderReferenceText.vue'
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const rows = ref<CustomerVisitRow[]>([]) const rows = ref<CustomerVisitRow[]>([])
@@ -188,7 +189,7 @@ onMounted(load)
<div class="inbound-options"> <div class="inbound-options">
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }"> <label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
<el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio> <el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio>
<span>尾程单:{{ option.lastMileOrderNo }}</span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span> <span class="visit-order-reference">尾程单:<OrderReferenceText :value="option.lastMileOrderNo" /></span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
</label> </label>
</div> </div>
</el-form-item> </el-form-item>
@@ -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); } .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-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); } .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); } .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 { 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); } .file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
@@ -15,6 +15,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null) const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
@@ -73,7 +74,7 @@ onMounted(load)
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()"> <section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo">尾程 {{ row.lastMileOrderNo }}</span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template> <template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template> <template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
@@ -87,13 +88,13 @@ onMounted(load)
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item> <el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item> <el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item> </div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
<el-alert v-if="relation" :title="`已关联:${relation.lastMileOrderNo || '无尾程单'} / ${relation.inboundCode || '无入仓单'},客户 ${relation.customerName || relation.customerId}`" type="success" :closable="false" /> <el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || relation.customerId }}</span></template></el-alert>
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" /> <el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form> </el-form>
</AppDialog> </AppDialog>
</template> </template>
<style scoped> <style scoped>
.related-orders { display: grid; gap: 2px; font-size: 12px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; } .related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } } @media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
</style> </style>
@@ -14,6 +14,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto }
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation' import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
interface DetailField { label: string; value: string | number } 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 } interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
@@ -150,6 +151,7 @@ onMounted(load)
<template> <template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back"> <OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" /></template>
<OperationErrorToast :details="mutationError" /> <OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading"> <section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
@@ -0,0 +1,155 @@
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 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(),
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
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),
}
}
const bookingId = '2092873463568597001'
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', orderCount: 15,
inboundCodeCount: 15, customerCount: 3, totalPackageCount: 150, totalPalletCount: 30,
totalVolumeCbm: 45.5, eligible: true, ineligibleReason: null,
}
function selectedRow(overrides: Record<string, unknown> = {}) {
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
async function mountView() {
const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
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
}
async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows: object[]) {
wrapper.findComponent(DataTable).vm.$emit('selection-change', rows)
await wrapper.vm.$nextTick()
}
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
await button(wrapper, '按柜批量生成')!.trigger('click')
await flushPromises()
}
async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
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: 50, total: 1, pages: 1 })
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
const wrapper = await openPending()
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('keeps single-order conversion isolated with long order and customer ids', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listPendingContainerSummaries).not.toHaveBeenCalled()
})
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
expect(listPendingContainerSummaries).toHaveBeenCalledTimes(1)
expect(listPendingContainerSummaries).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
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()
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}`)
})
})
@@ -12,7 +12,8 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } 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 { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation' import { toLastMileOrderListItem } from '../lastMilePresentation'
@@ -31,9 +32,15 @@ const actionLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null) const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false) const transferOpen = ref(false)
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const containerConvertOpen = ref(false)
const containerOptions = ref<PendingContainerSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配'>('全部') const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] }) const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const fields = computed<QueryField[]>(() => [ const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' }, { key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [ { key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [
@@ -56,11 +63,13 @@ const fields = computed<QueryField[]>(() => [
{ key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, { 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: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间' }, { key: 'createdAt', type: 'daterange', label: '创建时间' },
]) ]
const fields = computed<QueryField[]>(() => allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 }, { key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 }, { key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 }, { key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 }, { key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
@@ -92,7 +101,6 @@ const columns: DataTableColumn[] = [
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 }, { key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 }, { key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
] ]
async function load(model: QueryModel = filters.value) { async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model) Object.assign(filters.value, model)
loading.value = true loading.value = true
@@ -131,7 +139,11 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(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 onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
function openTransfer() { function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return } if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
@@ -153,12 +165,73 @@ async function submitTransfer() {
} }
async function convertInboundPlan() { async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
actionLoading.value = true; actionError.value = null 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 { try {
await convertLastMileOrderToInboundPlan({ orderId: selectedRows.value[0].id }) await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load() selectedRows.value = []
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') } ElMessage.success('入库计划生成成功')
finally { actionLoading.value = false } 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, '生成整柜入库计划失败,请重试')
} finally {
actionLoading.value = false
}
} }
function openDetail(source: object) { function openDetail(source: object) {
@@ -185,9 +258,13 @@ onMounted(() => { void load() })
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template>
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template> <template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
@@ -199,9 +276,24 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item> <el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form> </el-form>
</AppDialog> </AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !selectedContainer.eligible" @confirm="convertContainerInboundPlans">
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!container.eligible" :label="`${container.containerNo || '柜号缺失'} · ${container.orderCount} 票${container.eligible ? '' : ` · ${container.ineligibleReason || '不可生成'}`}`" />
</el-select>
</el-form-item>
<el-alert v-if="selectedContainer" :title="selectedContainer.eligible ? `确认后将生成该柜全部 ${selectedContainer.orderCount} 票的独立入库计划` : selectedContainer.ineligibleReason || '所选柜不可生成'" :type="selectedContainer.eligible ? 'info' : 'error'" :closable="false" show-icon />
</el-form>
</AppDialog>
</template> </template>
<style scoped> <style scoped>
.last-mile-toolbar { padding: 14px 16px 0; } .last-mile-toolbar { padding: 14px 16px 0; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; } .last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
</style> </style>
@@ -1,17 +1,21 @@
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
import { getInboundPlan } from '../api/inboundPlans' import { getInboundPlan } from '../api/inboundPlans'
import { listInventoryLedger } from '../api/inventory' import { listInventoryLedger } from '../api/inventory'
import { getLastMileOrder } from '../api/lastMileOrders'
import InboundOrderDetailView from './InboundOrderDetailView.vue' import InboundOrderDetailView from './InboundOrderDetailView.vue'
import InventoryTransactionsView from './InventoryTransactionsView.vue' import InventoryTransactionsView from './InventoryTransactionsView.vue'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } })) const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryLedger: 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() {} } class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); 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) } } function memoryStorage(): Storage { const values = new Map<string, string>(); 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()) 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(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(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(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 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('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') })
}) })
+6 -5
View File
@@ -6,6 +6,7 @@
export interface LastMileOrderListItem { export interface LastMileOrderListItem {
id: string id: string
customerId: string customerId: string
sourceContainerBookingId: string
orderNo: string orderNo: string
warning: string warning: string
orderType: string orderType: string
@@ -19,11 +20,11 @@ export interface LastMileOrderListItem {
containerStatus: string containerStatus: string
abnormal: string abnormal: string
held: string held: string
pallets: number pallets: number | '—'
cartons: number cartons: number | '—'
volume: number volume: number | '—'
quantity: number quantity: number | '—'
volumeWeight: number volumeWeight: number | '—'
portEta: string portEta: string
portAta: string portAta: string
warehouseEta: string warehouseEta: string