diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index a494009e..3582800e 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, + adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, @@ -68,7 +68,10 @@ describe('platform container loading API', () => { .mockResolvedValueOnce({ code: '200', data: container }) .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({ 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 }) + expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], 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 candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams + expect(candidateParams.getAll('orderStatuses')).toEqual(['PENDING_REVIEW', 'WAIT_MEASURE']) + expect(candidateParams.has('orderStatus')).toBe(false) 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') @@ -88,22 +91,41 @@ 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('uses the dedicated provisional assignment command for a pending-review order', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) + + await assignPendingReviewSmartLoadingOrder('10', { + customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361', + packageCount: 100, expectedVersion: 5, + }) + + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/pending-review-assignments', + expect.objectContaining({ + customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361', + packageCount: 100, expectedVersion: 5, + operationId: expect.stringContaining('assign-pending-review-'), + }), + ) + }) + 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 }) + const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', orderStatuses: ['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.get('createdStart')).toBe('2026-09-01') + expect(params.get('createdEnd')).toBe('2026-09-03') + expect(params.has('orderStatus')).toBe(false) + expect(params.has('orderStatuses')).toBe(false) expect(params.has('bookingId')).toBe(false) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index af9e69cc..9895e157 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -119,6 +119,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan { blockedReason: raw.blockedReason || null, pdaTaskId: optionalId(raw.pdaTaskId), pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion), + coLoadGroups: (raw.coLoadGroups || []).map((group) => ({ + ...group, + groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId), + memberOrderCount: numberValue(group.memberOrderCount), + inboundFragmentCount: numberValue(group.inboundFragmentCount), + fullPackageCount: numberValue(group.fullPackageCount), + memberInboundIds: (group.memberInboundIds || []).map(id), + })), items: (raw.items || []).map(item), } } @@ -228,20 +236,23 @@ export async function completeContainerClearance(containerId: string, expectedVe return document(unwrapApiResponse(response)) } -function smartLoadingQueryParams(query: SmartLoadingCandidateQuery) { +function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrderStatuses = false) { const params = new URLSearchParams() for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode) + if (includeOrderStatuses) { + for (const orderStatus of query.orderStatuses || []) params.append('orderStatuses', orderStatus) + } 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], + ['createdStart', query.createdStart], ['createdEnd', query.createdEnd], + ['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 params = smartLoadingQueryParams(query, true) const response = await http.get, ApiEnvelope>(`${smartLoadingPath}/inbound-candidates`, { params }) const result = unwrapApiResponse(response) return { @@ -249,7 +260,7 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue 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), + ...raw, accountId: id(raw.accountId), customerId: id(raw.customerId), inboundId: optionalId(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), @@ -277,6 +288,8 @@ export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCan 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), + capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm), + maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg), version: numberValue(raw.version), })), } @@ -297,6 +310,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI return plan(unwrapApiResponse(response)) } +export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) { + const response = await http.post, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`, + { ...payload, operationId: operationId('assign-pending-review') }, + ) + return plan(unwrapApiResponse(response)) +} + export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) { const response = await http.post, ApiEnvelope>( `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`, diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 931719b7..7a648fec 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -10,6 +10,7 @@ import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue' import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' import type { ContainerBookingResource } from '../types' import { + assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getSmartLoadingPlanByBooking, @@ -40,6 +41,7 @@ vi.mock('../api/platformContainerLoading', () => ({ listSmartLoadingWorkbenchContainers: vi.fn(), createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), + assignPendingReviewSmartLoadingOrder: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(), adjustSmartLoadingItem: vi.fn(), @@ -47,7 +49,13 @@ vi.mock('../api/platformContainerLoading', () => ({ removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(), })) -vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) })) +vi.mock('@/modules/warehouses', () => ({ + listAllPlatformWarehouses: vi.fn().mockResolvedValue([ + { id: '8', warehouseName: '义乌仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }, + { id: '18', warehouseName: '东莞仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }, + ]), +})) +vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) })) const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', @@ -62,6 +70,7 @@ const plan = { 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: [], 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, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } const candidate = { @@ -87,7 +96,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) { 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, + containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg, + 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> @@ -100,10 +110,18 @@ function mockContainerSearch(records: ContainerBookingResource[]) { } describe('GlobalSmartLoadingView', () => { + 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/GlobalSmartLoadingView.vue'), 'utf8') + expect(source).toContain('') + expect(source).toContain('') + 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).not.toContain(' { 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(candidatePage([candidate])) @@ -113,7 +131,6 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 }) expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 }) expect(wrapper.text()).toContain('候选装柜明细') @@ -143,13 +160,12 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) - 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 }])) + mockContainerSearch([container]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('EXX') - expect(wrapper.text()).not.toContain('纽约EXX海铁') + expect(wrapper.text()).toContain('美森正班') + expect(wrapper.text()).not.toContain('美森正班海卡包税') expect(wrapper.text()).not.toContain('缺少渠道快照') expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id) }) @@ -278,7 +294,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).toContain('MSCU-DG') const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length - wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) await flushPromises() expect(wrapper.text()).toContain('MSCU-DG') @@ -305,7 +321,7 @@ describe('GlobalSmartLoadingView', () => { await flushPromises() const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length - wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) await flushPromises() expect(wrapper.find('.channel-tags .el-checkbox-button.is-checked').text()).toBe('美森正班') @@ -336,7 +352,7 @@ describe('GlobalSmartLoadingView', () => { const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.findComponent(QueryToolbar).vm.$emit('search', { - warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', expectedArrivalRange: [], orderStatus: '', + warehouseId: '', customerId: '', inboundCode: 'NYEXX2609030002', createdRange: [], orderStatuses: [], }) await flushPromises() @@ -361,7 +377,7 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain(': candidate.availablePackageCount') }) - it('filters candidates by expected arrival range and order status', async () => { + it('filters candidates by creation range and multiple order statuses', async () => { vi.mocked(listSmartLoadingCandidates) .mockResolvedValueOnce(candidatePage([ { ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' }, @@ -374,7 +390,7 @@ describe('GlobalSmartLoadingView', () => { const containerRequestCount = vi.mocked(listSmartLoadingWorkbenchContainers).mock.calls.length wrapper.getComponent(QueryToolbar).vm.$emit('search', { - customerId: '', inboundCode: '', orderStatus: 'WAIT_SPLIT', expectedArrivalRange: ['2026-09-10', '2026-09-12'], + customerId: '', inboundCode: '', orderStatuses: ['WAIT_SPLIT', 'LOADING'], createdRange: ['2026-09-10', '2026-09-12'], }) await flushPromises() @@ -382,7 +398,7 @@ 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(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 }) expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount) expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 }) }) @@ -436,6 +452,8 @@ describe('GlobalSmartLoadingView', () => { await buttons[0].trigger('click') await flushPromises() expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除') + expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1') + expect(document.body.textContent).toContain('发货仓:义乌仓 · 渠道:美森正班') expect(document.body.textContent).not.toContain('本次分配箱数') ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() await flushPromises() @@ -445,6 +463,43 @@ describe('GlobalSmartLoadingView', () => { wrapper.unmount() }) + it('allows a pending-review order to join through the provisional assignment command', async () => { + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ + ...candidate, + customerId: '1', + inboundId: null, + orderStatusSnapshot: 'PENDING_REVIEW', + inboundCode: 'EXXHK2609020361', + unitGrossWeightKg: null, + averageUnitVolumeCbm: null, + availableVolumeCbm: null, + blockReason: null, + }])) + vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan) + const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) + await flushPromises() + + expect(wrapper.get('.candidate-pane').text()).toContain('待审核') + const add = wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button') + .find((button) => button.text().includes('加入'))! + expect(add.classes()).not.toContain('is-disabled') + await add.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('平均单箱毛重 待测量') + expect(document.body.textContent).toContain('平均单箱体积 待测量') + expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg') + ;([...document.body.querySelectorAll('button')] + .find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click() + await flushPromises() + + expect(assignPendingReviewSmartLoadingOrder).toHaveBeenCalledWith(plan.id, { + customerId: '1', orderId: '7', inboundCode: 'EXXHK2609020361', + packageCount: 12, expectedVersion: plan.version, + }) + expect(assignSmartLoadingItem).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('blocks an incomplete three-member candidate group without exposing member assignment', async () => { 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([{ @@ -476,8 +531,8 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - expect(wrapper.text()).toContain('0.22%') - expect(wrapper.text()).toContain('0.04%') + expect(wrapper.text()).toContain('0.16%') + expect(wrapper.text()).toContain('0.03%') expect(wrapper.text()).not.toContain('0.169999999') expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0) }) @@ -487,16 +542,19 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }> - expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'expectedArrivalRange', 'orderStatus']) - expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([{ label: '义乌仓', value: '8' }]) - expect(fields.find((field) => field.key === 'orderStatus')?.options).toEqual([ - { value: '', label: '全部' }, + const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record }> + expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses']) + expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([ + { label: '义乌仓', value: '8' }, + { label: '东莞仓', value: '18' }, + ]) + expect(fields.find((field) => field.key === 'orderStatuses')?.options).toEqual([ { value: 'PENDING_REVIEW', label: '待审核' }, { value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_SPLIT', label: '待分柜' }, { value: 'LOADING', label: '装柜中' }, ]) + expect(fields.find((field) => field.key === 'orderStatuses')?.props).toMatchObject({ multiple: true, collapseTags: true, maxCollapseTags: 2 }) expect(source).not.toContain('目的仓') expect(wrapper.text()).not.toContain('税率') @@ -577,7 +635,7 @@ describe('GlobalSmartLoadingView', () => { expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('CB-3') - expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑') + expect(wrapper.text()).toContain('暂无符合条件的货柜') expect(wrapper.text()).not.toContain('缺少渠道快照') }) @@ -628,7 +686,7 @@ describe('GlobalSmartLoadingView', () => { expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2) expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3) - expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3) + expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2) expect(warningMessage).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('装柜操作失败') expect(wrapper.text()).not.toContain('request-1') @@ -759,6 +817,7 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain('size="small"') expect(source).toContain('type="danger" plain') expect(source).toContain(':max="adjustmentMax(row)"') + expect(source).toContain("{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }") expect(source).not.toContain('link type="danger"') expect(source).toContain('@media (max-width: 720px)') expect(source.match(/ { 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('min-height: 112px') - expect(source).toContain("label: '预计到货时间'") + expect(source).toContain("label: '创建日期'") expect(source).toContain("label: '订单状态'") expect(source).toContain('.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }') expect(source).toContain('.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }') diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index d965bd93..f5c95a4a 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -4,19 +4,21 @@ import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import LineClampText from '@/shared/components/LineClampText.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { adjustSmartLoadingItem, + assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getSmartLoadingPlanByBooking, - listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, recommendSmartLoading, @@ -24,7 +26,6 @@ import { removeSmartLoadingItem, } from '../api/platformContainerLoading' import type { - ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan, SmartLoadingWorkbenchContainer, @@ -40,7 +41,7 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: fa const access = useAccessPreviewStore() const containers = ref([]) -const containerCatalog = ref([]) +const warehouses = ref([]) const customers = ref([]) const customersLoading = ref(false) const selectedContainerId = ref('') @@ -61,8 +62,8 @@ const selectedCandidate = ref(null) const assignForm = reactive({ packageCount: 1 }) const quantityDrafts = reactive>({}) const planGroupsByInboundId = ref(new Map()) -const query = ref({ warehouseId: '', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) -const appliedQuery = ref({ ...query.value }) +const query = ref({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] }) +const appliedQuery = ref({ ...query.value, orderStatuses: [] }) const candidatePage = ref(1) const candidatePageSize = ref(20) const candidateTotal = ref(0) @@ -73,8 +74,8 @@ let containerRequest = 0 const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete')) const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null) -const orchestratableContainers = computed(() => containerCatalog.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus))) -const completedContainerCount = computed(() => containerCatalog.value.filter((item) => item.planStatus === 'COMPLETED').length) +const orchestratableContainers = computed(() => containers.value) +const completedContainerCount = computed(() => 0) const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length) const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => ( item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) @@ -84,17 +85,12 @@ const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.va )).length) const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS) const warehouseOptions = computed(() => { - const options = new Map() - for (const item of orchestratableContainers.value) { - if (item.warehouseId && !options.has(item.warehouseId)) options.set(item.warehouseId, item.warehouseName?.trim() || '未命名发货仓') - } - return [...options].map(([value, label]) => ({ value, label })) + return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName })) }) const editable = computed(() => plan.value?.canEdit === true) const assignable = computed(() => plan.value?.canAssign === true) -const selectedContainerResource = computed(() => containerCatalog.value.find((item) => item.id === selectedContainerId.value) || null) const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId))) -type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number; totalVolumeCbm: number } +type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null } function memberOrderSummaries(members: T[]) { const orders = new Map() for (const member of members) { @@ -111,21 +107,21 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) { : `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段` } function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) { - return Number(candidate.unitGrossWeightKg || 0) * Number(candidate.availablePackageCount || 0) + return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0) } function candidateTotalVolume(candidate: SmartLoadingCandidate) { return candidate.availableVolumeCbm == null - ? Number(candidate.averageUnitVolumeCbm || 0) * Number(candidate.availablePackageCount || 0) + ? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0) : Number(candidate.availableVolumeCbm) } const displayedCandidates = computed(() => { return candidates.value.map((candidate) => { - const members: Array<{ accountId: string; inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length + const members: Array<{ accountId: string; inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length ? candidate.groupMembers : [candidate] return { ...candidate, - memberInboundIds: members.map((member) => member.inboundId), + memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)), memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'), memberOrderSummaries: memberOrderSummaries(members), memberOrderCount: new Set(members.map((member) => member.orderId)).size, @@ -176,25 +172,27 @@ const displayedPlanItems = computed(() => { const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0)) const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0)) -const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.1) -const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.1) +const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.5) +const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedWeightPercent = computed(() => percentage(weightPercent.value)) const candidateOrderStatusOptions = [ - { value: '', label: '全部' }, { value: 'PENDING_REVIEW', label: '待审核' }, { value: 'WAIT_MEASURE', label: '待测量数据' }, { value: 'WAIT_SPLIT', label: '待分柜' }, { value: 'LOADING', label: '装柜中' }, ] +function candidateStatusLabel(status: string | null | undefined) { + return candidateOrderStatusOptions.find((item) => item.value === status)?.label || '—' +} const queryFields = computed(() => [ { key: 'warehouseId', type: 'select', label: '发货仓', placeholder: '全部发货仓', width: 160, options: warehouseOptions.value, props: { filterable: true } }, { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 }, - { key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 260 }, - { key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 160, options: candidateOrderStatusOptions }, + { key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 }, + { key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } }, ]) const filteredContainers = computed(() => containers.value) @@ -216,7 +214,8 @@ const candidateColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, - { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70 }, + { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, + { key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 }, // { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' }, { key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' }, @@ -228,7 +227,7 @@ const itemColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 80, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' }, - { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70 }, + { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, { key: 'packageCount', label: '分配箱数', width: 100, slotName: 'packageCount' }, { key: 'grossWeightKg', label: '毛重', width: 80, slotName: 'weight' }, { key: 'volumeCbm', label: '体积', width: 80, slotName: 'volume' }, @@ -237,6 +236,9 @@ const itemColumns: DataTableColumn[] = [ function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' } +function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) { + return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}` +} function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 } function candidateRow(source: object) { return source as SmartLoadingCandidateRow } function itemRow(source: object) { return source as SmartLoadingInboundItem } @@ -245,17 +247,17 @@ function adjustmentMax(source: object) { const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0 return Math.max(item.minPackageCount, item.packageCount + remaining) } -function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) { if (!container) return '待生成' const cabinetReference = String(container.cabinetNo || '').trim() return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成' } -function hasRealContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) { if (!container) return false const value = String(container.containerNo || '').trim() return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value) } -function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) { +function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) { return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录' } function syncDrafts(next: SmartLoadingPlan, preserved: Record = {}) { @@ -264,6 +266,7 @@ function syncDrafts(next: SmartLoadingPlan, preserved: Record = } function acceptPlan(next: SmartLoadingPlan, preserved: Record = {}, syncContainerStatus = true) { plan.value = next + planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next) noDraft.value = false syncDrafts(next, preserved) const container = selectedContainer.value @@ -283,8 +286,8 @@ function clearSelectionData() { } function candidateQueryParams(): Parameters[0] { - const arrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange) - ? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10)) + const createdRange = Array.isArray(appliedQuery.value.createdRange) + ? appliedQuery.value.createdRange.map((value) => String(value || '').slice(0, 10)) : [] const params: Parameters[0] = { channelGroupCodes: [...selectedChannelCodes.value], @@ -294,15 +297,17 @@ function candidateQueryParams(): Parameters[0 const warehouseId = String(appliedQuery.value.warehouseId || '') const customerId = String(appliedQuery.value.customerId || '') const inboundCode = String(appliedQuery.value.inboundCode || '').trim() - const orderStatus = String(appliedQuery.value.orderStatus || '') as typeof params.orderStatus + const orderStatuses = Array.isArray(appliedQuery.value.orderStatuses) + ? appliedQuery.value.orderStatuses.map((value) => String(value)).filter(Boolean) as NonNullable + : [] if (warehouseId) params.warehouseId = warehouseId if (customerId) params.customerId = customerId if (inboundCode) params.inboundCode = inboundCode - if (arrivalRange.length === 2) { - params.expectedArrivalStart = arrivalRange[0] - params.expectedArrivalEnd = arrivalRange[1] + if (createdRange.length === 2) { + params.createdStart = createdRange[0] + params.createdEnd = createdRange[1] } - if (orderStatus) params.orderStatus = orderStatus + if (orderStatuses.length) params.orderStatuses = orderStatuses return params } @@ -350,7 +355,6 @@ async function loadSelectedPlan(preserved: Record = {}) { if (request !== selectionRequest) return if (planResult.value) { acceptPlan(planResult.value, preserved, false) - planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value) syncDrafts(planResult.value, preserved) } else noDraft.value = planResult.missing @@ -391,17 +395,11 @@ async function loadAll(preserved: Record = {}) { loading.value = true loadError.value = null try { - const firstPage = await listContainers({ page: 1, size: 100 }) - const remainingPages = firstPage.pages > 1 - ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 }))) - : [] - containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records) + warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code)) selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code)) if (!selectedChannelCodes.value.length) { - selectedChannelCodes.value = [channelOptions.value.find((channel) => orchestratableContainers.value.some((item) => ( - resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code - )))?.code || channelOptions.value[0]?.code || ''] + selectedChannelCodes.value = [channelOptions.value[0]?.code || ''] } await loadWorkbenchData(preserved) } catch (error) { @@ -437,7 +435,10 @@ async function selectChannels(codes: string[]) { await loadWorkbenchData() } async function applyFilters(model: QueryModel) { - appliedQuery.value = { ...model } + appliedQuery.value = { + ...model, + orderStatuses: Array.isArray(model.orderStatuses) ? model.orderStatuses.map((value) => String(value)) : [], + } candidatePage.value = 1 await loadCandidateData() } @@ -471,20 +472,34 @@ async function assign() { } await mutate(async () => { const selected = selectedCandidate.value! - acceptPlan(selected.coLoadGroupId + acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW' + ? await assignPendingReviewSmartLoadingOrder(plan.value!.id, { + customerId: selected.customerId, + orderId: selected.orderId, + inboundCode: selected.inboundCode || '', + packageCount: assignForm.packageCount, + expectedVersion: plan.value!.version, + }) + : selected.coLoadGroupId && selected.inboundId ? await assignSmartLoadingGroup(plan.value!.id, selected.inboundId, plan.value!.version) : await assignSmartLoadingItem(plan.value!.id, { - inboundId: selected.inboundId, + inboundId: selected.inboundId!, packageCount: assignForm.packageCount, expectedVersion: plan.value!.version, })) assignOpen.value = false - await loadWorkbenchData() + await loadCandidateData() ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次') }, '分配明细失败', { preserveAssign: true }) } function candidateBlocked(candidate: SmartLoadingCandidateRow) { + if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') { + return Boolean(candidate.blockReason) || candidate.locked === true + || !candidate.inboundCode || candidate.availablePackageCount < 1 + } return Boolean(candidate.blockReason) || candidate.locked === true + || !candidate.inboundId + || !candidate.inboundCode || candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId)) || candidate.availablePackageCount < 1 } @@ -500,7 +515,7 @@ async function recommend() { if (!plan.value) throw error acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)) } - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('智能推荐已生成') }, '智能推荐失败') } @@ -516,7 +531,7 @@ async function adjust(source: object) { if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return } await mutate(async () => { acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version)) - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('箱数已调整') }, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } }) } @@ -533,7 +548,7 @@ async function remove(source: object) { await mutate(async () => { if (row.coLoadGroup) { acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version)) - await loadWorkbenchData() + await loadCandidateData() ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`) return } @@ -544,7 +559,7 @@ async function remove(source: object) { current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version) acceptPlan(current) } - await loadWorkbenchData() + await loadCandidateData() ElMessage.success('该入仓码已从当前柜次移除') }, '移除装柜明细失败', { pickingConflict: true }) } @@ -571,7 +586,7 @@ async function removeAll() { acceptPlan(current) } } - await loadWorkbenchData() + await loadCandidateData() ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`) }, '全部移除失败', { pickingConflict: true }) } @@ -676,11 +691,13 @@ onMounted(() => { void load(); void loadCustomers() }) @click="openAssign(row)" >{{ row.coLoadGroupId ? '整组分配' : '加入' }} - - - - - + + + + + + + @@ -747,8 +764,9 @@ onMounted(() => { void load(); void loadCustomers() }) - - + + + @@ -759,13 +777,15 @@ onMounted(() => { void load(); void loadCustomers() })

{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}

+

目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}

+

发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}

@@ -831,7 +851,9 @@ onMounted(() => { void load(); void loadCustomers() }) .plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .assign-dialog p { margin: 0 0 10px; } .assign-dialog :deep(.el-input-number) { width: 100%; } -.candidate-group { display: grid; gap: 4px; } +.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; } +.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; } +.candidate-group :deep(.line-clamp-text) { width: 100%; } .candidate-block { color: var(--el-color-danger) !important; } @media (max-width: 1180px) { :deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; } diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 7f455c0f..dfaab72f 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -29,7 +29,7 @@ const container = { 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, items: [], + pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], 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[]) { @@ -43,6 +43,15 @@ function candidatePage(records: unknown[]) { } 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('') + expect(source).toContain('') + 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).not.toContain(' { setActivePinia(createPinia()) vi.clearAllMocks() @@ -63,7 +72,7 @@ describe('SmartLoadingWorkbenchView', () => { expect(wrapper.text()).not.toContain('LP-99') expect(wrapper.text()).toContain('IN-1') expect(wrapper.text()).not.toContain('ORD-1') - expect(wrapper.text()).toContain('110% 容量上限') + expect(wrapper.text()).toContain('150% 容量上限') }) it('uses loading plan capabilities to keep PDA increments editable', async () => { @@ -179,7 +188,7 @@ describe('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.findAll('button').find((button) => button.text() === '分配')!.trigger('click') + await wrapper.find('.el-table__body-wrapper button').trigger('click') await flushPromises() expect(document.body.textContent).toContain('本次计划装柜箱数') ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click() diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue index 996b6205..2534fa40 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -9,6 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import LineClampText from '@/shared/components/LineClampText.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, @@ -62,12 +63,12 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) { } const displayedCandidates = computed(() => { return candidates.value.map((candidate) => { - const members: Array<{ inboundId: string; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length + const members: Array<{ inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length ? candidate.groupMembers : [candidate] return { ...candidate, - memberInboundIds: members.map((member) => member.inboundId), + memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)), memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'), memberOrderSummaries: memberOrderSummaries(members), memberOrderCount: new Set(members.map((member) => member.orderId)).size, @@ -117,8 +118,8 @@ const displayedPlanItems = computed(() => { }) const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0)) -const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1) -const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1) +const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5) +const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5) const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0) const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const displayedVolumePercent = computed(() => percentage(volumePercent.value)) @@ -129,7 +130,7 @@ const itemColumns: DataTableColumn[] = [ { key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 }, { key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' }, { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, - { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, + { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' }, { key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' }, { key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' }, { key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' }, @@ -139,7 +140,7 @@ const candidateColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true }, { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, - { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, + { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' }, { key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 }, { key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' }, @@ -157,7 +158,7 @@ function explainCandidate(source: object) { const weight = Number(candidate.unitGrossWeightKg || 0) const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount - return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱` + return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱` } function displayCabinetNo(value: ContainerBookingResource | null | undefined) { if (!value) return '待生成' @@ -217,15 +218,17 @@ function openAssign(source: object) { assignOpen.value = true } async function assign() { - if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return - if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return + const currentPlan = plan.value + const selected = selectedCandidate.value + const inboundId = selected?.inboundId + if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return + if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return mutating.value = true mutationError.value = null try { - const selected = selectedCandidate.value const next = selected.coLoadGroupId - ? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version) - : await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }) + ? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version) + : await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version }) await acceptPlanWithGroups(next) assignOpen.value = false ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配') @@ -234,6 +237,7 @@ async function assign() { } function candidateBlocked(candidate: SmartLoadingCandidateRow) { return Boolean(candidate.blockReason) || candidate.locked === true + || !candidate.inboundId || candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId)) || candidate.availablePackageCount < 1 } @@ -347,7 +351,7 @@ onMounted(load) - + @@ -495,7 +496,7 @@ onMounted(() => { .partner-cell { display: flex; align-items: center; gap: 10px; } .partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); } .partner-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } -.partner-cell strong { overflow-wrap: anywhere; } +.partner-cell :deep(.line-clamp-text) { width: 100%; } .partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; } .partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); } .partner-detail { min-height: 180px; padding: 20px 24px 28px; box-sizing: border-box; } diff --git a/src/modules/suppliers/pages/SuppliersView.test.ts b/src/modules/suppliers/pages/SuppliersView.test.ts index 0afa203a..a2cf3d7d 100644 --- a/src/modules/suppliers/pages/SuppliersView.test.ts +++ b/src/modules/suppliers/pages/SuppliersView.test.ts @@ -8,6 +8,13 @@ const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/ const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/routes.ts'), 'utf8') describe('supplier formal pages', () => { + it('clamps long supplier and represented-carrier names with the shared component', () => { + expect(source).toContain("LineClampText from '@/shared/components/LineClampText.vue'") + expect(source).toContain('') + expect(source).toContain(' - + - + @@ -168,7 +169,7 @@ onActivated(() => { diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index b93bafc3..52a56a91 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -2311,7 +2311,7 @@ onBeforeUnmount(() => { - + - + diff --git a/src/shared/components/LineClampText.domestic-lists.test.ts b/src/shared/components/LineClampText.domestic-lists.test.ts new file mode 100644 index 00000000..74cd2d5e --- /dev/null +++ b/src/shared/components/LineClampText.domestic-lists.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8') + +describe('LineClampText domestic list consumers', () => { + it.each([ + ['统一订单', 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'], + ['入库任务', 'src/modules/inbounds/components/InboundTaskList.vue'], + ['入库单', 'src/modules/inbounds/pages/InboundOrdersView.vue'], + ['装柜清单', 'src/modules/loading-lists/components/LoadingListsView.vue'], + ['柜次智能装柜', 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'], + ['全局智能装柜', 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'], + ])('%s列表复用共享的行数省略组件', (_name, path) => { + expect(read(path)).toContain("LineClampText from '@/shared/components/LineClampText.vue'") + }) + + it('统一订单客户与渠道都使用一行省略,并保留渠道变更标记', () => { + const source = read('src/modules/unified-orders/pages/UnifiedOrdersView.vue') + expect(source).toContain("column.property === 'customerName'") + expect(source).toContain('') + expect(source).toContain('曾变更渠道') + }) + + it.each([ + ['src/modules/inbounds/components/InboundTaskList.vue', '#customerName', '#channelName'], + ['src/modules/inbounds/pages/InboundOrdersView.vue', '#customerName', '#channelName'], + ])('入库列表客户与渠道均使用共享组件:%s', (path, customerSlot, channelSlot) => { + const source = read(path) + expect(source).toContain(`