Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
8 changed files with 392 additions and 3 deletions
Showing only changes of commit a5d4b7d54a - Show all commits
@@ -39,6 +39,11 @@ describe('customer visit API', () => {
await uploadCustomerVisitAttachment('9', '100', file)
const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData
expect(body.get('file')).toBe(file)
expect(overseasHttp.post).toHaveBeenCalledWith(
'/platform/v1/last-leg/customer-visits/9/attachments',
body,
{ params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } },
)
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } })
await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] })
@@ -1,11 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getDeliveryOrder, listDeliveryOrders } from './deliveryOrders'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from './deliveryOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
describe('delivery order API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
})
it('passes paging and expanded filters and normalizes page numbers', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } })
@@ -19,4 +22,15 @@ describe('delivery order API', () => {
await getDeliveryOrder('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
})
it('loads return options and marks a return in the explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
await listDeliveryReturnInboundOptions('DEL/1', '1001')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', packageCount: 2 }
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
await markDeliveryReturn('DEL/1', '1001', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
})
})
@@ -0,0 +1,76 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCustomerVisit, listCustomerVisits, saveCustomerVisit, uploadCustomerVisitAttachment } from '../api/customerVisits'
import CustomerVisitsView from './CustomerVisitsView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/customerVisits', () => ({ listCustomerVisits: vi.fn(), getCustomerVisit: vi.fn(), saveCustomerVisit: vi.fn(), uploadCustomerVisitAttachment: vi.fn() }))
const visit = {
id: '9', customerId: '100', deliveryNo: 'DEL-1', deliveryCompletedAt: '2026-09-01T10:00:00',
status: 'PENDING' as const, visitCount: 0, attachments: [],
}
function mountView() { return mount(CustomerVisitsView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
describe('CustomerVisitsView permissions and visit material', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listCustomerVisits).mockReset().mockResolvedValue({ records: [visit], current: 1, size: 10, total: 1, pages: 1, visitable: 1, pending: 1, completed: 0 })
vi.mocked(getCustomerVisit).mockReset().mockResolvedValue({
visit,
inboundOrders: [{ inboundOrderId: '7', inboundCode: 'IN-1', lastMileOrderId: '6', lastMileOrderNo: 'LM-1', packageCount: 2 }],
customerFeedback: '', remark: '',
})
vi.mocked(saveCustomerVisit).mockReset().mockResolvedValue({ visit, inboundOrders: [] })
vi.mocked(uploadCustomerVisitAttachment).mockReset().mockResolvedValue({ fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 })
})
it('hides the visit action without edit permission', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('回访')
wrapper.unmount()
})
it('validates and submits a customer-scoped visit', async () => {
canPermission.mockImplementation(code => code.endsWith(':edit'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
expect(getCustomerVisit).toHaveBeenCalledWith('9', '100')
const dialog = wrapper.find('[data-dialog="客户回访"]')
await dialog.find('.dialog-confirm').trigger('click')
expect(saveCustomerVisit).not.toHaveBeenCalled()
await dialog.find('select').setValue('NORMAL')
const textareas = dialog.findAll('textarea')
await textareas[0].setValue(' 服务正常 ')
await textareas[1].setValue(' 后续关注 ')
await dialog.find('.dialog-confirm').trigger('click')
await flushPromises()
expect(saveCustomerVisit).toHaveBeenCalledWith('9', '100', {
selectedInboundOrderId: '7', visitResult: 'NORMAL', customerFeedback: '服务正常', remark: '后续关注', attachments: [],
})
wrapper.unmount()
})
it('uploads the selected attachment in the visit customer scope', async () => {
canPermission.mockImplementation(code => code.endsWith(':edit'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
const input = wrapper.find('[data-dialog="客户回访"] input[type="file"]')
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await flushPromises()
expect(uploadCustomerVisitAttachment).toHaveBeenCalledWith('9', '100', file)
expect(wrapper.text()).toContain('visit.pdf')
wrapper.unmount()
})
})
@@ -0,0 +1,42 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDeliveryOrder } from '../api/deliveryOrders'
import DeliveryOrderDetailView from './DeliveryOrderDetailView.vue'
const routeState = vi.hoisted(() => ({ params: { deliveryNo: 'DEL/1' }, query: { customerId: '100' } as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn() }))
describe('DeliveryOrderDetailView', () => {
beforeEach(() => {
routeState.query = { customerId: '100' }
vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({
order: {
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [], operations: [],
})
})
it('loads and renders detail in the explicit customer scope', async () => {
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDeliveryOrder).toHaveBeenCalledWith('DEL/1', '100')
expect(wrapper.text()).toContain('DEL/1')
expect(wrapper.text()).toContain('派送中')
expect(wrapper.text()).toContain('洛杉矶仓')
wrapper.unmount()
})
it('blocks an unsafe detail request without customer scope', async () => {
routeState.query = {}
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDeliveryOrder).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少派送单客户范围')
wrapper.unmount()
})
})
@@ -0,0 +1,78 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
const row = {
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00',
}
function mountView() {
return mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } })
}
describe('DeliveryOrdersView permissions and commands', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }])
vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false })
})
it('keeps detail read-only while hiding independent write operations', async () => {
const wrapper = mountView()
await flushPromises()
const tableText = wrapper.find('[data-testid="data-table"]').text()
expect(tableText).toContain('详情')
expect(tableText).not.toContain('编辑状态')
expect(tableText).not.toContain('标记退货')
wrapper.unmount()
})
it('submits status and return commands with the row customer scope', async () => {
canPermission.mockImplementation(code => code.endsWith(':status') || code.endsWith(':return'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'WAIT_DELIVERY', remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await flushPromises()
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
returnLogisticsNo: undefined, packageCount: undefined, skuCode: undefined,
})
wrapper.unmount()
})
it('does not submit an incomplete return form', async () => {
canPermission.mockImplementation(code => code.endsWith(':return'))
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([])
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,32 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDispatch } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue'
import { DataTableStub } from './phase5TestStubs'
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' } }), useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
describe('DispatchDetailView', () => {
beforeEach(() => {
routerPush.mockReset()
vi.mocked(getDispatch).mockReset().mockResolvedValue({
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', volumeCbm: 1.25 }],
})
})
it('loads the business dispatch number and renders summary and related orders', async () => {
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
expect(wrapper.text()).toContain('PC/1')
expect(wrapper.text()).toContain('CA-100')
expect(wrapper.text()).toContain('待发车')
const table = wrapper.findComponent(DataTableStub)
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: '—', palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
})
@@ -0,0 +1,59 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from '../api/exceptionOrders'
import ExceptionOrdersView from './ExceptionOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn() }))
function mountView() { return mount(ExceptionOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
describe('ExceptionOrdersView permissions and creation', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' })
vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
})
it('hides creation without the independent permission', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('新增异常订单')
wrapper.unmount()
})
it('validates the required relation before submitting', async () => {
canPermission.mockImplementation(code => code.endsWith(':create'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('新增异常订单'))!.trigger('click')
await wrapper.find('[data-dialog="新增异常订单"] .dialog-confirm').trigger('click')
await flushPromises()
expect(resolveExceptionRelation).not.toHaveBeenCalled()
expect(createExceptionOrder).not.toHaveBeenCalled()
wrapper.unmount()
})
it('resolves and submits trimmed business fields', async () => {
canPermission.mockImplementation(code => code.endsWith(':create'))
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('新增异常订单'))!.trigger('click')
const dialog = wrapper.find('[data-dialog="新增异常订单"]')
await dialog.find('input[placeholder="请输入入仓码"]').setValue(' IN-1 ')
await dialog.find('select').setValue('RETURN')
await dialog.find('textarea').setValue(' 包裹破损 ')
await dialog.find('.dialog-confirm').trigger('click')
await flushPromises()
expect(resolveExceptionRelation).toHaveBeenCalledWith({ customerId: undefined, inboundCode: 'IN-1', lastMileOrderNo: undefined })
expect(createExceptionOrder).toHaveBeenCalledWith({
customerId: undefined, inboundCode: 'IN-1', lastMileOrderNo: 'LM-1',
exceptionType: 'RETURN', reason: '包裹破损', packageCount: undefined, skuCode: undefined,
})
wrapper.unmount()
})
})
@@ -0,0 +1,83 @@
import { defineComponent, h, type Component, type PropType } from 'vue'
export const DataTableStub = defineComponent({
name: 'DataTable',
props: { data: { type: Array as PropType<object[]>, default: () => [] } },
setup(props, { slots }) {
return () => h('div', { 'data-testid': 'data-table' }, [
slots['toolbar-actions']?.(),
...props.data.flatMap(row => slots.actions?.({ row }) || []),
])
},
})
export const AppDialogStub = defineComponent({
name: 'AppDialog',
props: { title: { type: String, required: true }, confirmText: { type: String, default: '确定' } },
emits: ['confirm'],
setup(props, { emit, slots }) {
return () => h('section', { 'data-dialog': props.title }, [
slots.default?.(),
h('button', { class: 'dialog-confirm', onClick: () => emit('confirm') }, props.confirmText),
])
},
})
export const ModelInputStub = defineComponent({
name: 'ElInput',
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: '' }, type: { type: String, default: 'text' } },
emits: ['update:modelValue', 'blur'],
setup(props, { attrs, emit }) {
return () => h(props.type === 'textarea' ? 'textarea' : 'input', {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
onBlur: () => emit('blur'),
})
},
})
export const ModelSelectStub = defineComponent({
name: 'ElSelect',
props: { modelValue: { type: [String, Number], default: '' } },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
}, slots.default?.())
},
})
export const OptionStub = defineComponent({
name: 'ElOption',
props: { value: { type: [String, Number], required: true }, label: { type: String, required: true } },
setup(props) { return () => h('option', { value: props.value }, props.label) },
})
export const ModelNumberStub = defineComponent({
name: 'ElInputNumber',
props: { modelValue: { type: Number, default: undefined } },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('input', {
type: 'number',
value: props.modelValue,
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value === '' ? undefined : Number(value))
},
})
},
})
export const commonPhase5Stubs: Record<string, Component> = {
DataTable: DataTableStub,
AppDialog: AppDialogStub,
QueryToolbar: defineComponent({ name: 'QueryToolbar', setup: () => () => h('div') }),
ElInput: ModelInputStub,
ElSelect: ModelSelectStub,
ElOption: OptionStub,
ElInputNumber: ModelNumberStub,
}