205 lines
14 KiB
TypeScript
205 lines
14 KiB
TypeScript
import ElementPlus from 'element-plus'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useAuthStore } from '@/core/auth/auth'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
import TruckOrderActions from '../components/TruckOrderActions.vue'
|
|
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
|
import { executeUsTruckCommand, getUsTruckOrder } from '../api/platformOverseasBusiness'
|
|
import { overseasBusinessPermissions as permissions } from '../permissions'
|
|
import type { LocalTransportDetail } from '../types'
|
|
import TruckOrderDetailView from './TruckOrderDetailView.vue'
|
|
|
|
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'TR-TEST' }, query: { customerId: '9007199254741999' } as Record<string, string> } }))
|
|
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
|
vi.mock('../api/platformOverseasBusiness', () => ({ getUsTruckOrder: vi.fn(), executeUsTruckCommand: vi.fn() }))
|
|
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
|
|
|
function order(overrides: Partial<LocalTransportDetail> = {}): LocalTransportDetail {
|
|
return {
|
|
id: '9007199254741001', orderNo: 'TR-TEST', customerId: '9007199254741999', fleetArrangement: null,
|
|
vehicleType: 'PALLET_15', origin: 'LA', originAddress: '起运仓地址', destination: 'ONT', destinationAddress: '目的仓地址',
|
|
originContactName: '发货人', originContactPhone: '10001', destinationContactName: '收货人', destinationContactPhone: '10002',
|
|
pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00',
|
|
palletCount: 0, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, packageCount: 20,
|
|
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
|
|
status: 'PENDING_REVIEW', version: 1, remark: '运输备注', createdBy: '7', createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00',
|
|
pallets: [], histories: [], ...overrides,
|
|
}
|
|
}
|
|
function mountDetail(manage = true) {
|
|
const pinia = createPinia(); setActivePinia(pinia)
|
|
const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '操作员', account: 'tester', roles: [] }
|
|
auth.permissions = [permissions.truckList, ...(manage ? [permissions.truckStatus] : [])]
|
|
useAccessPreviewStore().initializeAfterLogin()
|
|
return mount(TruckOrderDetailView, { global: { plugins: [pinia, ElementPlus], stubs: { teleport: true, BusinessDocumentFees: true, ElSelect: true, ElOption: true } } })
|
|
}
|
|
async function click(wrapper: VueWrapper, label: string) {
|
|
const button = wrapper.findAll('button').find(item => item.text() === label)
|
|
expect(button, `button ${label}`).toBeDefined()
|
|
await button!.trigger('click'); await flushPromises()
|
|
}
|
|
async function confirm(wrapper: VueWrapper) {
|
|
const form = wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' })
|
|
if (form.exists() && form.props('model').decision === 'APPROVE') form.props('model').warehouseId = '9'
|
|
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
|
}
|
|
const formModel = (wrapper: VueWrapper) => wrapper.findComponent(TruckOrderActions).findComponent({ name: 'ElForm' }).props('model')
|
|
|
|
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: '9', warehouseCode: 'WH-9', warehouseName: '海外仓' }] }) }))
|
|
|
|
enableAutoUnmount(afterEach)
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
|
routing.route.params = { orderId: 'TR-TEST' }; routing.route.query = { customerId: '9007199254741999' }
|
|
vi.mocked(getUsTruckOrder).mockResolvedValue(order())
|
|
})
|
|
afterEach(() => vi.unstubAllGlobals())
|
|
|
|
describe('truck review and fulfillment', () => {
|
|
it('scopes fee operations to the loaded truck document', async () => {
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(wrapper.findComponent(BusinessDocumentFees).props()).toMatchObject({
|
|
businessType: 'US_TRUCK', businessNo: 'TR-TEST', customerId: '9007199254741999', businessId: '9007199254741001',
|
|
})
|
|
})
|
|
|
|
it('displays saved pallet counts instead of the unused package count', async () => {
|
|
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ palletCount: 3, packageCount: 0 }))
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
const facts = new Map(wrapper.findAll('.truck-detail-fact').map(fact => [fact.get('dt').text(), fact.get('dd').text()]))
|
|
expect(facts.get('托数')).toBe('3')
|
|
expect(facts.has('件数')).toBe(false)
|
|
})
|
|
|
|
it('groups the business number, single status and actions in the shared detail header', async () => {
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
const header = wrapper.find('.detail-page-layout__header')
|
|
expect(header.find('.density-detail-title').text()).toBe('本土卡车订单 TR-TEST')
|
|
expect(header.find('.status-badge').text()).toBe('待审核')
|
|
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
|
|
expect(header.find('.truck-order-actions').text()).toContain('审核')
|
|
expect(header.find('.truck-order-actions').text()).not.toContain('取消订单')
|
|
await click(wrapper, '刷新订单')
|
|
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
|
|
await click(wrapper, '返回列表')
|
|
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
|
|
})
|
|
|
|
it('loads customer scoped documents and moves approval directly to dispatch', async () => {
|
|
let current = order()
|
|
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
|
|
const next = { ...current, version: current.version + 1 }
|
|
switch (operation.command) {
|
|
case 'review': next.status = 'PENDING_DISPATCH'; break
|
|
}
|
|
next.histories = [...current.histories, { operationType: operation.command === 'review' ? 'REVIEW' : operation.command.toUpperCase().replaceAll('-', '_'), fromStatus: current.status, toStatus: next.status, operatorId: '操作员7', remark: operation.payload.remark || null, createdAt: '2026-10-20T10:00:00' }]
|
|
current = next; return next
|
|
})
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(getUsTruckOrder).toHaveBeenCalledWith('TR-TEST', '9007199254741999')
|
|
expect(wrapper.text()).toContain('起运仓地址'); expect(wrapper.text()).toContain('审核与操作记录')
|
|
await click(wrapper, '审核并生成排车'); formModel(wrapper).remark = '资料核对通过'; await confirm(wrapper)
|
|
expect(wrapper.text()).toContain('排车审核'); expect(wrapper.text()).toContain('资料核对通过')
|
|
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘')
|
|
expect(current.status).toBe('PENDING_DISPATCH')
|
|
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1])
|
|
})
|
|
|
|
it('requires a rejection reason and displays the resulting rejected state', async () => {
|
|
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
|
formModel(wrapper).decision = 'REJECT'; await confirm(wrapper)
|
|
expect(executeUsTruckCommand).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写拒绝或取消原因')
|
|
formModel(wrapper).remark = '取送时间需重新确认'
|
|
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
|
|
await confirm(wrapper)
|
|
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
|
|
|
|
})
|
|
|
|
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
|
|
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
|
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车')
|
|
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
|
|
expect(wrapper.text()).toContain('订单版本冲突')
|
|
expect(wrapper.text()).not.toContain('TRUCK-409')
|
|
expect(wrapper.text()).not.toContain('请求 ID')
|
|
expect(wrapper.text()).not.toContain('可修改后重新提交')
|
|
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
|
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
|
|
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
|
await click(wrapper, '审核并生成排车'); await confirm(wrapper)
|
|
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
|
|
expect(wrapper.text()).toContain('排车审核')
|
|
})
|
|
|
|
it('uses delivery as the last flow node and omits point aliases', async () => {
|
|
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'COMPLETED' }))
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(wrapper.findAll('.truck-flow-label').map(node => node.text())).toEqual(['待审核', '排车审核', '取货', '派送中', '送达'])
|
|
expect(wrapper.findAll('.truck-flow li.done')).toHaveLength(5)
|
|
expect(wrapper.text()).not.toMatch(/B点|C点|B\/C/)
|
|
})
|
|
|
|
it('locks duplicate submission until the original response arrives', async () => {
|
|
let finish!: (value: LocalTransportDetail) => void
|
|
vi.mocked(executeUsTruckCommand).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
|
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核并生成排车'); await confirm(wrapper); await confirm(wrapper)
|
|
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
|
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
|
|
finish(order({ status: 'PENDING_DISPATCH', version: 2 })); await flushPromises()
|
|
expect(wrapper.text()).toContain('排车审核')
|
|
})
|
|
|
|
it('hides obsolete pallet actions after approval and keeps the standalone order read-only', async () => {
|
|
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ status: 'PENDING_DISPATCH', version: 2 }))
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
|
|
expect(wrapper.text()).not.toContain('登记实收'); expect(wrapper.text()).not.toContain('分配托盘'); expect(wrapper.text()).not.toContain('超托审核')
|
|
})
|
|
|
|
it('does not offer platform cancellation of a customer truck order', async () => {
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('取消订单')
|
|
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps list-only users read-only and guards direct attempts to emit submit', async () => {
|
|
const wrapper = mountDetail(false); await flushPromises()
|
|
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
|
wrapper.findComponent(TruckOrderActions).findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
|
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps pool sourced orders on the existing fleet fulfillment path', async () => {
|
|
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ fleetArrangement: {
|
|
sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-TEST', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST',
|
|
plannedDepartureAt: null, originWarehouseName: 'LA', destinationLabel: 'ONT', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null,
|
|
fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null,
|
|
deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '按拼车承运实际履约推进',
|
|
} }))
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(wrapper.text()).toContain('按拼车承运实际履约推进')
|
|
expect(wrapper.findComponent(TruckOrderActions).exists()).toBe(false)
|
|
expect(executeUsTruckCommand).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows a scoped error without requesting detail when customer is missing', async () => {
|
|
routing.route.query = {}; const wrapper = mountDetail(); await flushPromises()
|
|
expect(getUsTruckOrder).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少有效客户或订单信息')
|
|
})
|
|
|
|
it('retains error metadata and retries a failed detail read', async () => {
|
|
vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'DETAIL-409')).mockResolvedValueOnce(order())
|
|
const wrapper = mountDetail(); await flushPromises()
|
|
expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409')
|
|
await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
|
expect(wrapper.text()).toContain('TR-TEST'); expect(wrapper.text()).toContain('审核与操作记录')
|
|
})
|
|
})
|