fix(dispatch): align dispatch actions and native return workflows

This commit is contained in:
2026-10-18 16:37:37 +08:00
parent 4806f88d7c
commit a5a1976924
29 changed files with 854 additions and 150 deletions
@@ -1,5 +1,5 @@
import { createPinia } from 'pinia'
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import ElementPlus, { ElMessage, ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -7,6 +7,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import DispatchesView from './DispatchesView.vue'
@@ -40,15 +41,19 @@ function memoryStorage(): Storage {
}
enableAutoUnmount(afterEach)
afterEach(() => vi.restoreAllMocks())
afterAll(() => vi.unstubAllGlobals())
const allowedActions = { canEdit: true, editBlockReason: null, canCancel: true, cancelBlockReason: null,
canRemovePallet: true, removePalletBlockReason: null, canTransfer: true, transferBlockReason: null, outboundNo: null, outboundStatus: null }
describe('DispatchesView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listDispatches).mockReset().mockResolvedValue({
records: [{
id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅',
id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], actions: allowedActions, vehiclePlate: 'CA-100', driverName: '张师傅',
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9',
overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
@@ -60,7 +65,7 @@ describe('DispatchesView', () => {
vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-1', outboundStatus: 'WAIT_OUTBOUND', palletCount: 12, packageCount: 80, deliveries: [], replayed: false })
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
canPermission.mockReset().mockReturnValue(true)
@@ -248,6 +253,59 @@ describe('DispatchesView', () => {
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('uses server blocks even for a pending row and offers the original trip and outbound paths', async () => {
const result = await listDispatches({ page: 1, size: 10 })
Object.assign(result.records[0]!, {
actions: { ...allowedActions, canEdit: false, canCancel: false, canRemovePallet: false, canTransfer: false,
editBlockReason: '请先在车次 TR-A 中解除关联', cancelBlockReason: '请先在车次 TR-A 中解除关联',
removePalletBlockReason: '请先在车次 TR-A 中解除关联', transferBlockReason: '原出库已取消,请取消原排车后通过新预排重新安排',
outboundNo: 'OUT-A', outboundStatus: 'CANCELLED' },
workflow: { tripNo: 'TR-A', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } },
})
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(item => item.element.closest('.el-table__body-wrapper'))!
const visible = actions.props('actions').filter(action => action.visible !== false).map(action => action.key)
expect(visible.filter(key => ['edit', 'cancel', 'transfer'].includes(key))).toEqual([])
expect(visible).toEqual(expect.arrayContaining(['trip', 'outbound', 'detail']))
expect(wrapper.text()).toContain('原出库已取消')
actions.vm.$emit('action', 'edit'); actions.vm.$emit('action', 'transfer'); await flushPromises()
expect(wrapper.findAllComponents(AppDialog).filter(dialog => ['编辑排车', '转派送'].includes(String(dialog.props('title')))).every(dialog => !dialog.props('modelValue'))).toBe(true)
actions.vm.$emit('action', 'trip'); await flushPromises()
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
actions.vm.$emit('action', 'outbound'); await flushPromises()
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-A' } })
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
})
it('keeps write commands closed when action capabilities are missing', async () => {
const result = await listDispatches({ page: 1, size: 10 })
delete result.records[0]!.actions
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
expect(wrapper.findComponent(RowActions).props('actions').filter(action => action.visible !== false).map(action => action.key)).toEqual(['detail', 'bol'])
expect(wrapper.text()).toContain('操作资格暂未返回,请刷新列表')
})
it.each([
{ replayed: true, outboundStatus: 'WAIT_OUTBOUND', method: 'info' as const, message: '本次未重复生成' },
{ replayed: true, outboundStatus: 'CANCELLED', method: 'warning' as const, message: '请取消原排车后重新预排' },
])('reports transfer replay accurately for $outboundStatus', async result => {
const success = vi.spyOn(ElMessage, 'success'), notice = vi.spyOn(ElMessage, result.method)
vi.mocked(transferDispatchToDelivery).mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-OLD', palletCount: 1, packageCount: 2, deliveries: [], replayed: result.replayed, outboundStatus: result.outboundStatus })
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [] })
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
expect(notice).toHaveBeenCalledWith(expect.stringContaining(result.message))
expect(success).not.toHaveBeenCalledWith('已生成出库单和派送单')
})
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
@@ -296,17 +354,18 @@ describe('DispatchesView', () => {
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
})
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
it('gates edit, cancel, pallet removal and transfer with independent permissions and server capabilities', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
expect(source).toContain('未生成出库单前可移除托盘')
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canEdit === true')
expect(source).toContain('access.can(permission.dispatchCancel) && row.actions?.canCancel === true')
expect(source).toContain('access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true')
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true')
expect(source).toContain('!canRemovePallet(editRow.value)')
expect(source).toContain('removeDispatchItem')
expect(source).not.toContain('托盘箱唛确认')
expect(source).not.toContain('palletCartonMarks')