Files
admin-web/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
T

343 lines
18 KiB
TypeScript
Raw Normal View History

2026-08-14 19:59:32 +08:00
import ElementPlus, { ElInputNumber, ElMessage, ElMessageBox } from 'element-plus'
2026-08-10 17:42:17 +08:00
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
2026-08-12 18:00:26 +08:00
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
2026-08-11 11:52:30 +08:00
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
2026-08-10 17:42:17 +08:00
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import {
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
recommendSmartLoading,
2026-08-11 11:52:30 +08:00
removeSmartLoadingItem,
2026-08-10 17:42:17 +08:00
} from '../api/platformContainerLoading'
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush }),
}))
2026-08-10 17:42:17 +08:00
vi.mock('@/core/access/accessPreview', () => ({
useAccessPreviewStore: () => ({ can: () => true }),
}))
2026-08-14 12:50:50 +08:00
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }),
}))
2026-08-10 17:42:17 +08:00
vi.mock('../api/platformContainerLoading', () => ({
listContainers: vi.fn(),
getSmartLoadingPlanByBooking: vi.fn(),
listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(),
removeSmartLoadingItem: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
2026-08-14 19:02:34 +08:00
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
2026-08-10 17:42:17 +08:00
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: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const plan = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1 }],
}
describe('GlobalSmartLoadingView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
2026-08-14 19:02:34 +08:00
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
2026-08-10 17:42:17 +08:00
})
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
2026-08-14 19:02:34 +08:00
expect(wrapper.text()).toContain('候选入库单与货物')
expect(wrapper.text()).toContain('美森正班海卡包税')
2026-08-14 19:59:32 +08:00
expect(wrapper.text()).toContain('IN-2')
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('ORD-2')
})
it('shows one user-facing container number and hides internal manual or split references', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
page: 1,
size: 100,
total: 1,
pages: 1,
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CAB-20260814-01')
expect(wrapper.text()).not.toContain('SPLIT-814022117898chain1')
expect(wrapper.text()).not.toContain('LP-99')
2026-08-10 17:42:17 +08:00
})
2026-08-14 19:02:34 +08:00
it('groups containers by channel and reloads candidates when the channel changes', async () => {
const other = {
...container,
id: '9007199254740998',
planId: '9007199254740999',
cabinetNo: 'CB-2',
2026-08-14 19:59:32 +08:00
containerNo: 'MSCU-2',
2026-08-14 19:02:34 +08:00
channelCode: 'HDHK',
channelName: '合德海卡包税',
}
vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId }))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
2026-08-14 19:59:32 +08:00
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).not.toContain('MSCU-2')
expect(wrapper.text()).not.toContain('个柜次')
2026-08-14 19:02:34 +08:00
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
expect(tab).toBeDefined()
await tab!.trigger('click')
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
2026-08-14 19:59:32 +08:00
expect(wrapper.text()).toContain('MSCU-2')
expect(wrapper.text()).not.toContain('MSCU-1')
2026-08-14 19:02:34 +08:00
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
})
2026-08-14 13:03:11 +08:00
it('filters candidates by an active name-only customer selector and accountId', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
2026-08-14 14:27:03 +08:00
expect(source).toContain("label: customer.tenantName")
2026-08-14 13:03:11 +08:00
expect(source).toContain('item.accountId !== customerId')
expect(source).not.toContain('customer.tenantCode')
})
2026-08-11 20:02:38 +08:00
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{ ...container, capacityCbm: 100, maxWeightKg: 100000 }], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
items: [{ ...plan.items[0], volumeCbm: 0.187, grossWeightKg: 13.6 }],
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('0.17%')
expect(wrapper.text()).toContain('0.01%')
expect(wrapper.text()).not.toContain('0.169999999')
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
})
it('keeps only customer and inbound code filters', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
2026-08-10 17:42:17 +08:00
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
2026-08-14 19:59:32 +08:00
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode'])
expect(source).not.toContain('目的仓')
2026-08-11 11:52:30 +08:00
2026-08-10 17:42:17 +08:00
expect(wrapper.text()).not.toContain('税率')
expect(wrapper.text()).not.toContain('是否退税')
})
it('prefers an editable container on first load so pushed plans do not show a false editing error', async () => {
const pushed = { ...container, id: '9007199254740995', planStatus: 'LOADING' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [pushed, container], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
2026-08-14 19:59:32 +08:00
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
})
2026-08-14 19:59:32 +08:00
it('keeps waiting and editing empty containers without exposing their status labels', async () => {
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', containerNo: null, planStatus: 'WAIT_LOADING' as const }
2026-08-11 14:23:46 +08:00
vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => (
bookingId === waiting.id
? { ...plan, bookingId: waiting.id, status: 'LOADING' as const }
: plan
))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const waitingOption = wrapper.findAll('.container-option').find((item) => item.text().includes('CB-WAIT'))!
2026-08-14 19:59:32 +08:00
expect(waitingOption.text()).toContain('CB-WAIT')
expect(waitingOption.text()).not.toContain('待装柜')
2026-08-11 14:23:46 +08:00
await waitingOption.trigger('click')
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id)
2026-08-14 19:59:32 +08:00
expect(waitingOption.text()).toContain('CB-WAIT')
2026-08-11 14:23:46 +08:00
expect(waitingOption.text()).not.toContain('拣货中')
})
2026-08-14 19:59:32 +08:00
it('excludes picking containers from the empty-container workbench', async () => {
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
2026-08-10 20:53:48 +08:00
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
2026-08-14 19:59:32 +08:00
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
expect(wrapper.findAll('.container-option')).toHaveLength(0)
expect(wrapper.text()).not.toContain('CB-2')
})
2026-08-11 14:36:08 +08:00
it('hides completed containers from the smart loading workbench', async () => {
const completed = { ...container, id: '9007199254740997', planId: '9007199254740998', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [completed], page: 1, size: 100, total: 1, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
2026-08-11 14:36:08 +08:00
expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).not.toContain('装柜完成')
2026-08-10 20:53:48 +08:00
})
2026-08-10 17:42:17 +08:00
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
2026-08-14 19:59:32 +08:00
const warningMessage = vi.spyOn(ElMessage, 'warning').mockImplementation(() => undefined as never)
2026-08-10 17:42:17 +08:00
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
vi.mocked(recommendSmartLoading).mockRejectedValue(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 },
))
const button = wrapper.findAll('button').find((item) => item.text().includes('智能推荐'))
expect(button).toBeDefined()
await button!.trigger('click')
await flushPromises()
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
2026-08-14 19:59:32 +08:00
expect(warningMessage).toHaveBeenCalledWith('方案已被其他人修改,已刷新最新版本,请重新确认后提交')
expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('request-1')
2026-08-10 17:42:17 +08:00
})
2026-08-11 11:52:30 +08:00
it('shows a picking-specific message when a picked order cannot be removed', async () => {
vi.mocked(removeSmartLoadingItem).mockRejectedValue(new AxiosError(
'conflict',
'ERR_BAD_REQUEST',
undefined,
undefined,
{ data: { code: 'A0409', message: '已有拣货记录的明细不能删除' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
))
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const errorMessage = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const removeButton = wrapper.find('.row-actions .el-button--danger')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
await flushPromises()
expect(removeSmartLoadingItem).toHaveBeenCalled()
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
2026-08-14 19:59:32 +08:00
expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('该订单在拣货中不许删除')
})
it('removes every item with the latest returned plan version', async () => {
const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' }
const initialPlan = { ...plan, items: [plan.items[0], secondItem] }
const afterFirst = { ...plan, version: 3, items: [secondItem] }
const emptyPlan = { ...plan, version: 4, items: [] }
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).mockResolvedValue(emptyPlan)
vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('全部移除'))
expect(removeAllButton).toBeDefined()
await removeAllButton!.trigger('click')
await flushPromises()
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1)
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
})
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 4, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const maxima = wrapper.findAllComponents(ElInputNumber).map((input) => input.props('max'))
expect(maxima).toContain(10)
2026-08-11 11:52:30 +08:00
})
2026-08-12 18:00:26 +08:00
it('uses bordered assignment and adjustment actions at narrow widths', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('size="small"')
expect(source).toContain('type="danger" plain')
2026-08-14 19:59:32 +08:00
expect(source).toContain(':max="adjustmentMax(row)"')
2026-08-12 18:00:26 +08:00
expect(source).not.toContain('link type="danger"')
expect(source).toContain('@media (max-width: 720px)')
2026-08-12 18:16:44 +08:00
expect(source.match(/<DataTable[\s\S]*?\bstripe\b/g)).toHaveLength(2)
2026-08-12 18:00:26 +08:00
})
2026-08-14 19:59:32 +08:00
it('bounds all three workbench panes and keeps their content independently scrollable', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('height: clamp(520px, 68vh, 720px); min-height: 0;')
expect(source).toContain('.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }')
expect(source).toContain('.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }')
expect(source).toContain('.container-list { display: grid; min-height: 0; flex: 1;')
expect(source).toContain('grid-auto-rows: max-content;')
expect(source).toContain('overflow-wrap: anywhere;')
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
})
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<el-tabs :model-value="activeChannelCode"')
expect(source).toContain('<el-tab-pane v-for="channel in channelTabs"')
expect(source).toContain(':label="channel.name"')
expect(source).not.toContain('channel.containerCount')
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
expect(source).toContain('<dt>候选货物</dt>')
expect(source).toContain('<dt>空柜</dt>')
expect(source).toContain('<dt>已装</dt>')
expect(source).toContain('<dt>总重量</dt>')
expect(source).toContain('<dt>总体积</dt>')
expect(source).toContain('.channel-tabs :deep(.el-tabs__content) { display: none; }')
expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
})
2026-08-10 17:42:17 +08:00
})