Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -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' })
|
||||
}
|
||||
@@ -28,7 +28,7 @@ describe('delivery order API', () => {
|
||||
await listDeliveryReturnInboundOptions('DEL/1', '1001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
|
||||
|
||||
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', packageCount: 2 }
|
||||
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
|
||||
await markDeliveryReturn('DEL/1', '1001', payload)
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
|
||||
|
||||
@@ -1,7 +1,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 +20,10 @@ export interface DeliveryOrderQuery {
|
||||
}
|
||||
|
||||
export interface DeliveryOrderRow {
|
||||
version: number
|
||||
allowedActions: string[]
|
||||
bolNo?: string | null
|
||||
documentSummary?: DeliveryDocumentSummary
|
||||
id: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
@@ -58,6 +62,29 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str
|
||||
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
|
||||
|
||||
export interface DeliveryReturnSource {
|
||||
outboundPalletItemId: string
|
||||
inventoryPositionId: string
|
||||
palletNo?: string | null
|
||||
skuCode?: string | null
|
||||
maxReturnPackages: number
|
||||
}
|
||||
export interface DeliveryReturnRelation {
|
||||
inboundOrderId: string
|
||||
inboundCode?: string | null
|
||||
lastMileOrderNo?: string | null
|
||||
customerId: string
|
||||
returnSources: DeliveryReturnSource[]
|
||||
}
|
||||
export interface MarkDeliveryReturnPayload {
|
||||
inboundOrderId: string
|
||||
reason: string
|
||||
returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER'
|
||||
returnLogisticsNo?: string
|
||||
items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }>
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export async function listDeliveryOrders(query: DeliveryOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>, ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>>(
|
||||
@@ -74,13 +101,13 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) {
|
||||
}
|
||||
|
||||
export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ExceptionRelation[]>, ApiEnvelope<ExceptionRelation[]>>(
|
||||
const response = await overseasHttp.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: { inboundOrderId: string; reason: string; returnDirection: string; returnLogisticsNo?: string; packageCount?: number; skuCode?: string }) {
|
||||
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<{ id: string; exceptionNo: string; status: string }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } },
|
||||
)
|
||||
|
||||
@@ -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,189 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { DOMWrapper, 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('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => {
|
||||
const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' }
|
||||
const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' }
|
||||
const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' }
|
||||
vi.mocked(uploadDeliveryDocument).mockResolvedValueOnce(receipt).mockResolvedValueOnce(courier).mockResolvedValueOnce(replacement)
|
||||
const wrapper = mount(DeliveryDocumentsDialog, { attachTo: document.body, props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus] } })
|
||||
const body = new DOMWrapper(document.body)
|
||||
try {
|
||||
await flushPromises()
|
||||
const dialog = body.find('[role="dialog"]')
|
||||
expect(dialog.findAll('[data-category]')).toHaveLength(3)
|
||||
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['pod'], 'pod.pdf'))
|
||||
await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['courier'], 'courier.pdf'))
|
||||
expect(dialog.text()).toContain('2/3 已上传')
|
||||
await choose(dialog.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['signed'], 'signed-bol.pdf'))
|
||||
await dialog.find('[data-category="RECEIPT"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
|
||||
expect(dialog.text()).not.toContain('pod.pdf')
|
||||
expect(dialog.text()).toContain('courier.pdf')
|
||||
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises()
|
||||
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] })
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
const persisted = response()
|
||||
persisted.version = 10; persisted.uploadedCategoryCount = 2
|
||||
persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' }))
|
||||
vi.mocked(getDeliveryDocuments).mockResolvedValue(persisted)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const reopened = body.find('[role="dialog"]')
|
||||
expect(reopened.text()).toContain('2/3 已上传')
|
||||
expect(reopened.text()).toContain('signed-bol.pdf')
|
||||
expect(reopened.text()).toContain('courier.pdf')
|
||||
expect(reopened.text()).not.toContain('original.pdf')
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(2)
|
||||
} finally { 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 { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
import { DOMWrapper, 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', 'MARK_RETURN'],
|
||||
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,8 +29,10 @@ 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(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }])
|
||||
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
|
||||
vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false })
|
||||
})
|
||||
@@ -47,19 +53,23 @@ 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()
|
||||
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
|
||||
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
|
||||
await wrapper.find('[data-dialog="标记退货"] input[type="checkbox"]').setValue(true)
|
||||
await wrapper.find('[data-dialog="标记退货"] input[type="number"]').setValue(2)
|
||||
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
|
||||
inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
returnLogisticsNo: undefined, packageCount: undefined, skuCode: undefined,
|
||||
returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: expect.any(String),
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -75,4 +85,162 @@ 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('keeps return hidden without the server action even when the user has permission', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':return'))
|
||||
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'RETURNED', 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(listDeliveryReturnInboundOptions).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('validates selected return sources and reuses the idempotency key after a failed request', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':return'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.find('[data-dialog="标记退货"]')
|
||||
expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)')
|
||||
expect(dialog.text()).not.toContain('9007199254740995')
|
||||
expect(dialog.text()).not.toContain('可自由填写')
|
||||
await dialog.find('textarea').setValue('拒收')
|
||||
await dialog.find('.dialog-confirm').trigger('click')
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
await dialog.find('input[type="checkbox"]').setValue(true)
|
||||
for (const invalid of [0, 4, 1.5]) {
|
||||
await dialog.find('input[type="number"]').setValue(invalid)
|
||||
await dialog.find('.dialog-confirm').trigger('click')
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
}
|
||||
await dialog.find('input[type="number"]').setValue(2)
|
||||
let reject!: (error: Error) => void
|
||||
vi.mocked(markDeliveryReturn).mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
|
||||
await dialog.find('.dialog-confirm').trigger('click'); await dialog.find('.dialog-confirm').trigger('click')
|
||||
expect(markDeliveryReturn).toHaveBeenCalledTimes(1)
|
||||
const first = vi.mocked(markDeliveryReturn).mock.calls[0]![2]
|
||||
reject(new Error('网络异常')); await flushPromises()
|
||||
expect(dialog.text()).toContain('标记退货失败')
|
||||
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('拒收')
|
||||
await dialog.find('.dialog-confirm').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(markDeliveryReturn).mock.calls[1]![2]).toEqual(first)
|
||||
expect(first.items).toEqual([{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }])
|
||||
expect(first).not.toHaveProperty('skuCode')
|
||||
expect(first).not.toHaveProperty('packageCount')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('clears the selected source when a different inbound order is chosen', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':return'))
|
||||
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([
|
||||
{ inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] },
|
||||
{ inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] },
|
||||
])
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
try {
|
||||
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.find('[data-dialog="标记退货"]')
|
||||
await dialog.find('select').setValue('9')
|
||||
await dialog.find('input[type="checkbox"]').setValue(true)
|
||||
await dialog.find('input[type="number"]').setValue(2)
|
||||
await dialog.find('textarea').setValue('拒收')
|
||||
await dialog.find('select').setValue('10')
|
||||
expect(dialog.find('input[type="checkbox"]').exists()).toBe(false)
|
||||
await dialog.find('.dialog-confirm').trigger('click')
|
||||
expect(markDeliveryReturn).not.toHaveBeenCalled()
|
||||
} finally { 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('selects completion through the real dialog and select, then retries a conflict with the refreshed version', async () => {
|
||||
canPermission.mockImplementation(code => code.endsWith(':status'))
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
|
||||
vi.mocked(updateDeliveryOrderStatus).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突' } } })
|
||||
const wrapper = mount(DeliveryOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: false, ElInput: false, ElSelect: false, ElOption: false } } })
|
||||
const body = new DOMWrapper(document.body)
|
||||
try {
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = body.find('[role="dialog"][aria-label="编辑派送状态"]')
|
||||
const save = () => dialog.findAll('button').find(button => button.text() === '保存状态')!
|
||||
expect(save().attributes('disabled')).toBeDefined()
|
||||
await dialog.find('.el-select__wrapper').trigger('click')
|
||||
await flushPromises()
|
||||
const options = body.findAll('.el-select-dropdown__item')
|
||||
expect(options.map(option => option.text())).toEqual(['派送完成'])
|
||||
await options[0]!.trigger('click')
|
||||
await dialog.find('textarea').setValue('已交付')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '已交付' })
|
||||
expect(save().attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('请刷新最新状态并重新确认')
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
|
||||
await dialog.findAll('button').find(button => button.text().includes('重新加载'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('已交付')
|
||||
await save().trigger('click'); await flushPromises()
|
||||
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 8, remark: '已交付' })
|
||||
expect(listDeliveryOrders).toHaveBeenCalledTimes(2)
|
||||
} finally { 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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user