Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user