import ElementPlus from 'element-plus' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { listOutbounds } from '../api/outbounds' import OutboundsView from './OutboundsView.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' const canPermission = vi.hoisted(() => vi.fn((_code: string) => true)) const routerPush = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn(), cancelOutbound: vi.fn(), createOneClickDropshipOutbound: vi.fn() })) vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), listInventoryPositions: vi.fn() })) class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} } describe('OutboundsView permissions and navigation', () => { it('distinguishes outbound stages and does not present unknown states as picking', async () => { const statuses = [ ['WAIT_PICK', '待拣货', 'warning'], ['WAIT_OUTBOUND', '待出库', 'warning'], ['PICKING', '拣货中', 'info'], ['PICKED', '已拣货', 'warning'], ['OUTBOUNDED', '已出库', 'success'], ['CANCELLED', '已取消', 'danger'], ['FUTURE_STATE', '未知状态', 'neutral'], ] vi.mocked(listOutbounds).mockResolvedValueOnce({ records: statuses.map(([status], index) => ({ id: String(index), outboundNo: `OUT-${index}`, status })), page: 1, size: 20, total: statuses.length, pages: 1 }) const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }) await flushPromises() const badges = wrapper.findAllComponents(StatusBadge).filter(badge => badge.element.closest('tbody')) expect(badges).toHaveLength(statuses.length) statuses.forEach(([, label, tone], index) => { expect(badges[index].text()).toBe(label) expect(badges[index].classes()).toContain(`is-${tone}`) }) wrapper.unmount() }) beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }) vi.mocked(listOutbounds).mockReset().mockResolvedValue({ records: [{ id: '1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_PICK', palletCount: 1, packageCount: 2 }], page: 1, size: 20, total: 1, pages: 1 }) canPermission.mockReset().mockReturnValue(true) routerPush.mockReset() }) it('uses list access for detail and independent create/cancel permissions', async () => { canPermission.mockImplementation((code: string) => code.endsWith(':list')) const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('详情') expect(wrapper.text()).not.toContain('新建出库单') expect(wrapper.text()).not.toContain('取消') expect(OutboundsView.__file).toContain('OutboundsView.vue') wrapper.unmount() }) it('shows outbound creation and cancellation only with their exact permissions', async () => { canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code)) const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('新建出库单') expect(wrapper.text()).toContain('取消') wrapper.unmount() }) it('loads one-click candidates across customers and loads return inventory after customer selection', async () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') expect(source).toContain('v-model="selectedCustomerId"') expect(source).toContain('listOneClickOutboundCandidates(selectedCustomerId.value || undefined') expect(source).toContain('if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates()') expect(source).toContain("createOneClickDropshipOutbound([...customerIds][0] || ''") }) it('applies batched return filters only through explicit search and exposes page sizes', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') expect(source).toContain('returnSkuQueryInput') expect(source).toContain('returnCartonQueryInput') expect(source).toContain("item.replace(/\\s+/g, '')") expect(source).toContain('@click="searchReturnSkus"') expect(source).toContain('@click="searchReturnCartons"') expect(source).toContain(':page-sizes="[5, 10, 20, 50]"') expect(source).toContain('width="min(1320px, calc(100vw - 32px))"') }) it('resolves warehouse-transfer customer names from the platform customer list', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') expect(source).toContain('function transferCustomerName(row: WarehouseTransferOutboundCandidate)') expect(source).toContain('String(item.id) === String(row.customerId)') expect(source).toContain("customer?.legalName?.trim() || customer?.tenantName?.trim()") expect(source).toContain('transferCustomerName(row as WarehouseTransferOutboundCandidate)') }) it('keeps warehouse option requests within the platform page-size limit', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') expect(source).toContain("size: 200, warehouseType: 'FBA'") expect(source).not.toContain('size: 500') }) it('batch-binds destinationless transfers without creating an outbound order', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') const api = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/outbounds.ts'), 'utf8') expect(source).toContain('无目的地转运单') expect(source).toContain('绑定目的地并进入排车') expect(source).toContain('privateDestinationPostalCode: postalCode.value.trim()') expect(source).toContain('transfers: selected.map(item => ({ customerId: item.customerId, transferNo: item.transferNo }))') expect(api).toContain('/platform/v1/last-leg/warehouse-transfers/bind-destination') expect(source).not.toContain('await createWarehouseTransferOutbound') }) it('auto-selects the sole warehouse-transfer candidate and shows destination state', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8') const api = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/api/outbounds.ts'), 'utf8') expect(source).toContain("if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]") expect(source).toContain("transferCandidates.value.length === 1 ? [transferCandidates.value[0]] : []") expect(source).toContain('label="主单目的去向"') expect(source).toContain("['私人仓', row.privateDestinationAddress?.trim(), row.privateDestinationPostalCode?.trim()]") expect(source).toContain('`FBA · ${row.destinationWarehouseCode.trim()}`') expect(source).toContain("return '未设置'") expect(api).toContain('destinationWarehouseCode?: string | null') expect(api).toContain("destinationType?: 'FBA' | 'PRIVATE' | null") expect(api).toContain('privateDestinationAddress?: string | null') expect(api).toContain('privateDestinationPostalCode?: string | null') }) })