274 lines
20 KiB
TypeScript
274 lines
20 KiB
TypeScript
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||
import { AxiosError } from 'axios'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { resolve } from 'node:path'
|
||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
|
||
|
||
vi.mock('../api/platformContainerLoading', () => ({
|
||
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
||
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
|
||
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
|
||
}))
|
||
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
|
||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||
|
||
const container = {
|
||
id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
|
||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 19500,
|
||
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||
}
|
||
const planResponse = {
|
||
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
|
||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], palletMembers: [], items: [],
|
||
}
|
||
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
|
||
function candidatePage(records: unknown[]) {
|
||
return {
|
||
records: records.map((record) => {
|
||
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
|
||
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
|
||
}),
|
||
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
|
||
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
|
||
}
|
||
|
||
describe('SmartLoadingWorkbenchView', () => {
|
||
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
|
||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
|
||
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
|
||
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
|
||
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
|
||
expect(source).toContain('<template #goodsSummary="{ row }"><LineClampText')
|
||
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
|
||
})
|
||
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia())
|
||
vi.clearAllMocks()
|
||
vi.mocked(getContainer).mockResolvedValue(container)
|
||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
|
||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
|
||
})
|
||
|
||
it('restores the real draft and explains candidate capacity', async () => {
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
expect(getContainer).toHaveBeenCalledWith(container.id)
|
||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '8', page: 1, size: 200 })
|
||
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||
expect(wrapper.text()).not.toContain('LP-99')
|
||
expect(wrapper.text()).toContain('IN-1')
|
||
expect(wrapper.text()).not.toContain('ORD-1')
|
||
expect(wrapper.text()).not.toContain('150% 容量上限')
|
||
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
|
||
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
|
||
})
|
||
|
||
it('shows the full inbound composition for an assigned sealed pallet', async () => {
|
||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||
...planResponse,
|
||
items: [{
|
||
id: '101', loadingPlanId: '99', accountId: '1', inboundId: '180', inboundDetailId: '201', orderId: '280',
|
||
orderNo: 'O180', inboundCode: 'I180', customerName: '客户', destinationWarehouseId: '9',
|
||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '货物',
|
||
unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-260914-0025', indivisible: true,
|
||
packageCount: 11, productQuantity: 110, grossWeightKg: 109.2, volumeCbm: 1.87, loadSequence: 1,
|
||
version: 1, pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
|
||
}],
|
||
palletMembers: [{ palletId: '801', members: [
|
||
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||
] }],
|
||
})
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
|
||
const row = wrapper.findAll('.el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
|
||
expect(row).toBeDefined()
|
||
expect(row!.text()).toContain('I180:6 箱')
|
||
expect(row!.text()).toContain('I181:5 箱')
|
||
})
|
||
|
||
it('uses loading plan capabilities to keep PDA increments editable', async () => {
|
||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
|
||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: container.warehouseId, page: 1, size: 200 })
|
||
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
|
||
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
|
||
expect(wrapper.text()).not.toContain('智能推荐')
|
||
expect(wrapper.text()).toContain('IN-1')
|
||
})
|
||
|
||
it('refreshes the latest plan after A0409', async () => {
|
||
vi.mocked(getSmartLoadingPlanByBooking)
|
||
.mockResolvedValueOnce(planResponse)
|
||
.mockResolvedValueOnce({ ...planResponse, version: 3 })
|
||
vi.mocked(recommendSmartLoading).mockRejectedValueOnce(new AxiosError(
|
||
'conflict', 'ERR_BAD_REQUEST', undefined, undefined,
|
||
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
|
||
)).mockResolvedValueOnce({ ...planResponse, version: 4 })
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
|
||
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
|
||
await flushPromises()
|
||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 2)
|
||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 3)
|
||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||
})
|
||
|
||
it('uses bordered row actions and has no manual preload push', () => {
|
||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||
expect(source).toContain('size="small" type="primary" plain')
|
||
expect(source).toContain('size="small" type="danger" plain')
|
||
expect(source).not.toContain('canPush')
|
||
expect(source).not.toContain('推送预装')
|
||
expect(source).not.toContain('关联并放舱真实订舱')
|
||
expect(source).not.toContain('订舱放舱')
|
||
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
||
expect(source).not.toContain("label: '目的仓'")
|
||
expect(source).not.toContain('plan.planNo')
|
||
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
|
||
expect(source).toContain('<span>柜次号</span>')
|
||
expect(source).toContain('<span>集装箱号</span>')
|
||
expect(source).not.toContain('当前柜号')
|
||
})
|
||
|
||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||
expect(source).toContain(':controls="false"')
|
||
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
|
||
expect(source).not.toContain('<Save')
|
||
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||
})
|
||
|
||
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
|
||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||
...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 12, availableVolumeCbm: 2.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null,
|
||
groupMembers: [
|
||
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '示例企业', availablePackageCount: 4, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', customerName: '示例企业', availablePackageCount: 2, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', customerName: '另一企业', availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||
],
|
||
}]))
|
||
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
expect(wrapper.text()).toContain('CLG-9007199254740999 · 2 票')
|
||
expect(wrapper.text()).toContain('3 个入仓片段')
|
||
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
|
||
expect(wrapper.text()).toContain('整组 12 箱')
|
||
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
|
||
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
|
||
await flushPromises()
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.props('title')).toBe('整组加入当前柜次 · CLG-9007199254740999')
|
||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||
expect(dialog.get('table[aria-label="同柜组成员"]').text()).toContain('ORD-1:IN-201、IN-203')
|
||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||
await flushPromises()
|
||
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
|
||
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it('shows an exact three-member group block and prevents individual assignment', async () => {
|
||
const members = ['401', '402', '403'].map((inboundId, index) => ({ accountId: `${index + 1}`, inboundId, orderId: `5${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, customerName: `客户${index}`, availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }))
|
||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||
...candidate, inboundId: '401', orderId: '50', inboundCode: 'IN-401', availablePackageCount: 18, coLoadGroupId: '9900', groupNo: 'CLG-9900',
|
||
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', groupMembers: members,
|
||
}]))
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
|
||
expect(button.attributes('disabled')).toBeDefined()
|
||
expect(wrapper.text()).not.toContain('本次计划装柜箱数')
|
||
})
|
||
|
||
it('keeps an ordinary ungrouped inbound on the existing partial-count assignment', async () => {
|
||
vi.mocked(assignSmartLoadingItem).mockResolvedValue(planResponse)
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||
await flushPromises()
|
||
expect(document.body.textContent).toContain('本次计划装柜箱数')
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
||
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
||
expect(count.props('max')).toBe(12)
|
||
count.vm.$emit('update:modelValue', 5)
|
||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||
await flushPromises()
|
||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 5, expectedVersion: planResponse.version })
|
||
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it('assigns a sealed pallet atomically without a package-count input', async () => {
|
||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||
...candidate, unitType: 'SEALED_PALLET', palletId: '9007199254740998', palletNo: 'DP-260908-0001',
|
||
indivisible: true, availablePackageCount: 12,
|
||
}]))
|
||
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(planResponse)
|
||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||
await flushPromises()
|
||
|
||
expect(wrapper.text()).toContain('DP-260908-0001')
|
||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||
await flushPromises()
|
||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
|
||
await flushPromises()
|
||
|
||
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(planResponse.id, { palletId: '9007199254740998', expectedVersion: planResponse.version })
|
||
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||
wrapper.unmount()
|
||
})
|
||
|
||
})
|