Files
admin-web/src/modules/inbounds/pages/InboundOrdersView.test.ts
T
2026-08-12 12:35:32 +08:00

338 lines
20 KiB
TypeScript

import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import InboundOrdersView from './InboundOrdersView.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { useAuthStore } from '@/core/auth/auth'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
vi.mock('../core/api', () => ({
downloadInboundCartonLabels: vi.fn(),
getPlatformInbound: vi.fn(),
listPlatformInbounds: vi.fn(),
}))
vi.mock('@/modules/warehouses', () => ({
listPlatformWarehouses: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn(),
}))
const customers: PlatformTenantResource[] = [
{ id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
{ id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
]
const summary: InboundOrderSummary = {
id: '19001', tenantId: '10002', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE',
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null,
loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
}
const detail: InboundOrderDetail = {
summary,
publicRemark: '请优先安排测量',
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
}
enableAutoUnmount(afterEach)
function mountPage() {
return mount(InboundOrdersView, {
attachTo: document.body,
global: { plugins: [ElementPlus] },
})
}
async function selectCustomer(wrapper: ReturnType<typeof mountPage>, customerId = '10001') {
await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId })
await flushPromises()
return toolbar
}
describe('InboundOrdersView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
useAuthStore().permissions = ['platform:firstleg:inbound:view']
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
vi.mocked(downloadInboundCartonLabels).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({
records: [
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
],
page: 1, size: 200, total: 2, pages: 1,
})
})
it('loads the customer-scoped list and renders the first-column view action', async () => {
const wrapper = mountPage()
await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId')
expect(customerField?.placeholder).toBe('全部客户')
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformWarehouses).not.toHaveBeenCalled()
const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
const requestsBeforeCustomerChange = vi.mocked(listPlatformInbounds).mock.calls.length
await selectCustomer(wrapper)
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(document.body.textContent).toContain('RK-001')
expect(document.body.textContent).toContain('查看')
expect(document.body.textContent).toContain('示例企业有限公司')
expect(document.body.textContent).not.toContain('租户')
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
})
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'),
warehouseId: '20001',
deliveryWarehouseId: '20002',
})
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
warehouseId: '20001',
deliveryWarehouseId: '20002',
}))
})
it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key)
const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width]))
expect(widths.customerId).toBe(260)
expect(widths.processStatus).toBe(160)
expect(widths.inboundCode).toBe(220)
expect(widths.expectedArrivalRange).toBe(320)
expect(fieldKeys()).toContain('expectedArrivalRange')
expect(fieldKeys()).not.toContain('feeReviewStatus')
const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选'))
await advancedButton()?.trigger('click')
expect(fieldKeys()).toContain('feeReviewStatus')
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'),
feeReviewStatus: 'PENDING',
destinationCode: 'ONT8',
})
await flushPromises()
const requestsBeforeCollapse = vi.mocked(listPlatformInbounds).mock.calls.length
await advancedButton()?.trigger('click')
await flushPromises()
expect(fieldKeys()).not.toContain('feeReviewStatus')
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCollapse)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCollapse + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
feeReviewStatus: undefined,
destinationCode: undefined,
}))
})
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
await flushPromises()
const requestsBeforeTab = vi.mocked(listPlatformInbounds).mock.calls.length
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeTab + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
await flushPromises()
expect(getPlatformInbound).toHaveBeenCalledWith('10002', '19001')
expect(document.body.textContent).toContain('托盘与库位')
expect(document.body.textContent).toContain('请优先安排测量')
wrapper.unmount()
})
it('downloads carton labels from the stable server-side mapping', async () => {
const createObjectURL = vi.fn(() => 'blob:labels')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountPage()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.filter((button) => button.textContent?.trim() === '打印箱唛').at(-1)?.click()
await flushPromises()
expect(downloadInboundCartonLabels).toHaveBeenCalledWith('10002', '19001')
expect(createObjectURL).toHaveBeenCalledOnce()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:labels')
expect(click).toHaveBeenCalledOnce()
click.mockRestore()
vi.unstubAllGlobals()
})
it('does not expose row operations without the confirmed inbound view permission', async () => {
useAuthStore().permissions = []
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('.row-actions').exists()).toBe(false)
expect(wrapper.text()).toContain('无查看权限')
expect(wrapper.text()).not.toContain('打印箱唛')
})
it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('.inbound-page-heading').exists()).toBe(false)
expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成')
const tabList = wrapper.find('[role="tablist"]')
const tabButtons = tabList.findAll('[role="tab"]')
const queryToolbar = wrapper.findComponent(QueryToolbar)
expect(tabList.attributes('aria-label')).toBe('入库单处理状态')
expect(tabList.element.closest('.table-toolbar')).not.toBeNull()
expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(tabButtons).toHaveLength(5)
const toolbarRows = wrapper.find('.table-toolbar').element.children
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
expect(toolbarRows).toHaveLength(2)
expect(wrapper.findComponent(DataTable).find('.data-table__pagination-left').text()).toContain('当前显示 1 条 · 共 1 条')
const dataTableToolbarStart = wrapper.findComponent(DataTable).find('.data-table__toolbar-start')
expect(dataTableToolbarStart.exists() ? dataTableToolbarStart.text() : '').not.toContain('入库单列表')
expect(wrapper.text()).not.toContain('刷新')
expect(wrapper.text()).not.toContain('全部入库单')
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
expect(tabButtons[0].attributes('tabindex')).toBe('0')
await tabButtons[0].trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabButtons[1].attributes('aria-selected')).toBe('true')
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 }))
})
it('shows an actionable list error and recovers on retry', async () => {
vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage()
await flushPromises()
expect(document.body.textContent).toContain('入库单暂时无法加载')
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '重新加载')?.click()
await flushPromises()
expect(listPlatformInbounds).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('RK-001')
})
it('updates customer-dependent options without querying until search', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
await flushPromises()
const requestsBeforeCustomerChange = vi.mocked(listPlatformInbounds).mock.calls.length
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(1, 200)
})
it('resets to all customers without sending an empty backend tenant ID', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
await resetButton?.trigger('click')
await flushPromises()
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 }))
expect(document.body.textContent).not.toContain('请选择所属企业')
expect(document.body.textContent).not.toContain('请选择客户')
})
it('loads another full-data page without a customer filter', async () => {
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
const wrapper = mountPage()
await flushPromises()
const requestsBeforePage = vi.mocked(listPlatformInbounds).mock.calls.length
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforePage + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
})
it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
const columnSource = source.slice(source.indexOf('const columns:'))
const operation = columnSource.indexOf("key: 'actions'")
const inboundCode = columnSource.indexOf("key: 'inboundCode'")
const processStatus = columnSource.indexOf("key: 'processStatus'")
const orderStatus = columnSource.indexOf("key: 'orderStatus'")
const measureReview = columnSource.indexOf("key: 'measureReviewStatus'")
const feeStatus = columnSource.indexOf("key: 'feeStatus'")
const feeReviewStatus = columnSource.indexOf("key: 'feeReviewStatus'")
const customer = columnSource.indexOf("key: 'customerName'")
const warehouse = columnSource.indexOf("key: 'warehouseName'")
expect(operation).toBeGreaterThan(-1)
expect(operation).toBeLessThan(inboundCode)
expect(inboundCode).toBeLessThan(processStatus)
expect(processStatus).toBeLessThan(orderStatus)
expect(orderStatus).toBeLessThan(measureReview)
expect(measureReview).toBeLessThan(feeStatus)
expect(feeStatus).toBeLessThan(feeReviewStatus)
expect(feeReviewStatus).toBeLessThan(customer)
expect(customer).toBeLessThan(warehouse)
expect(columnSource).toContain("fixed: 'left'")
expect(columnSource).toContain("{ key: 'processStatus', label: '入库流程状态'")
expect(columnSource).toContain("{ key: 'orderStatus', label: '订单状态'")
expect(columnSource).toContain("{ key: 'feeStatus', label: '费用状态'")
expect(columnSource).toContain("{ key: 'feeReviewStatus', label: '费用审核状态'")
expect(source).toContain('<template #processStatus="{ row }">')
expect(source).toContain('<template #feeReviewStatus="{ row }">')
expect(source).toContain("access.can('platform:firstleg:inbound:view')")
expect(source).toContain('preference-key="inbound-orders-table"')
expect(columnSource.match(/defaultVisible: false/g)?.length).toBeGreaterThanOrEqual(25)
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
})
})