diff --git a/src/modules/container-loading/components/SmartLoadingDialog.test.ts b/src/modules/container-loading/components/SmartLoadingDialog.test.ts index 9090f471..5283eedd 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.test.ts +++ b/src/modules/container-loading/components/SmartLoadingDialog.test.ts @@ -17,7 +17,7 @@ describe('SmartLoadingDialog', () => { }) await flushPromises() - expect(wrapper.findAll('.smart-loading-dialog__warehouses [role="tab"]').map((tab) => tab.text())).toEqual(['义乌仓', '东莞仓']) + expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false) const expand = wrapper.get('button[aria-label="展开全屏"]') await expand.trigger('click') expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull() diff --git a/src/modules/container-loading/components/SmartLoadingDialog.vue b/src/modules/container-loading/components/SmartLoadingDialog.vue index eca851e0..62657aca 100644 --- a/src/modules/container-loading/components/SmartLoadingDialog.vue +++ b/src/modules/container-loading/components/SmartLoadingDialog.vue @@ -3,23 +3,15 @@ import { Maximize2, Minimize2 } from '@lucide/vue' import { ref, watch } from 'vue' import AppDialog from '@/shared/components/AppDialog.vue' import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue' -import { SMART_LOADING_WAREHOUSE_GROUPS } from '../smartLoadingChannelGroups' const open = defineModel({ default: false }) const fullscreen = ref(false) -const warehouseCode = ref('') -const workbench = ref | null>(null) watch(open, (value) => { if (!value) { fullscreen.value = false - warehouseCode.value = '' } }) - -function selectWarehouse(code: string) { - void workbench.value?.selectWarehouse(code) -} - + diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index e8230cab..6db8959d 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -1,4 +1,4 @@ -import ElementPlus, { ElInputNumber, ElMessage, ElMessageBox } from 'element-plus' +import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox } from 'element-plus' import { AxiosError } from 'axios' import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' @@ -48,7 +48,7 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: const container = { id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1, - billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, + billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船', departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, @@ -88,7 +88,7 @@ describe('GlobalSmartLoadingView', () => { expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId) expect(wrapper.text()).toContain('候选装柜明细') expect(wrapper.text()).toContain('美森正班') - expect(wrapper.findAll('.channel-tabs [role="tab"]')).toHaveLength(9) + expect(wrapper.findAll('.channel-tags .el-checkbox-button')).toHaveLength(9) expect(wrapper.text()).not.toContain('美森正班海卡包税') expect(wrapper.text()).toContain('另一企业') expect(wrapper.text()).toContain('MSCU-1 装柜方案') @@ -105,7 +105,7 @@ describe('GlobalSmartLoadingView', () => { it('uses the top-level container channel snapshot without expecting a nested snapshot', async () => { vi.mocked(listContainers).mockResolvedValue({ - records: [{ ...container, channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }], + records: [{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }], page: 1, size: 100, total: 1, @@ -167,7 +167,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('真实柜号') }) - it('groups containers by channel and reloads candidates when the channel changes', async () => { + it('keeps nine fixed channel tags and allows selecting multiple channels', async () => { const other = { ...container, id: '9007199254740998', @@ -185,17 +185,19 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.findAll('.container-option')).toHaveLength(1) expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).not.toContain('MSCU-2') - expect(wrapper.text()).not.toContain('个柜次') + expect(wrapper.text()).toContain('柜子数量1 个') - const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '合德') - expect(tab).toBeDefined() - await tab!.trigger('click') + expect(wrapper.findAll('.channel-tags .el-checkbox-button').map((item) => item.text())).toEqual([ + '美森正班', '美森加班', 'EXX', '合德', '洛杉矶普船', '芝加哥普船', '休斯顿普船', '萨凡纳普船', '纽约普船海卡', + ]) + const hedeChannel = wrapper.findAllComponents(ElCheckboxButton).find((item) => item.text() === '合德')! + await hedeChannel.find('input').setValue(true) await flushPromises() - expect(wrapper.findAll('.container-option')).toHaveLength(1) + expect(wrapper.findAll('.container-option')).toHaveLength(2) + expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).toContain('MSCU-2') - expect(wrapper.text()).not.toContain('MSCU-1') - expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId) + expect(wrapper.text()).toContain('柜子数量2 个') }) it('merges detailed pricing channels into the same confirmed display group', async () => { @@ -219,7 +221,7 @@ describe('GlobalSmartLoadingView', () => { expect(wrapper.text()).not.toContain('美森正班海派') }) - it('isolates Yiwu and Dongguan containers when the host changes the warehouse', async () => { + it('moves the shipping warehouse into the query filters', async () => { const dongguanContainer = { ...container, id: '9007199254740998', @@ -235,9 +237,9 @@ describe('GlobalSmartLoadingView', () => { await flushPromises() expect(wrapper.text()).toContain('MSCU-1') - expect(wrapper.text()).not.toContain('MSCU-DG') + expect(wrapper.text()).toContain('MSCU-DG') - await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise }).selectWarehouse('DONGGUAN') + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) await flushPromises() expect(wrapper.text()).toContain('MSCU-DG') @@ -261,10 +263,10 @@ describe('GlobalSmartLoadingView', () => { const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() - await (wrapper.vm as unknown as { selectWarehouse: (code: string) => Promise }).selectWarehouse('DONGGUAN') + wrapper.findComponent(QueryToolbar).vm.$emit('search', { warehouseId: '18', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) await flushPromises() - expect(wrapper.get('.channel-tabs [role="tab"][aria-selected="true"]').text()).toBe('美森正班') + 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) @@ -391,13 +393,35 @@ describe('GlobalSmartLoadingView', () => { 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(['customerId', 'inboundCode', 'expectedArrivalRange', 'orderStatus']) + 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: '全部' }, + { value: 'PENDING_REVIEW', label: '待审核' }, + { value: 'WAIT_MEASURE', label: '待测量数据' }, + { value: 'WAIT_SPLIT', label: '待分柜' }, + { value: 'LOADING', label: '装柜中' }, + ]) expect(source).not.toContain('目的仓') expect(wrapper.text()).not.toContain('税率') 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' }, + ]) + 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') + }) + 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 }) @@ -656,23 +680,28 @@ describe('GlobalSmartLoadingView', () => { expect(source).toContain('min-height: 112px') 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; }') expect(source).toContain(':page-sizes="[20, 50, 100]"') }) - it('uses overflow-aware channel tabs with a compact right-aligned summary', () => { + it('uses nine fixed multi-select channel tags with a compact right-aligned summary', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8') - expect(source).toContain('候选明细') - expect(source).toContain('
柜次
') + expect(source).toContain('
柜子数量
') expect(source).toContain('
已装
') expect(source).toContain('
总重量
') expect(source).toContain('
总体积
') - expect(source).toContain('.channel-tabs :deep(.el-tabs__content) { display: none; }') + expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)') + expect(source).toContain('flex-wrap: nowrap; gap: 6px;') + expect(source).toContain('height: 34px; padding: 0 10px;') + expect(source).toContain('font-size: 13px; line-height: 32px;') expect(source).not.toContain('class="summary-band"') expect(source).toContain('class="capacity-grid"') expect(source).toContain('智能推荐') diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 79c1e372..bf1de4a7 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -32,13 +32,10 @@ import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLo import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups' import { SMART_LOADING_CHANNEL_GROUPS, - SMART_LOADING_WAREHOUSE_GROUPS, resolveSmartLoadingChannelGroup, - resolveSmartLoadingWarehouseGroup, } from '../smartLoadingChannelGroups' const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) -const activeWarehouseCode = defineModel('warehouseCode', { default: '' }) const access = useAccessPreviewStore() const router = useRouter() @@ -46,7 +43,7 @@ const containers = ref([]) const customers = ref([]) const customersLoading = ref(false) const selectedContainerId = ref('') -const activeChannelCode = ref('') +const selectedChannelCodes = ref([]) const plan = ref(null) const candidates = ref([]) const loading = ref(false) @@ -61,7 +58,7 @@ const selectedCandidate = ref(null) const assignForm = reactive({ packageCount: 1 }) const quantityDrafts = reactive>({}) const planGroupsByInboundId = ref(new Map()) -const query = ref({ customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) +const query = ref({ warehouseId: '', customerId: '', inboundCode: '', expectedArrivalRange: [], orderStatus: '' }) const appliedQuery = ref({ ...query.value }) const candidatePage = ref(1) const candidatePageSize = ref(20) @@ -77,16 +74,16 @@ const unmappedChannelContainerCount = computed(() => orchestratableContainers.va item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) )).length) const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => ( - resolveSmartLoadingWarehouseGroup(item.warehouseName) - && resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) + resolveSmartLoadingChannelGroup(item.channelCode, item.channelName) )).length) -const unmappedWarehouseContainerCount = computed(() => orchestratableContainers.value.filter((item) => ( - !resolveSmartLoadingWarehouseGroup(item.warehouseName) -)).length) -const warehouseTabs = computed(() => SMART_LOADING_WAREHOUSE_GROUPS) -const channelTabs = computed(() => SMART_LOADING_CHANNEL_GROUPS) -const activeWarehouse = computed(() => warehouseTabs.value.find((item) => item.code === activeWarehouseCode.value) || null) -const activeChannel = computed(() => channelTabs.value.find((item) => item.code === activeChannelCode.value) || null) +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 })) +}) const editable = computed(() => plan.value?.canEdit === true) const assignable = computed(() => plan.value?.canAssign === true) const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null) @@ -182,30 +179,32 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0) const displayedVolumePercent = computed(() => percentage(volumePercent.value)) const displayedWeightPercent = computed(() => percentage(weightPercent.value)) -const candidateOrderStatusLabels: Record = { - DRAFT: '草稿', SUBMITTED: '待审核', PENDING_REVIEW: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', - REJECTED: '已驳回', PROCESSING: '处理中', WAIT_MEASURE: '待测量数据', WAIT_SPLIT: '待分柜', - LOADING: '装柜中', LOADED: '装柜完成', COMPLETED: '已完成', DELIVERED: '派送完成', CANCELLED: '已取消', +const candidateOrderStatusOptions = [ + { value: '', label: '全部' }, + { value: 'PENDING_REVIEW', label: '待审核' }, + { value: 'WAIT_MEASURE', label: '待测量数据' }, + { value: 'WAIT_SPLIT', label: '待分柜' }, + { value: 'LOADING', label: '装柜中' }, +] +const candidateOrderStatusGroups: Record = { + PENDING_REVIEW: ['SUBMITTED', 'PENDING_REVIEW'], + WAIT_MEASURE: ['WAIT_MEASURE'], + WAIT_SPLIT: ['WAIT_SPLIT'], + LOADING: ['LOADING'], } -function candidateOrderStatusLabel(status: string) { - return candidateOrderStatusLabels[status] || '未知状态' -} -const candidateOrderStatusOptions = computed(() => [...new Set(candidates.value - .map((candidate) => candidate.orderStatusSnapshot?.trim().toUpperCase() || '') - .filter(Boolean))] - .map((status) => ({ value: status, label: candidateOrderStatusLabel(status) }))) +const completedCandidateOrderStatuses = new Set(['LOADED', 'COMPLETED']) const queryFields = computed(() => [ - { key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, 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: 240 }, - { key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 300 }, - { key: 'orderStatus', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 200, options: candidateOrderStatusOptions.value }, + { 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 }, ]) const filteredContainers = computed(() => containers.value.filter((item) => ( ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) - && resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === activeWarehouseCode.value - && Boolean(activeChannelCode.value) - && resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === activeChannelCode.value + && (!String(appliedQuery.value.warehouseId || '') || item.warehouseId === String(appliedQuery.value.warehouseId)) + && selectedChannelCodes.value.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '') ))) const filteredCandidates = computed(() => { const customerId = String(appliedQuery.value.customerId || '') @@ -217,9 +216,10 @@ const filteredCandidates = computed(() => { const channelCode = selectedContainer.value?.channelCode return displayedCandidates.value.filter((item) => { if (!channelCode || item.channelCode !== channelCode) return false + if (item.sourceCandidates.some((member) => completedCandidateOrderStatuses.has(member.orderStatusSnapshot?.trim().toUpperCase() || ''))) return false if (customerId && !item.sourceCandidates.some((member) => member.accountId === customerId)) return false if (inboundCode && !item.memberInboundCodes.some((code) => code.toLowerCase().includes(inboundCode))) return false - if (orderStatus && !item.sourceCandidates.some((member) => member.orderStatusSnapshot?.trim().toUpperCase() === orderStatus)) return false + if (orderStatus && !item.sourceCandidates.some((member) => candidateOrderStatusGroups[orderStatus]?.includes(member.orderStatusSnapshot?.trim().toUpperCase() || ''))) return false if (arrivalRange.length === 2 && !item.sourceCandidates.some((member) => { const arrivalDate = member.expectedArrivalAt?.slice(0, 10) || '' return arrivalDate && arrivalDate >= arrivalRange[0] && arrivalDate <= arrivalRange[1] @@ -298,12 +298,6 @@ function acceptPlan(next: SmartLoadingPlan, preserved: Record = } } -function preferredChannelCode(warehouseCode: string) { - return channelTabs.value.find((group) => orchestratableContainers.value.some((item) => ( - resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === warehouseCode - && resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === group.code - )))?.code || channelTabs.value[0]?.code || '' -} function clearSelectionData() { plan.value = null candidates.value = [] @@ -363,13 +357,12 @@ async function loadAll(preserved: Record = {}) { ? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 }))) : [] containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records) - if (!warehouseTabs.value.some((item) => item.code === activeWarehouseCode.value)) { - activeWarehouseCode.value = warehouseTabs.value.find((group) => orchestratableContainers.value.some((item) => ( - resolveSmartLoadingWarehouseGroup(item.warehouseName)?.code === group.code - )))?.code || warehouseTabs.value[0]?.code || '' - } - if (!channelTabs.value.some((item) => item.code === activeChannelCode.value)) { - activeChannelCode.value = preferredChannelCode(activeWarehouseCode.value) + 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 || ''] } if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId() await loadSelectedData(preserved) @@ -396,16 +389,12 @@ async function selectContainer(containerId: string) { selectedContainerId.value = containerId await loadSelectedData() } -async function selectWarehouse(warehouseCode: string) { - if (activeWarehouseCode.value === warehouseCode) return - activeWarehouseCode.value = warehouseCode - selectedContainerId.value = preferredContainerId() - await loadSelectedData() -} -defineExpose({ selectWarehouse }) -async function selectChannel(channelCode: string) { - if (activeChannelCode.value === channelCode) return - activeChannelCode.value = channelCode +async function selectChannels(codes: string[]) { + if (!codes.length) { + ElMessage.warning('至少选择一个渠道') + return + } + selectedChannelCodes.value = codes selectedContainerId.value = preferredContainerId() await loadSelectedData() } @@ -575,13 +564,14 @@ onMounted(() => { void load(); void loadCustomers() })