fix(admin): integrate workbench contracts and dropship review states

This commit is contained in:
2026-09-09 11:50:42 +08:00
79 changed files with 2288 additions and 448 deletions
@@ -9,7 +9,7 @@
| 尾程转入仓计划 | `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`;`GET /container-summaries/{sourceContainerBookingId}` | summary:`sourceContainerBookingId,containerNo?,totalOrderCount,inboundCodeCount,missingInboundCodeCount,planCount,plannedInboundCodeCount,pendingPlanCount,plannedArrivalAt?,allPlansReady,blockingReason?`;list query:`page,size,containerNo?,createdFrom?,createdTo?`;detail:`summary,orders[]`,订单与计划 ID 均为字符串 | 独立“柜子”Tab 消费服务端全柜分页,默认按计划到仓升序且 null 最后;详情抽屉显示柜内全部订单、入仓码和独立计划状态;同一摘要也供按柜生成选择框,不从订单分页本地拼柜 |
| 入库列表 | `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`;平台跨客户;每行返回计划级 `assigned/mergeAllowed/urgent` | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串;合托展示为可选提示,不作为完成门禁 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
@@ -29,8 +29,8 @@ describe('customer visit API', () => {
it('updates delivery status so selecting delivered can generate a visit', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', remark: '完成' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', remark: '完成' }, { params: { customerId: '100' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }, { params: { customerId: '100' } })
})
it('uploads and saves repeatable visit material', async () => {
@@ -107,7 +107,7 @@ export async function completeDeliveryOrder(deliveryNo: string, customerId: stri
return unwrapApiResponse(response)
}
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; remark?: string }) {
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) {
const response = await overseasHttp.put<ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
{ params: { customerId } },
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument } from './deliveryDocuments'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
describe('delivery document API', () => {
beforeEach(() => vi.clearAllMocks())
it('scopes reads and full three-category snapshots by customer and delivery number', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: {} })
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
await getDeliveryDocuments('DL/1', '9007199254740993')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } })
const body = { expectedVersion: 8, idempotencyKey: 'save-1', bolNo: null, documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) }
await saveDeliveryDocuments('DL/1', '9007199254740993', body)
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } })
})
it('uploads only the file and downloads through the authenticated category endpoint', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } })
const file = new File(['data'], 'proof.bin')
await uploadDeliveryDocument('DL/1', '100', 'RECEIPT', file)
const [url, body, config] = vi.mocked(overseasHttp.post).mock.calls[0]!
expect(url).toBe('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files')
expect(Array.from((body as FormData).keys())).toEqual(['file'])
expect((body as FormData).get('file')).toBe(file)
expect(config).toEqual({ params: { customerId: '100' } })
await downloadDeliveryDocument('DL/1', '100', 'RECEIPT', '9007199254740995')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' })
})
})
@@ -0,0 +1,33 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export const deliveryDocumentCategories = [
{ category: 'LOADING_PHOTO', label: '装车照片' },
{ category: 'BOL', label: 'BOL文件(含签名)' },
{ category: 'RECEIPT', label: '签收文件(POD签收/快递签收)' },
] as const
export type DeliveryDocumentCategory = typeof deliveryDocumentCategories[number]['category']
export interface DeliveryDocumentFile { fileId: string; fileName: string; contentType: string | null; sizeBytes: number; uploadedAt: string }
export interface DeliveryDocumentCategorySummary { category: DeliveryDocumentCategory; status: 'UPLOADED' | 'MISSING'; fileCount: number }
export interface DeliveryDocumentSummary { uploadedCategoryCount: number; totalCategoryCount: number; categories: DeliveryDocumentCategorySummary[] }
export interface DeliveryDocuments extends DeliveryDocumentSummary {
deliveryNo: string; customerId: string; version: number; bolNo: string | null
categories: Array<DeliveryDocumentCategorySummary & { files: DeliveryDocumentFile[] }>
allowedActions: string[]; replayed: boolean
}
export interface SaveDeliveryDocuments { expectedVersion: number; idempotencyKey: string; bolNo: string | null; documents: Array<{ category: DeliveryDocumentCategory; fileIds: string[] }> }
const path = (deliveryNo: string) => `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`
export async function getDeliveryDocuments(deliveryNo: string, customerId: string) {
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<DeliveryDocuments>, ApiEnvelope<DeliveryDocuments>>(path(deliveryNo), { params: { customerId } }))
}
export async function saveDeliveryDocuments(deliveryNo: string, customerId: string, body: SaveDeliveryDocuments) {
return unwrapApiResponse(await overseasHttp.put<ApiEnvelope<DeliveryDocuments>, ApiEnvelope<DeliveryDocuments>>(path(deliveryNo), body, { params: { customerId } }))
}
export async function uploadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, file: File) {
const body = new FormData()
body.append('file', file)
return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<DeliveryDocumentFile>, ApiEnvelope<DeliveryDocumentFile>>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } }))
}
export async function downloadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, fileId: string) {
return overseasHttp.get<Blob, Blob>(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
@@ -2,6 +2,7 @@ import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { ExceptionRelation } from './exceptionOrders'
import type { DeliveryDocumentSummary } from './deliveryDocuments'
export interface DeliveryOrderQuery {
page: number
@@ -20,6 +21,10 @@ export interface DeliveryOrderQuery {
}
export interface DeliveryOrderRow {
version: number
allowedActions: string[]
bolNo?: string | null
documentSummary?: DeliveryDocumentSummary
id: string
customerId: string
customerName?: string | null
@@ -37,6 +37,9 @@ export interface InboundOrderSummaryDto {
pickedPackageCount?: number | null
volumeCbm?: number | null
palletCount?: number | null
assigned?: boolean | null
mergeAllowed?: boolean | null
urgent?: boolean | null
status?: string | null
appointmentNo?: string | null
appointmentStatus?: string | null
@@ -114,11 +114,17 @@ describe('last-mile order overseas API', () => {
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([])
await expect(convertLastMileContainerToInboundPlans({
sourceContainerBookingId: '2092873463568597001',
snapshotVersion: 9,
snapshotHash: 'a'.repeat(64),
}, 'container-plan-idempotency-key')).resolves.toEqual([])
expect(overseasHttp.post).toHaveBeenCalledTimes(1)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', {
sourceContainerBookingId: '2092873463568597001',
})
snapshotVersion: 9,
snapshotHash: 'a'.repeat(64),
}, { headers: { 'Idempotency-Key': 'container-plan-idempotency-key' } })
})
it('loads server-side container progress with pagination and filters', async () => {
@@ -210,6 +210,66 @@ export interface ContainerPlanProgressDetailDto {
export interface ConvertContainerInboundPlanPayload {
sourceContainerBookingId: string
snapshotVersion: number
snapshotHash: string
}
export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED'
export interface ContainerInboundPlanPreviewOrderDto {
customerId: string
customerName: string | null
orderId: string
businessOrderNo: string
inboundCode: string | null
orderType: string
destinationType: 'FBA' | 'ONE_CLICK_DROPSHIP' | 'PRIVATE_ADDRESS'
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
packageCount: number
volumeCbm: number
suggestedPalletCount: number | null
plannedPalletCount: number | null
existingPlanNo: string | null
planStatus: string | null
orderVersion: number
readiness: ContainerInboundPlanPreviewReadiness
blockers: string[]
}
export interface ContainerInboundPlanPreviewGroupDto {
destinationType: 'FBA' | 'ONE_CLICK_DROPSHIP' | 'PRIVATE_ADDRESS'
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
orderCount: number
pendingOrderCount: number
pendingPalletCount: number
existingPlannedPalletCount: number
suggestedPalletCount: number
plannedPalletCount: number
orders: ContainerInboundPlanPreviewOrderDto[]
}
export interface ContainerInboundPlanPreviewDto {
sourceContainerBookingId: string
containerNo: string | null
overseasWarehouseId: string | null
overseasWarehouseCode: string | null
overseasWarehouseName: string | null
snapshotVersion: number
snapshotHash: string
totalOrderCount: number
readyOrderCount: number
blockedOrderCount: number
pendingOrderCount: number
pendingPalletCount: number
existingPlannedPalletCount: number
totalSuggestedPalletCount: number
totalPlannedPalletCount: number
ready: boolean
groups: ContainerInboundPlanPreviewGroupDto[]
}
export type ManualNodeCode =
@@ -394,9 +454,18 @@ export async function changeContainerReceivingWarehouse(
return unwrapApiResponse(response)
}
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
export async function getContainerInboundPlanPreview(sourceContainerBookingId: string) {
const response = await overseasHttp.get<ApiEnvelope<ContainerInboundPlanPreviewDto>, ApiEnvelope<ContainerInboundPlanPreviewDto>>(
'/platform/v1/last-leg/inbound-plans/container-preview',
{ params: { sourceContainerBookingId } },
)
return unwrapApiResponse(response)
}
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload, idempotencyKey: string) {
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,154 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import { commonPhase5Stubs } from '../pages/phase5TestStubs'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, type DeliveryDocuments } from '../api/deliveryDocuments'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn(), uploadDeliveryDocument: vi.fn(), downloadDeliveryDocument: vi.fn() }))
const dialogStub = defineComponent({
name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'],
setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, '保存资料') : null, h('button', { class: 'close', onClick: () => emit('update:modelValue', false) }, '关闭')]) : null },
})
const uploadStub = defineComponent({
name: 'ElUpload', props: ['onChange', 'disabled'],
setup(props, { slots }) { return () => h('label', [slots.default?.(), h('input', { type: 'file', disabled: props.disabled, onChange: (event: Event) => props.onChange({ raw: (event.target as HTMLInputElement).files?.[0] }) })]) },
})
const originalFile = { fileId: '9007199254740995', fileName: 'original.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-09-09T09:00:00' }
function response(): DeliveryDocuments {
return { deliveryNo: 'DL/1', customerId: '100', version: 9, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...originalFile }] : [] })) }
}
function mountDialog() {
const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: dialogStub, ElUpload: uploadStub } } })
return wrapper
}
async function choose(input: ReturnType<ReturnType<typeof mountDialog>['find']>, file: File) {
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
}
describe('delivery document drafts', () => {
beforeEach(() => {
can.mockReset().mockReturnValue(true)
vi.mocked(getDeliveryDocuments).mockReset().mockResolvedValue(response())
vi.mocked(saveDeliveryDocuments).mockReset().mockResolvedValue(response())
vi.mocked(uploadDeliveryDocument).mockReset().mockResolvedValue({ ...originalFile, fileId: '9007199254740996', fileName: 'new.pdf' })
})
it('loads once and stages replacement and deletion until a full versioned save', async () => {
const wrapper = mountDialog(); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('1/3 已上传')
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['next'], 'new.pdf'))
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('original.pdf')
expect(wrapper.text()).toContain('new.pdf')
await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(wrapper.text()).toContain('0/3 已上传')
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) })
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => {
const wrapper = mountDialog(); await flushPromises()
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf'))
expect(wrapper.text()).toContain('2/3 已上传')
await wrapper.find('.close').trigger('click')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.text()).not.toContain('new.pdf')
wrapper.unmount()
})
it('preserves the draft and idempotency key on a failed save, and prevents duplicate submissions', async () => {
const wrapper = mountDialog(); await flushPromises()
let reject!: (error: Error) => void
vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
await wrapper.find('.save').trigger('click'); await wrapper.find('.save').trigger('click')
expect(saveDeliveryDocuments).toHaveBeenCalledTimes(1)
reject(new Error('保存失败')); await flushPromises()
expect(wrapper.text()).toContain('original.pdf')
expect(wrapper.emitted('saved')).toBeUndefined()
const key = vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].idempotencyKey
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(vi.mocked(saveDeliveryDocuments).mock.calls[1]![2].idempotencyKey).toBe(key)
wrapper.unmount()
})
it('retains the existing file after replacement upload fails and rejects invalid file sizes', async () => {
const wrapper = mountDialog(); await flushPromises()
vi.mocked(uploadDeliveryDocument).mockRejectedValueOnce(new Error('上传失败'))
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'bad.pdf'))
expect(wrapper.text()).toContain('original.pdf')
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File([], 'empty.bin'))
const oversized = new File(['data'], 'large.bin'); Object.defineProperty(oversized, 'size', { value: 52428801 })
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), oversized)
expect(uploadDeliveryDocument).toHaveBeenCalledTimes(1)
const exact = new File(['data'], 'valid.bin'); Object.defineProperty(exact, 'size', { value: 52428800 })
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), exact)
expect(uploadDeliveryDocument).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it.each(['permission', 'action'])('is read-only without the required %s', async kind => {
if (kind === 'permission') can.mockReturnValue(false)
else vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), allowedActions: [] })
const wrapper = mountDialog(); await flushPromises()
expect(wrapper.find('.save').exists()).toBe(false)
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(wrapper.text()).toContain('original.pdf')
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
wrapper.unmount()
})
it('blocks retries after a version conflict without losing draft files', async () => {
vi.mocked(saveDeliveryDocuments).mockRejectedValue({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本已变化', requestId: 'req-1', traceId: 'trace-1' } } })
const wrapper = mountDialog(); await flushPromises()
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(wrapper.find('.save').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('original.pdf')
expect(wrapper.emitted('saved')).toBeUndefined()
wrapper.unmount()
})
it('limits each category to twenty files while allowing replacement', async () => {
const snapshot = response()
snapshot.categories.find(item => item.category === 'BOL')!.files = Array.from({ length: 20 }, (_, index) => ({ ...originalFile, fileId: String(index + 1) }))
snapshot.categories.find(item => item.category === 'BOL')!.fileCount = 20
vi.mocked(getDeliveryDocuments).mockResolvedValue(snapshot)
const wrapper = mountDialog(); await flushPromises()
expect(wrapper.find('[data-category="BOL"] header input[type="file"]').attributes('disabled')).toBeDefined()
await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'replace.pdf'))
await wrapper.find('.save').trigger('click'); await flushPromises()
expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toHaveLength(20)
expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toContain('9007199254740996')
wrapper.unmount()
})
it('ignores a late save response after switching to another customer delivery', async () => {
let finish!: (value: DeliveryDocuments) => void
vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mountDialog(); await flushPromises()
await wrapper.find('.save').trigger('click')
vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), deliveryNo: 'DL/2', customerId: '200', bolNo: 'BOL-2' })
await wrapper.setProps({ deliveryNo: 'DL/2', customerId: '200' }); await flushPromises()
finish(response()); await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.find('.save').exists()).toBe(true)
wrapper.unmount()
})
it('ignores a late upload failure after closing and reopening on another delivery', async () => {
let fail!: (reason: Error) => void
vi.mocked(uploadDeliveryDocument).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject }))
const wrapper = mountDialog(); await flushPromises()
await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['data'], 'pending.pdf'))
await wrapper.setProps({ modelValue: false }); await flushPromises()
await wrapper.setProps({ modelValue: true, deliveryNo: 'DL/2', customerId: '200' }); await flushPromises()
fail(new Error('old upload failed')); await flushPromises()
expect(wrapper.text()).not.toContain('old upload failed')
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
})
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const props = defineProps<{ deliveryNo: string; customerId: string }>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const snapshot = ref<DeliveryDocuments | null>(null)
const draft = ref<Array<{ category: DeliveryDocumentCategory; label: string; files: DeliveryDocumentFile[] }>>([])
const bolNo = ref('')
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const downloading = ref(false)
const uploadKey = ref(0)
const error = ref<ApiErrorDetails | null>(null)
const conflict = ref(false)
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
const busy = computed(() => saving.value || uploading.value)
const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length)
let sequence = 0
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
let attempt: { body: string; key: string } | null = null
async function load() {
const current = ++sequence
loading.value = true; saving.value = false; uploading.value = false; downloading.value = false; error.value = null; conflict.value = false; snapshot.value = null; draft.value = []; attempt = null
try {
const result = await getDeliveryDocuments(props.deliveryNo, props.customerId)
if (current !== sequence || !open.value) return
snapshot.value = result; bolNo.value = result.bolNo || ''
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '派送资料加载失败') }
finally { if (current === sequence) loading.value = false }
}
async function reload() {
if (busy.value) return
if (snapshot.value && canEdit.value) {
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { type: 'warning' }) } catch { return }
}
await load()
}
watch(() => [open.value, props.deliveryNo, props.customerId] as const, ([visible]) => {
if (visible && props.deliveryNo && props.customerId) void load()
else { sequence++; snapshot.value = null; draft.value = []; error.value = null; bolNo.value = ''; attempt = null }
}, { immediate: true })
async function upload(category: DeliveryDocumentCategory, file: UploadFile, replaceId?: string) {
if (!canEdit.value || busy.value || conflict.value || !file.raw) return
const group = draft.value.find(item => item.category === category)
if (!group || (!replaceId && group.files.length >= 20)) { ElMessage.warning('每类最多上传20个文件'); return }
if (file.raw.size === 0 || file.raw.size > 52428800) { ElMessage.warning('文件不能为空,单个文件不能超过50MiB'); return }
uploading.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props
try {
const result = await uploadDeliveryDocument(deliveryNo, customerId, category, file.raw)
if (!isCurrent(current, deliveryNo, customerId)) return
const index = replaceId ? group.files.findIndex(item => item.fileId === replaceId) : -1
if (index >= 0) group.files.splice(index, 1, result)
else group.files.push(result)
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '资料上传失败') }
finally { if (isCurrent(current, deliveryNo, customerId)) { uploading.value = false; uploadKey.value++ } }
}
function remove(category: DeliveryDocumentCategory, fileId: string) {
if (!canEdit.value || busy.value || conflict.value) return
const group = draft.value.find(item => item.category === category)
if (group) group.files = group.files.filter(file => file.fileId !== fileId)
}
function isSaved(category: DeliveryDocumentCategory, fileId: string) { return snapshot.value?.categories.find(item => item.category === category)?.files.some(file => file.fileId === fileId) }
async function download(category: DeliveryDocumentCategory, file: DeliveryDocumentFile) {
if (downloading.value || !isSaved(category, file.fileId)) return
downloading.value = true
const current = sequence; const { deliveryNo, customerId } = props
try {
const blob = await downloadDeliveryDocument(deliveryNo, customerId, category, file.fileId)
if (!isCurrent(current, deliveryNo, customerId)) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '文件下载失败') }
finally { if (isCurrent(current, deliveryNo, customerId)) downloading.value = false }
}
async function save() {
if (!open.value || !canEdit.value || !snapshot.value || busy.value || conflict.value || loading.value) return
const body = { expectedVersion: snapshot.value.version, bolNo: bolNo.value.trim() || null, documents: draft.value.map(item => ({ category: item.category, fileIds: item.files.map(file => file.fileId) })) }
const serialized = JSON.stringify(body)
if (attempt?.body !== serialized) attempt = { body: serialized, key: crypto.randomUUID() }
saving.value = true; error.value = null
const current = sequence; const { deliveryNo, customerId } = props
try {
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
if (!isCurrent(current, deliveryNo, customerId)) return
open.value = false; emit('saved'); ElMessage.success('派送资料已保存')
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
finally { if (current === sequence) saving.value = false }
}
</script>
<template>
<AppDialog v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<div class="delivery-documents">
<div class="document-identifiers"><label>派送单号<el-input :model-value="deliveryNo" readonly /></label><label>BOL号<el-input v-model="bolNo" :readonly="!canEdit" :disabled="busy || conflict" placeholder="未填写" /></label></div>
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="snapshot">
<div class="document-summary"><span>{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</span><strong>{{ uploadedCount }}/3 已上传</strong></div>
<div class="document-groups">
<section v-for="group in draft" :key="group.category" class="document-group" :data-category="group.category">
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="document-empty">尚未上传文件</p>
<div v-for="file in group.files" :key="file.fileId" class="document-file">
<div class="document-file-info"><button type="button" class="document-file-name" :title="file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">{{ file.fileName }}</button><small>{{ file.uploadedAt?.replace('T', ' ') || '—' }}{{ isSaved(group.category, file.fileId) ? '' : ' · 待保存' }}</small></div>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button :disabled="busy || conflict">替换</el-button></el-upload><el-button type="danger" link :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
</div>
</section>
</div>
</template>
</div>
</AppDialog>
</template>
<style scoped>
.delivery-documents { display:grid; gap:16px; max-height:66vh; overflow-y:auto; padding:2px 6px 4px 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:18px; }.document-identifiers label { display:grid; gap:8px; font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:12px 16px; background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:8px; }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:8px; overflow:hidden; }.document-group { padding:14px 16px; }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:10px; }.document-group header > span { color:var(--cc-muted); font-size:12px; }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:14px; padding-top:10px; }.document-file-info { display:grid; min-width:0; flex:1; gap:4px; }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:10px 0 0; }
</style>
@@ -13,6 +13,7 @@ describe('DeliveryOrderDetailView', () => {
routeState.query = { customerId: '100' }
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({
order: {
version: 3, allowedActions: ['COMPLETE_DELIVERY'],
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
createdAt: '2026-09-01T10:00:00',
@@ -1,19 +1,23 @@
import ElementPlus from 'element-plus'
import { defineComponent, h, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
import { getDeliveryDocuments, deliveryDocumentCategories } from '../api/deliveryDocuments'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn(), listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn() }))
const row = {
version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY'],
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00',
}
@@ -25,6 +29,8 @@ function mountView() {
describe('DeliveryOrdersView permissions and commands', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(getDeliveryDocuments).mockReset()
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { ...row, version: 7 }, skus: [], fees: [], operations: [] })
vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }])
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
@@ -47,9 +53,11 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERED')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'WAIT_DELIVERY', remark: undefined })
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
@@ -75,4 +83,64 @@ describe('DeliveryOrdersView permissions and commands', () => {
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
it('maps delivering separately from completion and blocks duplicate or conflicted submission', async () => {
canPermission.mockReturnValue(true)
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
expect(select.find('option[value="DELIVERED"]').text()).toBe('派送完成')
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
await select.setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
let reject!: (error: unknown) => void
vi.mocked(updateDeliveryOrderStatus).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
const button = wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm')
await button.trigger('click'); await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '123' })
reject({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突', requestId: 'req-conflict', traceId: 'trace-conflict' } } })
await flushPromises()
const state = wrapper.vm as unknown as { statusConflict: boolean; statusOpen: boolean; statusForm: { remark: string }; refreshStatus: () => Promise<void> }
expect(state.statusConflict).toBe(true)
expect(state.statusOpen).toBe(true)
expect(state.statusForm.remark).toBe('123')
await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
await state.refreshStatus(); await flushPromises()
expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['DELIVERED'])
wrapper.unmount()
})
it('hides status editing when the server supplies no actions', async () => {
canPermission.mockReturnValue(true)
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('编辑状态')
expect(getDeliveryOrder).not.toHaveBeenCalled()
wrapper.unmount()
})
it('renders category counts from the list without per-row document requests', async () => {
canPermission.mockReturnValue(true)
const documentSummary = { uploadedCategoryCount: 1, totalCategoryCount: 3, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' as const : 'MISSING' as const, fileCount: item.category === 'RECEIPT' ? 3 : 0 })) }
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, documentSummary }], page: 1, size: 10, total: 1, pages: 1 })
const table = defineComponent({ props: { data: Array as PropType<object[]> }, setup(props, { slots }) { return () => h('div', props.data?.flatMap(item => slots.documents?.({ row: item }) || [])) } })
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table, DeliveryDocumentsDialog: true } } })
await flushPromises()
expect(wrapper.text()).toContain('1/3 已上传')
expect(wrapper.text()).toContain('签收文件(POD签收/快递签收) · 已上传')
expect(getDeliveryDocuments).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text().includes('管理资料'))!.trigger('click')
const dialog = wrapper.findComponent({ name: 'DeliveryDocumentsDialog' })
expect(dialog.props()).toMatchObject({ deliveryNo: 'DEL/1', customerId: '100', modelValue: true })
const calls = vi.mocked(listDeliveryOrders).mock.calls.length
dialog.vm.$emit('saved'); await flushPromises()
expect(listDeliveryOrders).toHaveBeenCalledTimes(calls + 1)
wrapper.unmount()
})
})
File diff suppressed because one or more lines are too long
@@ -77,6 +77,9 @@ const columns: DataTableColumn[] = [
{ key: 'pickedCartons', prop: 'pickedCartons', label: '实际拣货数', width: 110, align: 'right' },
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
{ key: 'assigned', prop: 'assigned', label: '分配', width: 82, align: 'center' },
{ key: 'mergeAllowed', prop: 'mergeAllowed', label: '合托', width: 96, align: 'center' },
{ key: 'urgent', prop: 'urgent', label: '加急', width: 82, align: 'center' },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
@@ -100,6 +103,7 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通',
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
@@ -13,6 +13,7 @@ import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview,
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
@@ -30,7 +31,7 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
downloadLastMileOrders: vi.fn(),
}))
@@ -65,6 +66,26 @@ const fbaWarehouse = {
},
addressSummary: 'CA Ontario',
}
const preview = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15',
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
overseasWarehouseName: summary.overseasWarehouseName, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
totalOrderCount: 5, readyOrderCount: 5, blockedOrderCount: 0, pendingOrderCount: 5,
pendingPalletCount: 5, existingPlannedPalletCount: 0, totalSuggestedPalletCount: 5,
totalPlannedPalletCount: 0, ready: true,
groups: [{
destinationType: 'FBA', destinationWarehouseId: null, destinationWarehouseCode: 'ONT8',
destinationWarehouseName: 'Ontario FBA', orderCount: 5, pendingOrderCount: 5, pendingPalletCount: 5,
existingPlannedPalletCount: 0, suggestedPalletCount: 5, plannedPalletCount: 0,
orders: [{
customerId, customerName: '测试客户', orderId: '2092873463568596994', businessOrderNo: 'LMO-1',
inboundCode: 'IN-1', orderType: 'FIRST_LEG', destinationType: 'FBA', destinationWarehouseId: null,
destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Ontario FBA', packageCount: 10,
volumeCbm: 1, suggestedPalletCount: 5, plannedPalletCount: null, existingPlanNo: null,
planStatus: null, orderVersion: 3, readiness: 'READY', blockers: [],
}],
}],
}
const receivingWarehouse = {
...fbaWarehouse,
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
@@ -118,6 +139,8 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
const containerSelect = selects[selects.length - 1]
expect(containerSelect).toBeDefined()
containerSelect.vm.$emit('update:modelValue', bookingId)
containerSelect.vm.$emit('change', bookingId)
await flushPromises()
await wrapper.vm.$nextTick()
}
@@ -133,10 +156,14 @@ describe('LastMileOrdersView pending order and container conversion', () => {
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
})
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([
{ planNo: 'LIP-1', pallets: [{ id: 'PALLET-1', palletNo: 'PLT-001' }] },
{ planNo: 'LIP-2', pallets: [{ id: 'PALLET-2', palletNo: 'PLT-002' }] },
] as never)
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
@@ -253,7 +280,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划')
expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令')
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
@@ -261,14 +289,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
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(getContainerInboundPlanPreview).toHaveBeenCalledTimes(2)
expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(1, bookingId)
expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(2, bookingId)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
sourceContainerBookingId: bookingId,
snapshotVersion: 9,
snapshotHash: 'a'.repeat(64),
}, expect.stringContaining('container-inbound-plan-'))
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
expect(document.body.textContent).toContain('新建 2 个 / 已有 0 个')
expect(document.body.textContent).toContain('PLT-001')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
@@ -279,7 +315,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
@@ -288,6 +324,51 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).toContain(`trace-${code}`)
})
it('keeps the latest cabinet preview when an older request resolves last', async () => {
let resolveFirst!: (value: typeof preview) => void
let resolveSecond!: (value: typeof preview) => void
vi.mocked(getContainerInboundPlanPreview).mockImplementation((id) => new Promise((resolve) => {
if (id === bookingId) resolveFirst = resolve
else resolveSecond = resolve
}) as never)
const wrapper = await openPending()
await openContainerDialog(wrapper)
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const containerSelect = selects[selects.length - 1]
containerSelect.vm.$emit('update:modelValue', bookingId)
containerSelect.vm.$emit('change', bookingId)
await wrapper.vm.$nextTick()
containerSelect.vm.$emit('update:modelValue', '2092873463568597002')
containerSelect.vm.$emit('change', '2092873463568597002')
await wrapper.vm.$nextTick()
resolveSecond({ ...preview, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NEW', pendingPalletCount: 2 })
await flushPromises()
expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW')
resolveFirst(preview)
await flushPromises()
expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW')
})
it('reuses the same idempotency key when an uncertain confirmation is retried', async () => {
vi.mocked(convertLastMileContainerToInboundPlans)
.mockRejectedValueOnce(new Error('network uncertain'))
.mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never)
const wrapper = await openPending()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const findConfirm = () => [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
findConfirm().click()
await flushPromises()
findConfirm().click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0][1]).toBe(
vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1][1],
)
expect(document.body.textContent).toContain('PLT-RETRY')
})
it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
@@ -369,12 +450,16 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await flushPromises()
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('确认后将为该柜剩余 5 个入仓码分别生成独立入库计划')
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement
expect(document.body.textContent).toContain('预览已就绪,本次将新增 5 个计划、5 个托盘码')
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
sourceContainerBookingId: bookingId,
snapshotVersion: 9,
snapshotHash: 'a'.repeat(64),
}, expect.stringContaining('container-inbound-plan-'))
expect(getContainerPlanProgress).not.toHaveBeenCalled()
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -72,6 +72,25 @@ const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
const containerPreviewLoading = ref(false)
const containerPreviewError = ref('')
type ContainerConversionPlan = Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>[number]
interface ContainerConversionResult {
plans: ContainerConversionPlan[]
existingPlanNos: Set<string>
newPlanCount: number
existingPlanCount: number
}
const containerConversionResult = ref<ContainerConversionResult | null>(null)
let containerPreviewRequest = 0
let containerPreviewLoadingBookingId = ''
let containerConversionIdempotencyKey = ''
const containerConfirmDisabled = computed(() => !containerPreview.value?.ready
|| containerPreview.value.pendingOrderCount <= 0
|| containerPreviewLoading.value
|| actionLoading.value
|| Boolean(containerConversionResult.value))
const receivingWarehouseOpen = ref(false)
const receivingWarehouseLoading = ref(false)
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
@@ -543,6 +562,10 @@ function openContainerConversion() {
selectedContainerBookingId.value = ''
containerOptions.value = []
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
@@ -551,31 +574,136 @@ function openContainerConversionFor(container: ContainerPlanProgressSummaryDto)
selectedContainerBookingId.value = container.sourceContainerBookingId
containerOptions.value = [container]
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerPreview(container.sourceContainerBookingId)
}
async function convertContainerInboundPlans() {
const container = selectedContainer.value
if (!container?.sourceContainerBookingId) { ElMessage.warning('请选择一个待分配柜'); return }
if (!canGenerateContainerPlans(container)) {
actionError.value = { message: container.blockingReason || '所选柜当前不满足生成资格' }
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value, preserveResult = false) {
if (containerPreviewLoading.value && containerPreviewLoadingBookingId === sourceContainerBookingId) return
if (containerPreview.value?.sourceContainerBookingId === sourceContainerBookingId) return
const request = ++containerPreviewRequest
containerPreview.value = null
containerPreviewError.value = ''
if (!preserveResult) {
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
}
if (!sourceContainerBookingId) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
return
}
containerPreviewLoading.value = true
containerPreviewLoadingBookingId = sourceContainerBookingId
try {
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreview.value = preview
if (!preserveResult) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
} catch (error) {
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
} finally {
if (request === containerPreviewRequest) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
}
}
}
async function confirmContainerInboundPlans() {
const preview = containerPreview.value
if (!preview?.ready || preview.pendingOrderCount <= 0 || actionLoading.value) return
if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
const bookingId = preview.sourceContainerBookingId
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
.filter((order) => order.readiness === 'PLANNED' && order.existingPlanNo)
.map((order) => String(order.existingPlanNo)))
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()
const plans = await convertLastMileContainerToInboundPlans({
sourceContainerBookingId: bookingId,
snapshotVersion: preview.snapshotVersion,
snapshotHash: preview.snapshotHash,
}, containerConversionIdempotencyKey)
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
containerConversionResult.value = {
plans,
existingPlanNos,
newPlanCount: plans.length - existingPlanCount,
existingPlanCount,
}
ElMessage.success(`已生成 ${plans.length - existingPlanCount} 个入库计划`)
containerPreview.value = null
await Promise.all([load(), loadContainerPreview(bookingId, true)])
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成整柜入库计划失败,请重试')
actionError.value = getApiErrorDetails(error, '按柜生成入库计划失败,请使用原操作重试')
} finally {
actionLoading.value = false
}
}
function previewGroupTitle(group: ContainerInboundPlanPreviewGroupDto) {
if (group.destinationType === 'FBA') return `${group.destinationWarehouseName || group.destinationWarehouseCode || '未配置目的仓'} FBA仓`
return group.destinationType === 'ONE_CLICK_DROPSHIP' ? '一件代发' : '私人地址'
}
function destinationText(order: { destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null }) {
return [order.destinationWarehouseCode, order.destinationWarehouseName].filter(Boolean).join(' / ') || '—'
}
function orderTypeText(orderType: string) {
const labels: Record<string, string> = {
FIRST_LEG: '头程订单',
RELABEL_APPLICATION: '换标下单',
WAREHOUSE_TRANSFER: '仓储转运',
LOCAL_LOOSEBOARD: '本土散板',
RETURN_RELABEL: '退货换标',
ONE_CLICK_DROPSHIP: '一件代发',
}
return labels[orderType] || orderType
}
function readinessText(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' ? '可生成' : readiness === 'PLANNED' ? '已有计划' : '已阻断'
}
function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger'
}
function blockerText(blockers: string[]) {
const labels: Record<string, string> = {
MISSING_INBOUND_CODE: '缺少入仓码',
ORDER_DETAINED: '订单已暂扣',
ORDER_STATUS_NOT_READY: '订单状态未就绪',
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
DESTINATION_NOT_READY: '目的地未就绪',
CARGO_FACT_INVALID: '货物数据无效',
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
}
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
}
watch(containerConvertOpen, (open) => {
if (open) return
containerPreviewRequest += 1
containerPreviewLoadingBookingId = ''
containerPreviewLoading.value = false
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
})
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
@@ -660,19 +788,62 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜批量生成入库计划" width="640px" confirm-text="确认按柜生成" :loading="containerOptionsLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans">
<p class="container-convert-hint">将为所选柜内所有待分配尾程订单分别生成独立入库计划。</p>
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<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="!canGenerateContainerPlans(container)" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票${canGenerateContainerPlans(container) ? '' : ` · ${container.blockingReason || '不可生成'}`}`" />
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
</el-select>
</el-form-item>
<el-alert v-if="selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${pendingInboundCodeCount(selectedContainer)} 个入仓码分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show-icon />
</el-form>
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview">
<div class="container-preview__context">
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || containerPreview.sourceContainerBookingId">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
</div>
<div class="container-preview__metrics">
<div class="container-preview__metric container-preview__metric--primary"><span>本次新增托盘码</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
<div class="container-preview__metric"><span>本次新增计划</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
<div class="container-preview__metric"><span>已有计划托盘</span><strong>{{ containerPreview.existingPlannedPalletCount }}</strong></div>
<div class="container-preview__metric" :class="{ 'container-preview__metric--danger': containerPreview.blockedOrderCount > 0 }"><span>阻断订单</span><strong>{{ containerPreview.blockedOrderCount }}</strong></div>
</div>
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪,本次将新增 ${containerPreview.pendingOrderCount} 个计划、${containerPreview.pendingPalletCount} 个托盘码。`" />
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>生成完成</strong><span>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
</article>
</div>
</section>
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ group.pendingPalletCount }}托</strong>
<span>本次 {{ group.pendingOrderCount }} 票 / 已有 {{ group.existingPlannedPalletCount }} 托 / 共 {{ group.orderCount }} 票</span>
</header>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="businessOrderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column label="客户" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="业务类型" min-width="112" show-overflow-tooltip><template #default="{ row }">{{ orderTypeText(row.orderType) }}</template></el-table-column>
<el-table-column label="目的地" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ destinationText(row) }}</template></el-table-column>
<el-table-column label="建议托数" width="94" align="right"><template #default="{ row }">{{ row.suggestedPalletCount ?? '—' }}</template></el-table-column>
<el-table-column label="已有计划托数" width="118" align="right"><template #default="{ row }">{{ row.plannedPalletCount ?? '—' }}</template></el-table-column>
<el-table-column label="可生成性" width="104"><template #default="{ row }"><el-tag :type="readinessType(row.readiness)" effect="light">{{ readinessText(row.readiness) }}</el-tag></template></el-table-column>
<el-table-column label="阻断原因" min-width="210" show-overflow-tooltip><template #default="{ row }">{{ blockerText(row.blockers) }}</template></el-table-column>
</el-table>
</section>
</div>
</div>
</AppDialog>
<AppDialog v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
@@ -717,7 +888,7 @@ onMounted(() => { void load() })
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-form class="command-form" label-position="top">
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
@@ -747,6 +918,31 @@ onMounted(() => { void load() })
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.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; }
.container-convert-alert { margin-top: 12px; }
.container-preview { margin-top: 16px; }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.container-preview__identity { color: var(--cc-text); font-size: 18px; font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: 13px; }
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.container-preview__metric { display: flex; min-height: 72px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); flex-direction: column; justify-content: center; }
.container-preview__metric span { color: var(--cc-muted); font-size: 13px; }
.container-preview__metric strong { margin-top: 4px; color: var(--cc-text); font-size: 24px; line-height: 1; }
.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; }
.container-preview__metric--primary strong { color: var(--cc-primary); }
.container-preview__metric--danger strong { color: var(--el-color-danger); }
.container-preview__scroll { max-height: 48vh; margin-top: 14px; padding-right: 6px; overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: 16px; }
.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
.container-conversion-result { margin-top: 14px; padding: 14px; border: 1px solid #86efac; border-radius: 8px; background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.container-conversion-result__plan { min-width: 0; padding: 10px 12px; border: 1px solid #bbf7d0; border-radius: 6px; background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: 4px; background: #dcfce7; color: #166534; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: 12px; }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: 18px; }
@@ -15,7 +15,7 @@ describe('OutboundDetailView', () => {
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
delivery: { version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
})
})
@@ -0,0 +1,47 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/lastMileOrders.ts'), 'utf8')
const pageSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8')
describe('container inbound plan preview contract', () => {
it('uses the backend preview endpoint with the stable container booking id', () => {
expect(apiSource).toContain("'/platform/v1/last-leg/inbound-plans/container-preview'")
expect(apiSource).toContain('{ params: { sourceContainerBookingId } }')
})
it('renders backend groups, pallet totals, readiness and blockers without local grouping or sorting', () => {
expect(pageSource).toContain('containerPreview.pendingPalletCount')
expect(pageSource).toContain('containerPreview.existingPlannedPalletCount')
expect(pageSource).toContain('v-for="group in containerPreview.groups"')
expect(pageSource).toContain('readinessText(row.readiness)')
expect(pageSource).toContain('blockerText(row.blockers)')
expect(pageSource).not.toContain('containerPreview.groups.sort(')
})
it('requests one preview per selection and submits one atomic snapshot command', () => {
expect(pageSource.match(/await getContainerInboundPlanPreview\(sourceContainerBookingId\)/g)).toHaveLength(1)
expect(pageSource).toContain('containerPreviewLoadingBookingId === sourceContainerBookingId')
expect(pageSource).toContain('request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value')
expect(pageSource).toContain('containerPreviewRequest += 1')
expect(pageSource.match(/await convertLastMileContainerToInboundPlans\(\{/g)).toHaveLength(1)
expect(pageSource).toContain('snapshotVersion: preview.snapshotVersion')
expect(pageSource).toContain('snapshotHash: preview.snapshotHash')
expect(pageSource).toContain('containerConversionIdempotencyKey')
expect(pageSource).toContain(':confirm-disabled="containerConfirmDisabled"')
})
it('shows returned plan numbers and real pallet numbers then refreshes the page and preview', () => {
expect(pageSource).toContain('containerConversionResult.plans')
expect(pageSource).toContain('plan.pallets')
expect(pageSource).toContain('pallet.palletNo')
expect(pageSource).toContain('Promise.all([load(), loadContainerPreview(bookingId, true)])')
})
it('translates known blockers and never exposes unknown internal blocker codes', () => {
expect(pageSource).toContain("PALLET_COUNT_NOT_READY: '计划托数暂未就绪'")
expect(pageSource).toContain("labels[code] || '计划数据暂未就绪,请刷新或联系管理员'")
expect(pageSource).not.toContain('labels[code] || code')
})
})
@@ -206,6 +206,10 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('access.can(permission.inboundOrderAppointment)')
expect(value).toContain('access.can(permission.inboundOrderSmartDispatch)')
expect(value).toContain('<SmartDispatchDialog')
expect(value).toContain("key: 'assigned', prop: 'assigned', label: '分配'")
expect(value).toContain("key: 'mergeAllowed', prop: 'mergeAllowed', label: '合托'")
expect(value).toContain("key: 'urgent', prop: 'urgent', label: '加急'")
expect(value).toContain("dto.mergeAllowed ? '可合托' : '不合托'")
expect(value).not.toContain('ContractPendingNotice')
})
@@ -353,7 +357,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
'pallets', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
'actions', 'owner', 'warehouse', 'sku', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
@@ -19,6 +19,7 @@ export const overseasWarehousePermissions = {
exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create',
deliveryReturn: 'platform:overseas-warehouse:delivery:return',
deliveryStatus: 'platform:overseas-warehouse:delivery:status',
deliveryDocuments: 'platform:overseas-warehouse:delivery:documents',
customerVisitList: 'platform:overseas-warehouse:customer-visit:list',
customerVisitEdit: 'platform:overseas-warehouse:customer-visit:edit',
inventoryList: 'platform:overseas-warehouse:inventory:list',
@@ -18,6 +18,8 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/exception-orders', permission.exceptionOrderList],
['overseas-warehouse/customer-visits', permission.customerVisitList],
['overseas-warehouse/inventory', permission.inventoryList],
['overseas-warehouse/return-inventory', permission.returnInventoryList],
['overseas-warehouse/return-cartons', permission.returnCartonsList],
['overseas-warehouse/inventory-transactions', permission.inventoryList],
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
['overseas-warehouse/relabel-orders', permission.relabelOrderList],
+3
View File
@@ -72,6 +72,9 @@ export interface InboundOrderListItem {
pickedCartons: number
volume: number
pallets: number
assigned: string
mergeAllowed: string
urgent: string
status: string
appointmentNo: string
appointmentStatus: string