发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21

Merged
admin merged 100 commits from release/test into main 2026-10-23 10:51:28 +08:00
18 changed files with 373 additions and 536 deletions
Showing only changes of commit 090431b409 - Show all commits
@@ -139,7 +139,7 @@ async function executeStep() {
if (command.action === 'CONVERT') {
const converted = value as ConvertedDraft
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
request.converted = converted
} else {
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
const identity = vi.hoisted(() => ({ id: '' }))
@@ -25,11 +25,14 @@ it('honors native stage/gate and permissions and rejects a different returned bu
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
})
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
it('ignores an old add command without blocking the calendar or another action',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=render();await flushPromises()
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(await guard()).toBe(true)
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
wrapper.unmount()
})
it('retries lookup as a read and keeps the original detail navigation', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
@@ -50,22 +53,12 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
wrapper.unmount()
})
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
recovery.pending.value!.attempted = true
const completed: CommandResult = {
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
}
recovery.pending.value!.result = completed
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
await button(wrapper, '刷新加货资料').trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
it('does not offer a stored command replay when opening the form again',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}}))
const wrapper=render();await flushPromises()
expect(wrapper.text()).not.toContain('刷新加货资料')
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
wrapper.unmount()
})
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
@@ -31,9 +31,6 @@ async function load() {
finally { if (current === generation) loading.value = false }
}
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
function recover() {
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
}
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
watch(() => props.dispatchNo, load, { immediate: true })
@@ -47,7 +44,6 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
</section>
+1 -1
View File
@@ -35,7 +35,7 @@
## 履约硬边界
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、幂等键和完整载荷;前端仅请求进行中防连点,成功或失败均立即结束请求态,不持久化未确认请求、不重放旧请求、不因失败锁住后续操作。失败保留表单并读取当前事实,用户下一次点击按当前版本和输入新建请求;成功后另读当前事实。
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
@@ -109,22 +109,22 @@ describe('dispatch workflow native layout', () => {
expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
it('keeps failed input editable and permits a new request and another target',async()=>{
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认','S0503',200,null,'workflow-request')).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
const {wrapper,dialog}=await render('EDIT')
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm');await flushPromises()
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(dialog.text()).toContain('workflow-request')
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
expect(original.remark).toBe('真实备注')
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
dialog.vm.$emit('confirm');await flushPromises()
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
expect(next.vehicle?.plate).toBe('CA-CHANGED');expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
await wrapper.setProps({dispatchNo:'PC/2',action:'CANCEL'});await flushPromises()
expect(dialog.props('title')).toContain('PC/2')
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
@@ -143,7 +143,7 @@ describe('dispatch workflow native layout', () => {
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => {
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and permits a new decision after failure', async action => {
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
vi.mocked(review).mockRejectedValueOnce(new Error('结果未知'))
const { dialog } = await render(action)
@@ -153,10 +153,10 @@ describe('dispatch workflow native layout', () => {
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
expect(dialog.props('showCancel')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false)
await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click')
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click')
await flushPromises()
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
expect(vi.mocked(review).mock.calls[1]![1].decision).toBe('APPROVED');expect(vi.mocked(review).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => {
can.mockReturnValue(false)
@@ -169,7 +169,7 @@ describe('dispatch workflow native layout', () => {
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it('uploads all three photos during resource maintenance and retries the exact original edit', async () => {
it('retains all three uploaded photos and permits a fresh vehicle save after failure', async () => {
can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT', undefined, true)
@@ -191,16 +191,16 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises()
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)})
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
const { wrapper, dialog } = await render(action)
const value = detail({ actions })
value.vehicleInfo.vehicle!.evidenceFileIds = ['401']
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
@@ -208,9 +208,6 @@ describe('dispatch workflow native layout', () => {
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
expect(fields[0]!.props('modelValue')).toEqual(['401'])
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
@@ -40,19 +40,22 @@ it('accepts actual evidence and a matching confirmed manifest while refusing an
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('requires a rejection reason and retains the original %s decision after an unknown result', async action => {
const success = vi.spyOn(ElMessage, 'success')
const reviewApi = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
const { wrapper, state } = await open(action); state.form.decision = 'REJECTED'
await state.submit(); expect(reviewApi).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(reviewApi).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'; state.form.decision = 'APPROVED'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command(action, key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
it.each(['DISPATCH_REVIEW','LOADING_REVIEW'] as const)('allows editing the %s decision after a failed request',async action=>{
const reviewApi=action==='DISPATCH_REVIEW'?api.reviewDispatch:api.reviewDispatchLoading
const {wrapper,state}=await open(action);state.form.decision='REJECTED'
await state.submit();expect(reviewApi).not.toHaveBeenCalled()
state.form.reason='预约未核实';vi.mocked(reviewApi).mockRejectedValueOnce(new Error('unknown result'))
await state.submit()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
state.form.reason='重新确认';state.form.decision='APPROVED'
vi.mocked(reviewApi).mockImplementationOnce(async(_,body)=>command(action,body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
await state.submit()
const calls=vi.mocked(reviewApi).mock.calls
expect(calls).toHaveLength(2)
expect(calls[1]![1].decision).toBe('APPROVED')
expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('shows the preserved rejection while customer service edits vehicle information', async () => {
const { wrapper, state } = await open('EDIT')
@@ -81,7 +84,7 @@ it('preserves external destinations and retries route EDIT through its original
await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1]).toMatchObject({destinationKeys:first.destinationKeys,reason:first.reason}); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1].idempotencyKey).not.toBe(first.idempotencyKey); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
})
it('refuses a route that drops an authoritative destination', async () => {
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
@@ -133,37 +136,29 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps an unknown original command when its later replay is rejected instead of treating it as a fresh rejection', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown transport outcome')).mockRejectedValueOnce(new ApiResponseError('当前已提交', 'A0409', 409))
it('ends every failed request and displays a later business rejection without replaying',async()=>{
const {wrapper,state}=await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('当前已提交','A0409',409))
await state.submit()
const original = structuredClone(vi.mocked(api.submitDispatchReview).mock.calls[0]![1])
expect(wrapper.text()).toContain('原操作结果尚待核对')
vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('未找到命令', 'A0404', 404))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenNthCalledWith(2, 'PC/1', original)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.body).toEqual(original)
expect(wrapper.emitted('rejected')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(api.getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('describes a proven success with a failed GET as completed and retries only its read', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit(); await flushPromises()
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result).toMatchObject({ action: 'SUBMIT_REVIEW', recordVersion: 1 })
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.text()).not.toContain('核对并重试原请求')
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
await state.submit();await flushPromises()
const calls=vi.mocked(api.submitDispatchReview).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(api.getDispatchCommand).not.toHaveBeenCalled();expect(wrapper.text()).toContain('当前已提交');wrapper.unmount()
})
it('unlocks when a successful write is followed by a failed read',async()=>{
const {wrapper,state}=await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementation(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit();await flushPromises()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
@@ -179,23 +174,13 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and d
expect(wrapper.emitted('saved')).toBeUndefined()
} finally { wrapper.unmount() }
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s command for recovery in smart dispatch', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/ORIGINAL', action, { recordVersion: '9007199254740993', idempotencyKey: 'original-key', reason: 'original reason', cargo: [{ sourceVersion: 'original-source' }] }, action === 'REMOVE_CARGO' ? { itemId: 'original-item' } : {})
recovery.pending.value!.attempted = true
const original = JSON.parse(JSON.stringify(recovery.pending.value))
const { wrapper, state } = await open('EDIT')
try {
expect(wrapper.text()).toContain('请从智能排车恢复原请求')
await state.submit(); await state.retryError()
expect(recovery.pending.value).toEqual(original)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('rejected')).toBeUndefined()
} finally { wrapper.unmount() }
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when opening another operation',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
const {wrapper,state}=await open('EDIT')
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
})
it('requires an exception type and submits optional direction, description and images once', async () => {
@@ -249,33 +234,30 @@ it('rechecks review eligibility after save and leaves the saved editor open if b
expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1)
expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('retains the follow-up review intent through an unknown save and does not save twice on recovery', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save outcome unknown'))
it('allows a fresh save and review after a failed save without persisting review intent',async()=>{
const {wrapper,state}=await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.submitReviewAfterSave).toBe(true)
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${identity.id}`)!).submitReviewAfterSave).toBe(true)
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce(command('EDIT', String(pending.body.idempotencyKey)))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull(); wrapper.unmount()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
await state.submit(true)
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledOnce()
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('recovers an unknown review submission without saving the vehicle again', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review outcome unknown'))
it('allows a new save-and-review click after review submission fails',async()=>{
const {wrapper,state}=await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementation(async(_,body)=>command('EDIT',body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1,actions:{EDIT:{allowed:true,blockCode:null,blockReason:null},SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}}))
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.action).toBe('SUBMIT_REVIEW'); expect(pending.body).toMatchObject({ recordVersion: 1, remark: null })
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce({ ...command('SUBMIT_REVIEW', String(pending.body.idempotencyKey)), recordVersion: 2 })
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
await state.submit(true)
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('persists the explicit publication choice with the existing vehicle save', async () => {
@@ -166,26 +166,23 @@ async function submit(submitReviewAfterSave = false) {
if (value.dispatchNo === currentNo.value) detail.value = value
return value
}
let result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
let result = await recovery.execute(send, readCurrent)
if (!result) return
if (continueReview) {
emit('saved')
if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料')
recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null })
editorRequestKey = String(pending.value?.body.idempotencyKey || '')
result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
result = await recovery.execute(send, readCurrent)
if (!result) return
}
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
// Recovery clears only a fresh, definite rejection. A later refusal must not erase an unknown write.
if (!pending.value && isDefiniteDispatchRejection(cause)) {
editorRequestKey = ''
emit('rejected', error.value)
await refreshFacts()
}
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
editorRequestKey = ''
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
if (!(cause instanceof DispatchInputError)) await refreshFacts()
} finally { submitting.value = false }
}
async function refreshFacts() {
@@ -214,12 +211,11 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-alert v-if="pending && !cargoAction" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
@@ -268,7 +264,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template v-if="vehicleEditor" #footer="{ close }">
<el-button :disabled="submitting || busy || uploadingEvidence" @click="close">取消</el-button>
<el-button v-if="!pending" :disabled="submitting || loading || uploadingEvidence || !allowed" @click="submit()">保存</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">{{ pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '提交审核' }}</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">提交审核</el-button>
</template>
<template v-else-if="isReview && !pending" #footer>
<div class="workflow-review-actions">
@@ -186,21 +186,19 @@ describe('new dispatch native layout', () => {
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
})
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
it('unlocks after a failed first container and accepts a new cargo click',async()=>{
const empty:DispatchDraft={...draft(),containers:[],sources:[]}
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
const { dialog } = await render(); await selectWarehouse(dialog)
await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original)
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({...empty,recordVersion:1,containers:[{draftContainerId:'first-group',sequenceNo:1,capacity:30,sourceKeys:[]}]})
const {dialog}=await render();await selectWarehouse(dialog);await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog,'建单方式').getComponent({name:'ElRadioGroup'}).props('disabled')).toBe(false)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
const calls=vi.mocked(api.createDispatchDraftContainer).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
@@ -231,7 +229,7 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
})
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
@@ -241,7 +239,7 @@ describe('new dispatch native layout', () => {
expect(listVehicles).not.toHaveBeenCalled()
expect(listDrivers).not.toHaveBeenCalled()
})
it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await field(dialog, '备注').get('input').setValue('第一组')
@@ -260,12 +258,12 @@ describe('new dispatch native layout', () => {
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
expect(wrapper.emitted('created')).toBeUndefined()
expect(view(dialog).text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
it('prevents active double clicks but unlocks editing and starts a new conversion after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
const { wrapper, dialog } = await render(); await restore(dialog)
@@ -278,12 +276,13 @@ describe('new dispatch native layout', () => {
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('重试原请求')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('keeps all current-page orders and reveals complete pallet source details only after selecting an order', async () => {
const rows = Array.from({ length: 20 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
@@ -344,40 +343,27 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
it.each(['rejection','unknown'] as const)('unlocks an empty-car deletion after %s and accepts another click',async failure=>{
const empty={...draft(),sources:[],containers:[{...draft().containers[0]!,sourceKeys:[]}]}
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
const { dialog } = await render(); await restore(dialog)
await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
if (failure === 'rejection') {
expect(view(dialog).text()).toContain('仅空容器可以删除')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
} else {
const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
expect(view(dialog).text()).toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
}
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure==='rejection'?new ApiResponseError('仅空容器可以删除','A0409',409):new Error('network unknown'))
const {dialog}=await render();await restore(dialog)
await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull()
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({...empty,recordVersion:1,containers:[]})
await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
const calls=vi.mocked(api.deleteDispatchDraftContainer).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
})
it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
it('clears a saved definite rejection on reopen without restoring the failed request', async () => {
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it('keeps creation unavailable without its write permission', async () => {
@@ -183,24 +183,20 @@ describe('three-column cargo planning through visible controls', () => {
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
})
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
lostReply = step
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
it.each(['CREATE','CONTAINER'] as const)('unlocks after a failed %s and permits a new cargo click',async step=>{
lostReply=step
const dialog=await render();await warehouseSelected(dialog);await join(dialog,'PALLET-1')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
const before = step === 'CREATE' ? clone(vi.mocked(api.createDispatchDraft).mock.calls[0]![0]) : clone(vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0])
await button(dialog, '重试原请求').trigger('click'); await flushPromises()
if (step === 'CREATE') {
expect(api.createDispatchDraft).toHaveBeenNthCalledWith(2, before)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled()
} else {
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, before)
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull()
await join(dialog,'PALLET-1')
expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
if(step==='CREATE'){
const calls=vi.mocked(api.createDispatchDraft).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
}else{
expect(api.createDispatchDraftContainer).toHaveBeenCalledOnce();expect(api.createDispatchDraft).toHaveBeenCalledOnce()
}
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
await join(dialog, 'PALLET-1')
expect(api.createDispatchDraft).toHaveBeenCalledTimes(step === 'CREATE' ? 2 : 1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CONTAINER' ? 2 : 1)
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('keeps the preparation chain frozen while CREATE is in flight', async () => {
let resolve!: (value: DispatchDraft) => void
@@ -7,7 +7,6 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
@@ -33,15 +32,13 @@ it('switches to existing cargo inside the same dialog without creating a draft o
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('opens %s recovery in existing cargo without reading creation drafts', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', action, { recordVersion: 0, idempotencyKey: 'existing-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
expect(api.listDispatchDrafts).not.toHaveBeenCalled()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(recovery.pending.value?.body.idempotencyKey).toBe('existing-key')
wrapper.unmount()
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
})
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
@@ -68,7 +65,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
})
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in conversion and refreshes the batch version on a new click', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
@@ -80,7 +77,10 @@ it.each([0, '0', '9007199254740993'])('transmits original authoritative batch ve
const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
await state.submit()
const next=vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]
expect(next.idempotencyKey).not.toBe(request.idempotencyKey)
expect(next.groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe('9007199254740994')
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
@@ -113,11 +113,11 @@ it('restores an explicit draft and submits complete server source versions in ea
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
it('releases an unknown conversion and starts a fresh request on the next click', async () => {
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].idempotencyKey); wrapper.unmount()
})
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
@@ -127,7 +127,7 @@ it('accepts a partial candidate page without requiring the filtered whole set',
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps planning remarks independent with unassigned resources and reuses a proven conversion after a detail read fails', async () => {
it('keeps planning remarks independent with unassigned resources and permits resubmission after a failed detail read', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
@@ -137,16 +137,19 @@ it('keeps planning remarks independent with unassigned resources and reuses a pr
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('never sends an existing departure command through the empty creation transport', async () => {
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
it('clears an old departure request without blocking new empty creation',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'DEPART',attempted:true,body:{idempotencyKey:'old'}}))
vi.mocked(api.createDispatch).mockImplementation(async body=>command('CREATE',body.idempotencyKey))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
state.mode='empty';await flushPromises();await state.submit()
expect(api.createDispatch).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
})
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
@@ -17,7 +17,7 @@ import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
@@ -28,12 +28,9 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[] }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
// Only a recorded first-attempt rejection proves that no write occurred.
let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
if (owner) sessionStorage.removeItem(storageKey)
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
@@ -51,11 +48,10 @@ const currentSettings = computed(() => {
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || recovery.busy.value)
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
@@ -113,25 +109,17 @@ function changeSize(value: number) { size.value = value; void search() }
let generation = 0
let candidateFilterKey = ''
function persist() {
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
}
async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
if (recovery.pending.value && ['ADD_CARGO', 'REMOVE_CARGO'].includes(recovery.pending.value.action)) workspace.value = 'existing'
else if (pending.value || recovery.pending.value?.action === 'CREATE') workspace.value = 'create'
else if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (workspace.value === 'existing' || !canCreate.value) return
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
const resumeToken = draft.value?.draftToken
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
rejectedDraftToken = ''
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
@@ -196,16 +184,17 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
body = { ...common, groups } satisfies ConvertDraft
}
}
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}), attempted: false, rejected: false }; persist()
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}) }
}
await retryDraft()
return true
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次排车操作失败,请重试'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
const command = pending.value, previousAttempt = command.attempted
saving.value = true; command.attempted = true; persist()
const command = pending.value
saving.value = true
try {
const body = command.body
let result: DispatchDraft | ConvertedDraft
@@ -216,52 +205,55 @@ async function retryDraft() {
case 'UNLOCK': result = await api.unlockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'RELEASE': result = await api.releaseDispatchDraft(body as unknown as VersionCommand & { draftToken: string }); break
case 'DELETE': result = await api.deleteDispatchDraftContainer(String(body.draftToken), command.containerId!, body as unknown as VersionCommand & { reason: string }); break
case 'CONVERT': result = command.result || await api.convertDispatchDraft(body as unknown as ConvertDraft); break
case 'CONVERT': result = await api.convertDispatchDraft(body as unknown as ConvertDraft); break
}
if (body.draftToken && result.draftToken !== body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致')
if (command.action === 'CONVERT') {
const converted = result as ConvertedDraft, expected = (body as unknown as ConvertDraft).groups
if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与原草稿容器不一致,请核对原请求')
command.result = converted; persist()
if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与本次草稿容器不一致,请刷新当前排车')
await Promise.all(converted.vehicles.map(async row => validateCurrentDispatch(await api.getDispatch(row.dispatch.dispatchNo), row.dispatch)))
pending.value = null; persist(); emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果尚未核对,请保留原请求重试')
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果异常,请刷新后重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
if (draft.value) warehouseId.value = draft.value.warehouseId
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { // A later rejection cannot erase an earlier unknown write result.
if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
persist(); throw cause }
finally { saving.value = false }
} catch (cause) {
// 请求失败后读取服务器最新草稿,保留本地时间和备注,后续点击按当前版本重新提交。
if (command.body.draftToken) {
try {
const saved = await api.listDispatchDrafts()
drafts.value = saved; draft.value = saved.find(row => row.draftToken === command.body.draftToken) || null
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
} catch { /* 刷新失败只保留原错误,不锁住其他操作。 */ }
}
throw cause
} finally { pending.value = null; saving.value = false }
}
async function submit() {
if (workspace.value === 'existing') return
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
if (pending.value) { await retryDraft(); return }
if (recovery.pending.value && recovery.pending.value.action !== 'CREATE') throw new DispatchInputError('另一次排车操作尚待核对,请从列表的原请求恢复入口继续')
if (!recovery.pending.value && mode.value === 'cargo') { await draftAction('CONVERT'); return }
if (!recovery.pending.value) { if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓'); recovery.begin('', 'CREATE', creation([])) }
const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch, api.getDispatchCommand)
if (mode.value === 'cargo') { await draftAction('CONVERT'); return }
if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
recovery.begin('', 'CREATE', creation([]))
const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch)
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
}
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -269,8 +261,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeUndefined()
})
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
@@ -147,28 +147,23 @@ it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as con
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
})
it('auto-opens the original removal command after remount and checks its result without resending', async () => {
const wrapper = await open()
await button(wrapper, '移除货物').trigger('click')
await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
vi.mocked(api.removeDispatchItem).mockRejectedValue(new Error('unknown result'))
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
const original = vi.mocked(api.removeDispatchItem).mock.calls[0]!
wrapper.unmount()
const resumed = await open(null)
expect(resumed.text()).toContain('PC/1')
const recovery = useDispatchCommandRecovery(() => identity.id)
expect(recovery.pending.value).toMatchObject({ dispatchNo: 'PC/1', itemId: '9007199254740993', body: original[2] })
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key))
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
await button(resumed, '核对并重试原请求').trigger('click'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledTimes(1)
expect(recovery.pending.value).toBeNull()
expect(resumed.emitted('saved')).toHaveLength(1)
it('unlocks a failed removal and accepts an updated reason on a fresh request',async()=>{
const wrapper=await open()
await button(wrapper,'移除货物').trigger('click');await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown result'))
await button(wrapper,'确认移除').trigger('click');await flushPromises()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect((wrapper.get('textarea[aria-label="移除原因"]').element as HTMLTextAreaElement).value).toBe('原移除原因')
await wrapper.get('textarea[aria-label="移除原因"]').setValue('重新安排')
vi.mocked(api.removeDispatchItem).mockImplementationOnce(async(_,__,body)=>command('REMOVE_CARGO',body.idempotencyKey))
await button(wrapper,'确认移除').trigger('click');await flushPromises()
const calls=vi.mocked(api.removeDispatchItem).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![2].reason).toBe('重新安排')
expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
})
it('keeps a confirmed result pending if the subsequent manifest cannot be read', async () => {
it('unlocks a completed request when its follow-up manifest read fails', async () => {
const wrapper = await open()
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
@@ -176,11 +171,11 @@ it('keeps a confirmed result pending if the subsequent manifest cannot be read',
return command('ADD_CARGO', body.idempotencyKey)
})
await add(wrapper)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result?.action).toBe('ADD_CARGO')
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
await button(wrapper, '刷新当前资料').trigger('click'); await flushPromises()
await button(wrapper, '刷新本车').trigger('click'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
})
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
@@ -31,7 +31,6 @@ const reading = ref(false), listing = ref(false), preparing = ref(false), stale
const removing = ref<DispatchCargoItem | null>(null), reason = ref('')
let generation = 0, listGeneration = 0, alive = true
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
const pendingMessage = computed(() => !cargoPending.value ? '另一个排车操作尚待核对,请返回原操作入口处理。' : pending.value?.result ? '货物调整已完成,请刷新当前资料;不会重复提交。' : '原货物调整结果尚待核对,保留原版本和原请求内容。')
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
const loading = computed(() => reading.value || candidatePage.loading.value)
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
@@ -176,7 +175,7 @@ async function execute() {
const result = await recovery.execute(send, async target => {
const facts = await readFacts(target)
return facts.current
}, api.getDispatchCommand)
})
if (!result || !alive) return
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
emit('saved')
@@ -185,7 +184,16 @@ async function execute() {
} catch (cause) {
if (!alive) return
stale.value = true
report(cause, pending.value?.result ? '货物已调整,当前资料待刷新' : pending.value ? '操作结果尚未确认,请核对原请求' : '本次货物调整未通过,请刷新当前资料')
report(cause, '本次货物调整失败,请刷新当前资料后重试')
const failure = error.value
try {
const facts = await readFacts(no)
if (!alive || selectedNo.value !== no) return
detail.value = facts.current; items.value = facts.cargo; stale.value = false
if (removing.value && !facts.cargo.items.some(row => row.dispatchItemId === removing.value?.dispatchItemId)) removing.value = null
await loadCandidates()
} catch { /* 读取失败保留原业务错误,仍允许切换排车或手动刷新。 */ }
finally { error.value = failure }
}
}
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
@@ -200,8 +208,6 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
<template>
<div class="existing-cargo density-dialog-sections">
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
<div class="existing-cargo-query">
@@ -3,154 +3,65 @@ import { ApiResponseError } from '@/shared/api/http'
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
import { command } from './dispatchTestFixtures'
beforeEach(() => sessionStorage.clear())
it('rejects a loading result that has not completed loading', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'LOADING' }), read, vi.fn())).rejects.toThrow('装车完成')
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value?.result).toBeNull()
expect(recovery.pending.value?.body.idempotencyKey).toBe('key')
it.each([new Error('network unknown'), new ApiResponseError('server failure','S0500',200), new ApiResponseError('gateway timeout','HTTP_504',504), new ApiResponseError('version changed','A0409',200)])('releases failed $message and permits a new request with edited input', async failure => {
const owner=crypto.randomUUID(), state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first',remark:'original'})
await expect(state.execute(vi.fn().mockRejectedValue(failure),vi.fn())).rejects.toBe(failure)
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
const reopened=useDispatchCommandRecovery(()=>owner)
reopened.begin('PC/1','EDIT',{recordVersion:2,idempotencyKey:'second',remark:'edited'})
const send=vi.fn().mockResolvedValue({...command('EDIT','second'),recordVersion:3})
await reopened.execute(send,vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:3}))
expect(send.mock.calls[0]![0].body).toEqual({recordVersion:2,idempotencyKey:'second',remark:'edited'})
expect(reopened.pending.value).toBeNull()
})
it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('LOADING_COMPLETE'))
const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
await recovery.execute(send, read, find)
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
expect(recovery.pending.value).toBeNull()
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('clears saved failed requests without restoring them',saved=>{
const owner=crypto.randomUUID()
sessionStorage.setItem(`dispatch-command:${owner}`,saved)
const state=useDispatchCommandRecovery(()=>owner)
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('keeps a proven result across GET failure and does not send the command a second time', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
await reopened.execute(send, read, find)
expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
expect(reopened.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
it('prevents double clicks only during the active request',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first'})
let finish!:(result:ReturnType<typeof command>)=>void
const send=vi.fn().mockReturnValue(new Promise(done=>{finish=done}))
const read=vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:1})
const running=state.execute(send,read),another=useDispatchCommandRecovery(()=>owner)
expect(another.busy.value).toBe(true)
expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).toThrow('正在提交')
await another.execute(send,read);expect(send).toHaveBeenCalledTimes(1)
finish(command('EDIT','first'));await running
expect(another.pending.value).toBeNull();expect(another.busy.value).toBe(false)
expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).not.toThrow()
})
it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
expect(second.pending.value?.dispatchNo).toBe('PC/1')
expect(() => second.begin('', 'CREATE', { idempotencyKey: 'new' })).toThrow('原请求')
it('unlocks even when a successful write is followed by a failed detail read',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT')),vi.fn().mockRejectedValue(new Error('read failed')))).rejects.toThrow('read failed')
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})
it('does not discard an earlier unknown write on a later business rejection', async () => {
const recovery = useDispatchCommandRecovery(() => crypto.randomUUID())
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })).toThrow('账号')
const owner = crypto.randomUUID(), stable = useDispatchCommandRecovery(() => owner)
stable.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
expect(find).toHaveBeenCalledWith('PC/1', 'key')
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key' })
expect(stable.pending.value?.rejected).toBe(false)
const saved = JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!)
expect(saved).toMatchObject({ attempted: true, rejected: false, result: null, body: { recordVersion: 0, idempotencyKey: 'key' } })
expect(useDispatchCommandRecovery(() => owner).pending.value).toEqual(saved)
it('keeps loading-stage and response identity checks without retaining failed requests',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner),read=vi.fn()
state.begin('PC/1','LOADING_COMPLETE',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue({...command('LOADING_COMPLETE'),stage:'LOADING'}),read)).rejects.toThrow('装车完成')
expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT','different')),read)).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
})
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue(command('LOADING_COMPLETE', 'different')), read, vi.fn())).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
it('preserves large version precision and unlocks after a mismatched detail',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:'9007199254740992',idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue({...command('EDIT'),recordVersion:'9007199254740993'}),vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:'9007199254740992'}))).rejects.toThrow('详情版本早于')
expect(state.pending.value).toBeNull()
})
it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), recordVersion: '9007199254740993' }), find = vi.fn()
await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
expect(recovery.pending.value?.result).not.toBeNull()
await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
})
it('treats an internal server error as unknown instead of authorizing a new command', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
})
it.each(['400', '401', '403', '404', '409', '410', '422'].flatMap(code => [code, `A0${code}`, `HTTP_${code}`]))(
'clears the first definite rejection %s from memory and storage and preserves its original error', async code => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'rejected-key' })
const rejection = new ApiResponseError('明确拒绝', code)
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn(), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(send).toHaveBeenCalledTimes(1)
expect(read).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled()
expect(recovery.pending.value).toBeNull(); expect(recovery.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value).toBeNull()
expect(() => reopened.begin('PC/1', 'EDIT', { recordVersion: 2, idempotencyKey: 'fresh-key' })).not.toThrow()
},
)
it('cleans an explicitly rejected legacy browser record when its scope is restored', () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
sessionStorage.setItem(key, JSON.stringify({ dispatchNo: 'PC/1', action: 'SUBMIT_REVIEW',
body: { recordVersion: 0, idempotencyKey: 'terminal-key' }, attempted: true, rejected: true, result: null }))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 1, idempotencyKey: 'new-key' })).not.toThrow()
})
it('cleans a terminal rejected record already shared by dialogs in the same scope', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'terminal-key' })
first.pending.value!.attempted = true; first.pending.value!.rejected = true
sessionStorage.setItem(`dispatch-command:${owner}`, JSON.stringify(first.pending.value))
const second = useDispatchCommandRecovery(() => owner)
expect(second.pending).toBe(first.pending)
expect(first.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('retains a restored unknown record without explicit rejection proof and reconciles its original key', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'EDIT', body: { recordVersion: 0, idempotencyKey: 'unknown-key', remark: 'original' },
attempted: true, rejected: false, result: null }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toEqual(original)
const rejection = new ApiResponseError('later rejection', 'A0409')
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn()
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(find).toHaveBeenCalledWith('PC/1', 'unknown-key')
expect(send.mock.calls[0]![0].body).toEqual(original.body)
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
})
it('only rereads a restored proven result and keeps it when the detail read still fails', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'LOADING_COMPLETE', body: { recordVersion: 0, idempotencyKey: 'key' },
attempted: true, rejected: false, result: command('LOADING_COMPLETE') }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
const send = vi.fn(), find = vi.fn(), failure = new Error('detail unavailable')
const read = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 })
await expect(recovery.execute(send, read, find)).rejects.toBe(failure)
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
await recovery.execute(send, read, find)
expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
})
it('accepts a loading confirmation that already departed and recovers it without resubmitting', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'DELIVERING', recordVersion: 3 })
const read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 3 })
await expect(recovery.execute(send, read, vi.fn())).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
await recovery.execute(send, read, vi.fn())
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
it('rejects an account change before sending and releases state',async()=>{
let owner=crypto.randomUUID();const state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'});owner=crypto.randomUUID()
const send=vi.fn()
await expect(state.execute(send,vi.fn())).rejects.toThrow('账号已变化')
expect(send).not.toHaveBeenCalled();expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})
@@ -3,7 +3,7 @@ import { ApiResponseError } from '@/shared/api/http'
import type { CommandResult, DispatchAction } from './dispatchContracts'
import { requireVersion } from './dispatchContracts'
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
const storageKey = (scope: string) => `dispatch-command:${scope}`
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
@@ -13,74 +13,46 @@ export function isDefiniteDispatchRejection(error: unknown) {
export function validateCommandResult(result: CommandResult, pending: PendingDispatchCommand) {
if (!result || result.dispatchNo !== pending.dispatchNo && pending.action !== 'CREATE'
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
|| !result.dispatchNo || !result.result) throw new Error('操作结果与本次排车请求不一致,请刷新后重试')
requireVersion(result.recordVersion)
if (pending.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage)) throw new Error('装车结果未确认装车完成,请核对原请求')
if (pending.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage)) throw new Error('装车结果未确认装车完成,请刷新当前排车')
}
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
if (current?.dispatchNo !== result.dispatchNo) throw new Error('恢复详情与原排车单不一致')
const latest = String(requireVersion(current.recordVersion)).replace(/^0+(?=\d)/, '')
const confirmed = String(requireVersion(result.recordVersion)).replace(/^0+(?=\d)/, '')
if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请保留原请求重读')
if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请刷新后重试')
}
/** A response proves the original command; a subsequent GET supplies current UI facts. */
/** 仅在当前请求期间防止连点;失败立即释放页面,不持久化或恢复历史请求。 */
export function useDispatchCommandRecovery(scope: () => string) {
const owner = scope()
const saved = owner ? sessionStorage.getItem(storageKey(owner)) : null
if (owner) sessionStorage.removeItem(storageKey(owner))
const existing = sessions.get(owner)
const pending = existing?.pending || ref<PendingDispatchCommand | null>(null)
const busy = existing?.busy || ref(false)
if (!existing && saved) {
try { pending.value = JSON.parse(saved) as PendingDispatchCommand }
catch { throw new Error('本次排车请求记录损坏,请保留现场核对') }
}
if (!busy.value) pending.value = null
if (!existing) sessions.set(owner, { pending, busy })
function persist() {
function requireOwner() {
if (!owner || owner !== scope()) throw new Error('账号已变化,请重新进入排车列表')
if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
else sessionStorage.removeItem(storageKey(owner))
}
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId' | 'submitReviewAfterSave'> = {}) {
if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, attempted: false, result: null, rejected: false, ...target }
persist()
requireOwner()
if (busy.value) throw new Error('操作正在提交,请稍候')
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, ...target }
}
async function execute(send: (command: PendingDispatchCommand) => Promise<CommandResult>, read: (dispatchNo: string) => Promise<unknown>, find: (dispatchNo: string, key: string) => Promise<CommandResult>) {
async function execute(send: (command: PendingDispatchCommand) => Promise<CommandResult>, read: (dispatchNo: string) => Promise<unknown>) {
if (busy.value || !pending.value) return
const command = pending.value
const previouslyAttempted = command.attempted
busy.value = true
try {
persist()
if (!command.result && previouslyAttempted && command.dispatchNo) {
try { const found = await find(command.dispatchNo, String(command.body.idempotencyKey)); validateCommandResult(found, command); command.result = found }
catch (error) { if (!(error instanceof ApiResponseError) || !['404', 'A0404', 'HTTP_404'].includes(String(error.code))) throw error }
}
if (!command.result) {
command.attempted = true; persist()
try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
catch (error) {
// A later rejection cannot erase an earlier unknown write result.
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) {
pending.value = null
}
persist(); throw error
}
}
validateCommandResult(command.result, command); persist()
validateCurrentDispatch(await read(command.result.dispatchNo), command.result)
pending.value = null; persist()
return command.result
} finally { busy.value = false }
requireOwner()
const result = await send(snapshot(command))
validateCommandResult(result, command)
validateCurrentDispatch(await read(result.dispatchNo), result)
return result
} finally { pending.value = null; busy.value = false }
}
function discardRejected() {
if (busy.value || pending.value?.rejected !== true) return
pending.value = null; persist()
}
// Older browser records explicitly marked a first-attempt rejection as terminal.
// Only that recorded proof permits cleanup; unknown attempts and proven results remain.
discardRejected()
return { pending, busy, begin, execute, discardRejected }
return { pending, busy, begin, execute }
}
@@ -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托)'")