fix(admin): present dispatch vehicle statuses and scoped notices

This commit is contained in:
2026-10-20 10:17:17 +08:00
parent 3267679758
commit f4224e5d3a
3 changed files with 57 additions and 8 deletions
@@ -1,5 +1,6 @@
export { overseasMasterDataRoutes } from './routes'
export { inquiryReturnTarget } from './inquiryNavigation'
export { vehicleStatusLabels } from './presentation'
export { overseasMasterDataPermissions } from './permissions'
export type * from './types'
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
@@ -1,7 +1,8 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDispatch } from '../api/dispatches'
import { ApiResponseError } from '@/shared/api/http'
import { getDispatch, getDispatchEvidence } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue'
import { detail, destination } from '../dispatchTestFixtures'
import { DataTableStub } from './phase5TestStubs'
@@ -10,8 +11,11 @@ const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query }), useRouter: () => ({ push }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchEvidence: vi.fn() }))
vi.mock('@/modules/overseas-master-data', async () => ({
vehicleStatusLabels: (await vi.importActual<typeof import('../../overseas-master-data/presentation')>('../../overseas-master-data/presentation')).vehicleStatusLabels,
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
@@ -39,6 +43,18 @@ describe('whole-dispatch four-tab detail', () => {
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
})
it.each([
['IN_SERVICE', '在役'], ['DISPATCHED', '外派中'], ['MAINTENANCE', '维修中'], ['INACTIVE', '停用'], ['RETURNED', '已归还'],
['FUTURE_STATUS', '—'], ['toString', '—'], [null, '—'],
])('presents vehicle status %s through the existing master-data labels or an explicit placeholder', async (status, label) => {
const value = detail(); value.vehicleInfo.vehicle!.vehicleStatus = status
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const field = wrapper.findAll('.detail-fields > div').find(row => row.find('dt').text() === '车辆状态')!
expect(field.get('dd').text()).toBe(label)
if (status) expect(field.text()).not.toContain(status)
wrapper.unmount()
})
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
const value = detail(); value.documents.deliveries = ['DEL-A', 'DEL-B'].map((deliveryNo, index) => ({ deliveryNo, customerId: `C${index}`, customerName: `客户${index}`, outboundNo: `OUT-${index}`, groupKey: `group-${index}`, destination: destination(), status: 'WAIT_DELIVERY', packageCount: 1, palletCount: 1 }))
vi.mocked(getDispatch).mockResolvedValueOnce(value)
@@ -49,7 +65,36 @@ describe('whole-dispatch four-tab detail', () => {
it('requires a literal service allowance and permission for a write action', async () => {
can.mockReturnValue(true); vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { DEPART: { allowed: false, blockCode: 'NOT_ARRANGED', blockReason: '尚未安排派送' } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
})
it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'AFTER_LOADING', actions: {
ARRANGE_DELIVERY: { allowed: true, blockCode: null, blockReason: null },
EDIT: { allowed: false, blockCode: 'STAGE', blockReason: '当前阶段或作业事实不满足操作条件' },
PICK: { allowed: false, blockCode: 'PERMISSION', blockReason: '当前无此操作权限' },
} }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('装车后')
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
expect(wrapper.text()).not.toContain('当前无此操作权限')
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === '安排派送')!.trigger('click')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'ARRANGE_DELIVERY' })
wrapper.unmount()
})
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
vi.mocked(getDispatch).mockResolvedValueOnce(value)
vi.mocked(getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('附件不存在', 'A0404', 404, undefined, 'evidence-request', 'evidence-trace'))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
expect(wrapper.text()).toContain('本趟附件操作未完成')
expect(wrapper.text()).toContain('A0404')
expect(wrapper.text()).toContain('evidence-request')
expect(wrapper.text()).toContain('evidence-trace')
expect(value.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['301'])
wrapper.unmount()
})
it('retains a real API failure and exposes retry rather than rendering a successful empty detail', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('permission denied'))
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { inquiryReturnTarget } from '@/modules/overseas-master-data'
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -25,10 +25,13 @@ const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
const detailActions: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
const actionBlockReasons = computed(() => [...new Set(Object.values(detail.value?.actions || {}).map(gate => gate?.allowed === true ? '' : gate?.blockReason).filter(Boolean))])
const vehicleStatus = computed(() => {
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
})
const vehicleFields = computed(() => [
['车牌号', text(detail.value?.vehicleInfo.vehicle?.plate)], ['司机', text(detail.value?.vehicleInfo.driver?.name)],
['联系电话', text(detail.value?.vehicleInfo.driver?.phone)], ['拖车公司名称', text(detail.value?.vehicleInfo.vehicle?.carrierName)], ['车辆状态', text(detail.value?.vehicleInfo.vehicle?.vehicleStatus)],
['联系电话', text(detail.value?.vehicleInfo.driver?.phone)], ['拖车公司名称', text(detail.value?.vehicleInfo.vehicle?.carrierName)], ['车辆状态', vehicleStatus.value],
])
const dispatchFields = computed(() => [
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
@@ -71,7 +74,7 @@ onBeforeUnmount(() => { generation++ })
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<section v-if="detail" class="detail-section" aria-label="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>