Merge origin/release/test and preserve pickup review photos
This commit is contained in:
@@ -436,3 +436,5 @@
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
@@ -49,6 +49,7 @@ it('reads existing delivery files in one dialog and saves receipt without requir
|
||||
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||
const wrapper = setup(); await flushPromises()
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
||||
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['LOADING_PHOTO', 'BOL'])
|
||||
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -66,8 +66,9 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
|
||||
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['BOL']" @saved="saved" />
|
||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['LOADING_PHOTO', 'BOL']" @saved="saved" />
|
||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||
</div>
|
||||
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||
|
||||
@@ -62,6 +62,17 @@ it('shows existing photos without exposing upload for a read-only account', asyn
|
||||
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('allows choosing another image after the server definitively rejects the file', async () => {
|
||||
vi.mocked(api.uploadLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('图片内容无效', 'A0400'))
|
||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||
expect(wrapper.text()).toContain('图片内容无效')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('allows saving photos while loading and keeps the loading stage', async () => {
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' }))
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] }))
|
||||
|
||||
@@ -69,7 +69,13 @@ async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||
try {
|
||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求') }
|
||||
} catch (cause) {
|
||||
if (active) {
|
||||
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
|
||||
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
|
||||
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
|
||||
}
|
||||
}
|
||||
finally { if (active) busy.value = false }
|
||||
}
|
||||
onMounted(() => {
|
||||
|
||||
@@ -54,32 +54,43 @@ function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) {
|
||||
describe('dispatch workflow native layout', () => {
|
||||
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
|
||||
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
|
||||
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
|
||||
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
|
||||
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
|
||||
await field(dialog, '司机姓名').get('input').setValue('新司机')
|
||||
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
|
||||
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('维护备注')
|
||||
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' })
|
||||
})
|
||||
it('saves all three resource photos before submitting review with the refreshed version', async () => {
|
||||
const { dialog } = await render('EDIT')
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
fields.forEach((field, index) => field.vm.$emit('update:modelValue', [String(401 + index)]))
|
||||
await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '提交审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.objectContaining({ evidenceFileIds: ['401'] }), driver: expect.objectContaining({ evidence: expect.objectContaining({ photoFileIds: ['402'], fileIds: ['403'] }) }) }))
|
||||
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')
|
||||
await dialog.get('textarea[aria-label="本次操作备注"]').setValue('保留本次输入')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
|
||||
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')
|
||||
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((dialog.get('textarea[aria-label="本次操作备注"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
|
||||
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)
|
||||
})
|
||||
@@ -173,9 +184,10 @@ describe('dispatch workflow native layout', () => {
|
||||
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
|
||||
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
|
||||
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
|
||||
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
|
||||
if (needsReason) await dialog.get('textarea').setValue(' 现场原因 ')
|
||||
else expect(dialog.find('textarea').exists()).toBe(false)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
|
||||
})
|
||||
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
|
||||
const { dialog } = await render('EDIT', 'ROUTE')
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -215,3 +215,65 @@ it('does not submit an exception when the server disallows the action', async ()
|
||||
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
||||
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('submits review from vehicle maintenance after saving the current inputs and reading the new version', async () => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
expect(wrapper.find('[aria-label="本次操作备注"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存')).toBe(true)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '提交审核')).toBe(true)
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: gate, SUBMIT_REVIEW: gate } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
|
||||
await state.submit(true)
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.any(Object), driver: expect.any(Object) }))
|
||||
expect(api.submitDispatchReview).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
|
||||
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
|
||||
})
|
||||
it('save alone never submits review and a failed save cannot advance review', async () => {
|
||||
const first = await open('EDIT')
|
||||
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
|
||||
await first.state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); first.wrapper.unmount()
|
||||
const second = await open('EDIT')
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('车辆资料不完整', 'A0400'))
|
||||
await second.state.submit(true); expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
expect(second.wrapper.text()).toContain('车辆资料不完整'); second.wrapper.unmount()
|
||||
})
|
||||
it('rechecks review eligibility after save and leaves the saved editor open if blocked', 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: { EDIT: { allowed: true, blockCode: null, blockReason: null }, SUBMIT_REVIEW: { allowed: false, blockCode: 'MISSING', blockReason: '预约资料未齐全' } } }))
|
||||
await state.submit(true)
|
||||
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'))
|
||||
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()
|
||||
})
|
||||
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'))
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -46,8 +46,10 @@ let editorRequestKey = ''
|
||||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||||
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value || currentAction.value === 'LOADING_COMPLETE' ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
|
||||
@@ -115,6 +117,7 @@ function payload(): object {
|
||||
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
|
||||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
|
||||
}
|
||||
case 'SUBMIT_REVIEW': return { ...common, remark: null }
|
||||
default: return { ...common, remark: form.remark || null }
|
||||
}
|
||||
}
|
||||
@@ -138,20 +141,35 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||||
form.decision = decision
|
||||
await submit()
|
||||
}
|
||||
async function submit() {
|
||||
if (busy.value || loading.value || uploadingEvidence.value) return
|
||||
async function submit(submitReviewAfterSave = false) {
|
||||
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
if (!pending.value) {
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value })
|
||||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||||
}
|
||||
const routeCommand = pending.value?.operation === 'ROUTE'
|
||||
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||||
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
|
||||
const readCurrent = async (no: string) => {
|
||||
const value = await api.getDispatch(no)
|
||||
if (value.dispatchNo === currentNo.value) detail.value = value
|
||||
return value
|
||||
}
|
||||
let result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
|
||||
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)
|
||||
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) {
|
||||
@@ -162,7 +180,7 @@ async function submit() {
|
||||
emit('rejected', error.value)
|
||||
await refreshFacts()
|
||||
}
|
||||
}
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
async function refreshFacts() {
|
||||
const sequence = ++generation, no = currentNo.value
|
||||
@@ -190,7 +208,7 @@ 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="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 ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : 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 />
|
||||
@@ -209,7 +227,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
|
||||
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'EDIT'">
|
||||
<template v-else-if="vehicleEditor">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||
</template>
|
||||
@@ -225,7 +243,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="currentOperation !== 'ROUTE' && currentAction === 'EDIT'" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<div v-if="vehicleEditor" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
@@ -234,10 +252,15 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
<template v-if="isReview && !pending" #footer>
|
||||
<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>
|
||||
</template>
|
||||
<template v-else-if="isReview && !pending" #footer>
|
||||
<div class="workflow-review-actions">
|
||||
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
|
||||
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('APPROVED')">审核通过</el-button>
|
||||
|
||||
@@ -300,7 +300,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
</template>
|
||||
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
|
||||
<section class="density-dialog-section" aria-label="排车计划时间与备注">
|
||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
|
||||
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable dispatch-create-table"><tbody>
|
||||
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
|
||||
|
||||
@@ -86,7 +86,7 @@ it.each([false, true])('confirms the %s photo stage with its own evidence and cu
|
||||
await input.trigger('change'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
|
||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
|
||||
if (arriving) expect(commandTransport.mock.calls[0]![2]).not.toHaveProperty('actualPackages')
|
||||
if (arriving) expect(vi.mocked(commandTransport).mock.calls[0]![2]).not.toHaveProperty('actualPackages')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
|
||||
|
||||
@@ -163,8 +163,8 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
|
||||
</dl>
|
||||
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="value => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="value => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||
|
||||
@@ -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 }
|
||||
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; 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>> }>()
|
||||
@@ -42,7 +42,7 @@ export function useDispatchCommandRecovery(scope: () => string) {
|
||||
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'> = {}) {
|
||||
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()
|
||||
|
||||
@@ -17,7 +17,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
||||
return result
|
||||
}
|
||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
const actions = wrapper.findAll('button').map(button => button.text())
|
||||
expect(actions).not.toContain('维护车辆信息')
|
||||
expect(actions).not.toContain('维护车辆')
|
||||
expect(actions).not.toContain('添加货物')
|
||||
expect(actions).not.toContain('移除货物')
|
||||
wrapper.unmount()
|
||||
@@ -181,7 +181,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(wrapper.text()).toContain('装车中')
|
||||
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
|
||||
expect(wrapper.text()).not.toContain('当前无此操作权限')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 } from '../dispatchTestFixtures'
|
||||
import { command, detail, listRow, page, reviews } from '../dispatchTestFixtures'
|
||||
import type { DispatchPage } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -29,7 +29,7 @@ it('offers pickup detail, transport vehicle maintenance and the shared review wo
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆信息', '提交调度审核', '详情', '更多'])
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交调度审核', '详情', '更多'])
|
||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
|
||||
@@ -42,7 +42,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', recordVersion: 10, actions: { CANCEL: allowed } })]))
|
||||
vi.mocked(getTransport).mockResolvedValue({ execution: { trip: { recordVersion: 4 } }, pickups: [{ transferNo: 'WT-1' }] } as Awaited<ReturnType<typeof getTransport>>)
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
row.vm.$emit('action', 'CANCEL'); await flushPromises()
|
||||
@@ -125,17 +125,18 @@ it('centralizes cargo changes in smart dispatch and never exposes row add/remove
|
||||
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps vehicle maintenance and detail visible while submitting review remains available in the menu', async () => {
|
||||
it('keeps vehicle maintenance but hides standalone review submission and route ordering', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, { ...options, attachTo: document.body }); await flushPromises()
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆信息', '详情', '更多'])
|
||||
await row.get('button[aria-label="更多操作 · PC/1"]').trigger('click'); await flushPromises()
|
||||
const submit = Array.from(document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')).find(item => item.textContent === '提交调度审核')!
|
||||
expect(submit).toBeDefined()
|
||||
submit.click(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
||||
const keys = (row.props('actions') as Array<{ key: string }>).map(item => item.key)
|
||||
expect(keys).toContain('EDIT'); expect(keys).toContain('detail')
|
||||
expect(keys).not.toContain('SUBMIT_REVIEW'); expect(keys).not.toContain('route-order')
|
||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); row.vm.$emit('action', 'route-order'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'EDIT', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
|
||||
@@ -323,7 +324,7 @@ it('gates every mutation with both its literal service capability and independen
|
||||
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
|
||||
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
|
||||
can.mockReturnValue(false); wrapper.unmount()
|
||||
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail', 'bol']); readonly.unmount()
|
||||
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail']); readonly.unmount()
|
||||
})
|
||||
it('keeps server errors visible with the integrated query and retry context', async () => {
|
||||
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
|
||||
@@ -356,21 +357,38 @@ it('keeps pickup workflow actions but hides route ordering and BOL even when edi
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
||||
const current = reviews()
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } } })]))
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers BOL only after current dispatch review is approved: %s', async status => {
|
||||
const current = reviews()
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status } } })]))
|
||||
const popup = { close: vi.fn(), location: { href: '' } }
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(popup))
|
||||
vi.mocked(getDispatchBolPdf).mockRejectedValue(new Error('print unavailable'))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect((actions.props('actions') as Array<{ key: string }>).some(item => item.key === 'bol')).toBe(status === 'APPROVED')
|
||||
actions.vm.$emit('action', 'bol'); await flushPromises()
|
||||
expect(getDispatchBolPdf).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
|
||||
expect(window.open).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
|
||||
if (status === 'APPROVED') expect(popup.close).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })]))
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
|
||||
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'EDIT' }))
|
||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
|
||||
else dialog.vm.$emit('saved')
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'SUBMIT_REVIEW' }))
|
||||
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
|
||||
if (event === 'rejected') {
|
||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
|
||||
@@ -116,13 +116,15 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; pag
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
|
||||
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
|
||||
// 仅当前调度审核通过的仓库派送排车可下载,退回或审核失效后重新收起入口。
|
||||
function canDownloadBol(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.reviews?.dispatchReview?.status === 'APPROVED' }
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType === 'LOCAL_TRUCK') return [...(row.status === 'ACTIVE' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: '运输操作', priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const available = rowActionOrder.filter(value => (value !== 'SUBMIT_REVIEW' || row.taskType === 'PICKUP') && canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
const execution = pickupExecutionLabel(row)
|
||||
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
|
||||
@@ -132,7 +134,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
||||
loading: value === 'CANCEL' && cancellingPickup.value === row.dispatchNo,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDownloadBol(row) ? [{ key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }] : [])]
|
||||
}
|
||||
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
|
||||
function pickupExecutionLabel(row: DispatchListRow) {
|
||||
@@ -153,7 +155,7 @@ function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
|
||||
if (value === 'bol') return void openBol(row)
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
|
||||
operation.value = undefined
|
||||
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
||||
const requested = value as DispatchAction
|
||||
@@ -183,7 +185,7 @@ function recoverPending() {
|
||||
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
async function openBol(row: DispatchListRow) {
|
||||
if (bolLoadingNo.value) return
|
||||
if (bolLoadingNo.value || !canDownloadBol(row)) return
|
||||
const windowHandle = window.open('', '_blank')
|
||||
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
|
||||
bolLoadingNo.value = row.dispatchNo
|
||||
|
||||
Reference in New Issue
Block a user