fix(dispatch): release failed request state and return rejected reviews

This commit is contained in:
2026-10-22 10:10:32 +08:00
parent ef85ed30e4
commit 090431b409
18 changed files with 373 additions and 536 deletions
@@ -8,9 +8,8 @@ import DataTable from '@/shared/components/DataTable.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { cancelTransport, getTransport } from '../api/transportTasks'
import { command, detail, listRow, page, reviews } from '../dispatchTestFixtures'
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => true))
@@ -104,13 +103,13 @@ it('keeps return-to-review on the loading list with its service gate and permiss
expect(denied.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
denied.unmount()
})
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('PC-OTHER 的确认装车完成结果尚待核对')
await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
const dialog = wrapper.get('dispatch-workflow-dialog-stub')
expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
it('does not let an old pending command hide or block filtered list operations',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action:'LOADING_COMPLETE',attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
expect(wrapper.text()).not.toContain('结果尚待核对')
expect(wrapper.findAll('button').some(button=>button.text()==='核对原请求')).toBe(false)
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('PC/1');wrapper.unmount()
})
it('centralizes cargo changes in smart dispatch and never exposes row add/remove actions', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } })]))
@@ -156,13 +155,13 @@ it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart dispatch', async action => {
useDispatchCommandRecovery(() => identity.id).begin('PC-OTHER', action, { recordVersion: 0, idempotencyKey: 'cargo-key' }, action === 'REMOVE_CARGO' ? { itemId: '17' } : undefined)
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
wrapper.unmount()
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s requests while allowing smart dispatch to open normally',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action,attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.findAll('button').find(button=>button.text()==='智能排车')!.trigger('click')
expect(wrapper.getComponent({name:'SmartDispatchDialog'}).props('modelValue')).toBe(true)
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
expect(wrapper.text()).not.toContain('结果尚待核对');wrapper.unmount()
})
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
can.mockReturnValue(false)
@@ -397,16 +396,12 @@ it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
it('describes a proven command awaiting its GET as completed instead of unknown', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'known-key' })
recovery.pending.value!.attempted = true
recovery.pending.value!.result = command('SUBMIT_REVIEW', 'known-key')
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
it('does not restore stored failed commands or expose replay controls in the list',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'SUBMIT_REVIEW',attempted:true,result:{recordVersion:1},body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
expect(wrapper.text()).not.toContain('结果尚待核对')
await wrapper.findAll('button').find(button => button.text() === '刷新页面资料')!.trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
expect(wrapper.text()).not.toContain('刷新页面资料')
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
@@ -433,3 +428,17 @@ it('opens the exception form from the dispatching row and hides it without permi
expect((deniedRow.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain('MARK_EXCEPTION')
denied.unmount()
})
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{
const allowed={allowed:true,blockCode:null,blockReason:null}
vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})]))
const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
row.vm.$emit('action',action);await flushPromises()
vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'}))
wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
wrapper.unmount()
})
@@ -7,7 +7,6 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -28,10 +27,8 @@ import { cancelTransport, getTransport, transportLabels, type TransportType } fr
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
@@ -177,12 +174,19 @@ async function cancelPickup(row: DispatchListRow) {
finally { cancellingPickup.value = '' }
}
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
function recoverPending() {
const command = recovery.pending.value
if (!command) return
if (['CREATE', 'ADD_CARGO', 'REMOVE_CARGO'].includes(command.action)) { createOpen.value = true; return }
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
async function handleSaved() {
rejectionError.value = null
if (actionNo.value && ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(action.value)) {
try {
const current = await getDispatch(actionNo.value)
if (current.dispatchNo === actionNo.value && current.stage === 'BEFORE_LOADING' && ['DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(current.reviewState)) {
stage.value = 'BEFORE_LOADING'; section.value = 'VEHICLE_INFO'; page.value = 1
model.value.status = 'ACTIVE'
if (model.value.vehicleStatus) model.value.vehicleStatus = 'WAIT_DEPARTURE'
}
} catch (cause) { mutationError.value = getApiErrorDetails(cause, '审核已处理,当前排车状态读取失败,请刷新') }
}
await load()
}
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value || !canDownloadBol(row)) return
@@ -204,7 +208,6 @@ onBeforeUnmount(() => { sequence++ })
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
@@ -271,7 +271,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).not.toContain('一次排车只能装同一客户')
expect(dialog).not.toContain('ensureDraftToken')
expect(dialog).not.toContain('preArrangeDispatch')
expect(dialog).toContain('原请求结果尚待核对')
expect(dialog).not.toContain('原请求结果尚待核对')
expect(dialog).not.toContain('sessionStorage.setItem')
expect(dialog).not.toContain('v-model="route"')
const vehicleSpecs = source('../dispatchVehicleSpecs.ts')
expect(vehicleSpecs).toContain("label: '大车(30托)'")