发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -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>
|
||||
|
||||
@@ -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托)'")
|
||||
|
||||
Reference in New Issue
Block a user