diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 05f266a1..a494009e 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, - listContainers, listSmartLoadingCandidates, removeSmartLoadingGroup, removeSmartLoadingItem, + listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, @@ -66,10 +66,13 @@ describe('platform container loading API', () => { it('normalizes every long id in candidates and plans', async () => { vi.mocked(http.get) .mockResolvedValueOnce({ code: '200', data: container }) - .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] }) + .mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, customerId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } }) expect((await getContainer('9')).id).toBe(String(container.id)) - expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }]) - expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } }) + expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 }) + const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams + expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE']) + expect(params.get('warehouseId')).toBe('5') + expect(params.has('bookingId')).toBe(false) }) it('sends plan and item versions plus operation identifiers', async () => { @@ -85,6 +88,25 @@ describe('platform container loading API', () => { expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) }) + it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { + records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HC', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }], + page: '1', size: '200', total: '1', pages: '1', + } }) + + const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', expectedArrivalStart: '2026-09-01', expectedArrivalEnd: '2026-09-03', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 }) + + expect(result).toMatchObject({ records: [{ bookingId: '9007199254740993', warehouseId: '8', planId: '9007199254740994', version: 2 }], total: 1 }) + const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams + expect(params.getAll('channelGroupCodes')).toEqual(['MATSON_CLX', 'EXX']) + expect(params.get('customerId')).toBe('12') + expect(params.get('inboundCode')).toBe('IN-1') + expect(params.get('expectedArrivalStart')).toBe('2026-09-01') + expect(params.get('expectedArrivalEnd')).toBe('2026-09-03') + expect(params.get('orderStatus')).toBe('WAIT_SPLIT') + expect(params.has('bookingId')).toBe(false) + }) + it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 322775a0..af9e69cc 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -16,6 +16,9 @@ import type { ContainerSimulationCreateRequest, ContainerOceanBookingLinkRequest, SmartLoadingCandidate, + SmartLoadingCandidatePage, + SmartLoadingCandidateQuery, + SmartLoadingWorkbenchContainerPage, SmartLoadingItem, SmartLoadingPlan, BusinessDocument, @@ -225,21 +228,58 @@ export async function completeContainerClearance(containerId: string, expectedVe return document(unwrapApiResponse(response)) } -export async function listSmartLoadingCandidates(bookingId: string, warehouseId: string) { - const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/inbound-candidates`, { params: { bookingId, warehouseId } }) - return unwrapApiResponse(response).map((raw) => ({ - ...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), - orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), - coLoadGroupId: optionalId(raw.coLoadGroupId), - memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), - groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), - locked: raw.locked == null ? null : Boolean(raw.locked), - availablePackageCount: numberValue(raw.availablePackageCount), - unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), - unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), - averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm), - availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm), - })) +function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) { + const params = new URLSearchParams() + for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode) + const optionalParams: Array<[string, string | number | undefined]> = [ + ['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode], + ['expectedArrivalStart', query.expectedArrivalStart], ['expectedArrivalEnd', query.expectedArrivalEnd], + ['orderStatus', query.orderStatus], ['page', query.page], ['size', query.size], + ] + for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value)) + return params +} + +export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) { + const params = smartLoadingQueryParams(query) + const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/inbound-candidates`, { params }) + const result = unwrapApiResponse(response) + return { + ...result, + page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), + total: numberValue(result.total), pages: numberValue(result.pages), + records: result.records.map((raw) => ({ + ...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: id(raw.inboundId), + orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), + coLoadGroupId: optionalId(raw.coLoadGroupId), + memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), + groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), + locked: raw.locked == null ? null : Boolean(raw.locked), + availablePackageCount: numberValue(raw.availablePackageCount), + unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), + unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), + averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm), + availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm), + groupMembers: raw.groupMembers.map((member) => ({ + ...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), + availablePackageCount: numberValue(member.availablePackageCount), + })), + })), + } +} + +export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCandidateQuery) { + const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/containers`, { params: smartLoadingQueryParams(query) }) + const result = unwrapApiResponse(response) + return { + ...result, + page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), + total: numberValue(result.total), pages: numberValue(result.pages), + records: result.records.map((raw) => ({ + ...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId), + version: numberValue(raw.version), + })), + } } export async function getSmartLoadingPlanByBooking(bookingId: string) { diff --git a/src/modules/container-loading/components/SmartLoadingDialog.test.ts b/src/modules/container-loading/components/SmartLoadingDialog.test.ts index 5283eedd..64f4c3e0 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.test.ts +++ b/src/modules/container-loading/components/SmartLoadingDialog.test.ts @@ -1,6 +1,8 @@ import ElementPlus from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import SmartLoadingDialog from './SmartLoadingDialog.vue' describe('SmartLoadingDialog', () => { @@ -18,6 +20,7 @@ describe('SmartLoadingDialog', () => { await flushPromises() expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false) + expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true) const expand = wrapper.get('button[aria-label="展开全屏"]') await expand.trigger('click') expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() @@ -27,4 +30,11 @@ describe('SmartLoadingDialog', () => { expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull() expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true) }) + + it('keeps the outer dialog fixed so only workbench data panes scroll', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8') + expect(source).toContain('height: 94vh; max-height: 94vh;') + expect(source).toContain('flex: 1; padding: 0; overflow: hidden;') + expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;') + }) }) diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue index 62657aca..045685cc 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.vue +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -43,16 +43,15 @@ watch(open, (value) => { diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 6db8959d..b2b68ad9 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -7,12 +7,15 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue' +import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' +import type { ContainerBookingResource } from '../types' import { assignSmartLoadingGroup, assignSmartLoadingItem, getSmartLoadingPlanByBooking, listContainers, listSmartLoadingCandidates, + listSmartLoadingWorkbenchContainers, recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem, @@ -34,6 +37,7 @@ vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(), + listSmartLoadingWorkbenchContainers: vi.fn(), createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), @@ -69,14 +73,41 @@ const candidate = { availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null, } +function candidatePage(records: unknown[], page = 1, size = 20, total = records.length) { + return { + records: records.map((record) => { + const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] } + return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate } + }), + page, size, total, pages: total ? Math.ceil(total / size) : 0, + } as Awaited> +} +function containerPage(records: ContainerBookingResource[] = [container]) { + return { + records: records.map((item) => ({ + bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo, + warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '', + containerType: item.containerType, planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version, + })), + page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0, + } as Awaited> +} +function mockContainerSearch(records: ContainerBookingResource[]) { + vi.mocked(listSmartLoadingWorkbenchContainers).mockImplementation(async (query) => containerPage(records.filter((item) => ( + item.planStatus !== 'COMPLETED' + && (!query.warehouseId || item.warehouseId === query.warehouseId) + && query.channelGroupCodes.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '') + )))) +} describe('GlobalSmartLoadingView', () => { beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) + mockContainerSearch([container]) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate])) }) it('loads real containers, candidates and the selected plan into the three-column workbench', async () => { @@ -85,7 +116,7 @@ describe('GlobalSmartLoadingView', () => { expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 }) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) - expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) expect(wrapper.text()).toContain('候选装柜明细') expect(wrapper.text()).toContain('美森正班') expect(wrapper.findAll('.channel-tags .el-checkbox-button')).toHaveLength(9) @@ -101,6 +132,8 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('IN-2') expect(wrapper.text()).not.toContain('ORD-1') expect(wrapper.text()).not.toContain('ORD-2') + expect(wrapper.get('.candidate-pane').text()).toContain('120 kg') + expect(wrapper.get('.candidate-pane').text()).toContain('2.4 m³') }) it('uses the top-level container channel snapshot without expecting a nested snapshot', async () => { @@ -111,7 +144,8 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', 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 }]) + mockContainerSearch([{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', 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 }])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -129,6 +163,7 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) + mockContainerSearch([{ ...container, cabinetNo: 'CAB-260814-0001', containerNo: 'SPLIT-814022117898chain1' }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -152,6 +187,7 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) + mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HC' }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -178,6 +214,7 @@ describe('GlobalSmartLoadingView', () => { channelName: '合德海卡包税', } vi.mocked(listContainers).mockResolvedValue({ records: [container, other], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, other]) vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId })) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -211,6 +248,7 @@ describe('GlobalSmartLoadingView', () => { channelName: '美森正班海派', } vi.mocked(listContainers).mockResolvedValue({ records: [container, parcelContainer], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, parcelContainer]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -232,6 +270,7 @@ describe('GlobalSmartLoadingView', () => { warehouseName: '东莞仓', } vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanContainer], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, dongguanContainer]) vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ({ ...plan, bookingId })) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -244,7 +283,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('MSCU-DG') expect(wrapper.text()).not.toContain('MSCU-1') - expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(dongguanContainer.id, dongguanContainer.warehouseId) + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: dongguanContainer.warehouseId, page: 1, size: 20 }) }) it('keeps the selected channel when the warehouse changes even if that warehouse has no matching container', async () => { @@ -260,6 +299,7 @@ describe('GlobalSmartLoadingView', () => { channelName: '美森加班海卡', } vi.mocked(listContainers).mockResolvedValue({ records: [container, dongguanOvertimeContainer], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, dongguanOvertimeContainer]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -269,14 +309,15 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班') expect(wrapper.findAll('.container-option')).toHaveLength(0) expect(wrapper.text()).not.toContain('MSCU-DG-MAX') - expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(1) + expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(2) + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '18', page: 1, size: 20 }) }) - it('filters candidates by an active name-only customer selector and accountId', () => { + it('sends the active name-only customer selector to the server candidate query', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })") expect(source).toContain("label: customer.tenantName") - expect(source).toContain('member.accountId === customerId') + expect(source).toContain('params.customerId = customerId') expect(source).not.toContain('customer.tenantCode') }) @@ -286,11 +327,13 @@ describe('GlobalSmartLoadingView', () => { }) it('filters candidates by expected arrival range and order status', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ + vi.mocked(listSmartLoadingCandidates) + .mockResolvedValueOnce(candidatePage([ { ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }, { ...candidate, inboundId: '52', inboundCode: 'IN-WRONG-STATUS', orderStatusSnapshot: 'LOADING', expectedArrivalAt: '2026-09-11T08:00:00' }, { ...candidate, inboundId: '53', inboundCode: 'IN-WRONG-DATE', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-20T08:00:00' }, - ]) + ])) + .mockResolvedValueOnce(candidatePage([{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -303,22 +346,31 @@ describe('GlobalSmartLoadingView', () => { expect(candidates).toContain('IN-MATCH') expect(candidates).not.toContain('IN-WRONG-STATUS') expect(candidates).not.toContain('IN-WRONG-DATE') + expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 20 }) + expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], expectedArrivalStart: '2026-09-10', expectedArrivalEnd: '2026-09-12', orderStatus: 'WAIT_SPLIT', page: 1, size: 200 }) }) it('paginates large inbound candidate results with twenty rows by default', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue(Array.from({ length: 25 }, (_, index) => ({ - ...candidate, - inboundId: String(100 + index), - orderId: String(200 + index), - inboundCode: `IN-${String(index + 1).padStart(3, '0')}`, - }))) + vi.mocked(listSmartLoadingCandidates) + .mockResolvedValueOnce(candidatePage(Array.from({ length: 20 }, (_, index) => ({ + ...candidate, + inboundId: String(100 + index), + orderId: String(200 + index), + inboundCode: `IN-${String(index + 1).padStart(3, '0')}`, + })), 1, 20, 25)) + .mockResolvedValueOnce(candidatePage(Array.from({ length: 5 }, (_, index) => ({ + ...candidate, + inboundId: String(120 + index), + orderId: String(220 + index), + inboundCode: `IN-${String(index + 21).padStart(3, '0')}`, + })), 2, 20, 25)) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() const pane = wrapper.get('.candidate-pane') expect(pane.text()).toContain('IN-020') expect(pane.text()).not.toContain('IN-021') - expect(pane.text()).toContain('共 25 条 · 第 1 / 2 页') + expect(pane.text()).toContain('共 25 条 · 1/2 页') await pane.findAll('button').find((button) => button.attributes('aria-label') === '下一页')!.trigger('click') await flushPromises() @@ -327,11 +379,14 @@ describe('GlobalSmartLoadingView', () => { }) it('aggregates a two-order candidate group and calls only whole-group assignment', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ - { accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null }, - { accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '10', destinationWarehouseCode: 'JFK8', destinationWarehouseName: 'JFK8', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 2, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.4, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null }, - { accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null }, - ]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ + accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', 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: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null, + groupMembers: [ + { accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', customerName: '客户一', availablePackageCount: 4, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }, + { accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', customerName: '客户一', availablePackageCount: 2, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }, + { accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', customerName: '客户二', availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }, + ], + }])) vi.mocked(assignSmartLoadingGroup).mockResolvedValue(plan) const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) await flushPromises() @@ -354,9 +409,10 @@ describe('GlobalSmartLoadingView', () => { }) it('blocks an incomplete three-member candidate group without exposing member assignment', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({ - accountId: `${index + 1}`, inboundId, orderId: `50${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: `客户${index}`, goodsSummary: `货物${index}`, availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', - }))) + const members = ['401', '402', '403'].map((inboundId, index) => ({ accountId: `${index + 1}`, inboundId, orderId: `50${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: '500', inboundCode: 'IN-401', coLoadGroupId: '9900', groupNo: 'CLG-9900', memberCount: 3, groupTotalBoxes: 18, availablePackageCount: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', groupMembers: members, + }])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -408,23 +464,21 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('是否退税') }) - it('filters completed loading candidates out even when all statuses are selected', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ - candidate, - { ...candidate, inboundId: '6', orderId: '8', inboundCode: 'IN-LOADED', orderStatusSnapshot: 'LOADED' }, - { ...candidate, inboundId: '7', orderId: '9', inboundCode: 'IN-COMPLETED', orderStatusSnapshot: 'COMPLETED' }, - ]) + it('relies on the server contract to exclude completed candidates', async () => { + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.get('.candidate-pane').text()).toContain('IN-2') expect(wrapper.get('.candidate-pane').text()).not.toContain('IN-LOADED') expect(wrapper.get('.candidate-pane').text()).not.toContain('IN-COMPLETED') + expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) }) it('prefers an editing container on first load while keeping loading containers available', 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 }) + mockContainerSearch([pushed, container]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -435,6 +489,7 @@ describe('GlobalSmartLoadingView', () => { 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 } vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([container, waiting]) vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => ( bookingId === waiting.id ? { ...plan, bookingId: waiting.id, status: 'LOADING' as const } @@ -447,10 +502,12 @@ describe('GlobalSmartLoadingView', () => { expect(waitingOption.text()).toContain('CB-WAIT') expect(waitingOption.text()).not.toContain('待装柜') + const candidateCalls = vi.mocked(listSmartLoadingCandidates).mock.calls.length await waitingOption.trigger('click') await flushPromises() expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id) + expect(listSmartLoadingCandidates).toHaveBeenCalledTimes(candidateCalls) expect(waitingOption.text()).toContain('CB-WAIT') expect(waitingOption.text()).not.toContain('拣货中') }) @@ -458,12 +515,13 @@ describe('GlobalSmartLoadingView', () => { it('keeps picking containers available when the plan capability allows increments', 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 }) + mockContainerSearch([pushed]) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: pushed.id, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 }) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id) - expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId) + expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) expect(wrapper.findAll('.container-option')).toHaveLength(1) expect(wrapper.text()).toContain('CB-2') expect(wrapper.text()).not.toContain('已下发 PDA,仍可维护未完成的装柜明细') @@ -474,6 +532,7 @@ describe('GlobalSmartLoadingView', () => { 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 }) + mockContainerSearch([completed]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -486,6 +545,7 @@ describe('GlobalSmartLoadingView', () => { it('isolates a missing-channel row without hiding another valid container', async () => { const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null } vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 }) + mockContainerSearch([missingChannel, container]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -496,17 +556,17 @@ describe('GlobalSmartLoadingView', () => { expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) }) - it('matches candidate goods by their own formal top-level channel code', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ + it('renders the channel-filtered candidate page returned by the server', async () => { + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([ { accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', 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 }, - { accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }, - ]) + ])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('匹配企业') expect(wrapper.text()).not.toContain('IN-OTHER') expect(wrapper.text()).not.toContain('其他货物') + expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) }) it('refreshes the latest plan after A0409 without losing the selected container', async () => { @@ -647,7 +707,7 @@ describe('GlobalSmartLoadingView', () => { }) it('limits adjusted package count to the assigned count plus remaining availability', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ 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: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ 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: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -671,12 +731,14 @@ describe('GlobalSmartLoadingView', () => { 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(".candidate-pane :deep(.data-table__page-button[aria-label^='第']),.candidate-pane :deep(.data-table__page-ellipsis) { display: none; }") + expect(source).toContain('.candidate-pane :deep(.data-table__page-size .el-select) { width: 68px; }') 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); }') - expect(source).toContain('grid-template-columns: minmax(390px, 1.1fr) minmax(320px, .9fr) minmax(460px, 1.35fr)') + expect(source).toContain('grid-template-columns: minmax(430px, 1fr) minmax(260px, 280px) minmax(520px, 1.25fr)') expect(source).toContain('min-height: 112px') expect(source).toContain("label: '预计到货时间'") expect(source).toContain("label: '订单状态'") diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index bf1de4a7..758ae53d 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -1,6 +1,5 @@