This commit is contained in:
2026-10-27 17:39:19 +08:00
parent b5d8ccdffe
commit c148912b73
49 changed files with 1134 additions and 464 deletions
@@ -74,7 +74,7 @@ onBeforeUnmount(() => { generation++ })
<OverseasDetailShell workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" 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">
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" :pickup-stage="detail.stage" />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<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 vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
@@ -1,4 +1,5 @@
import { createPinia } from 'pinia'
import { defineComponent } from 'vue'
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
@@ -23,13 +24,31 @@ vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.f
vi.mock('../api/transportTasks', () => ({ transportLabels: { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }, cancelTransport: vi.fn(), getTransport: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('places truck completion next to close in the outer dialog footer', async () => {
const submitCompletion = vi.fn()
const panel = defineComponent({
name: 'TransportExecutionPanel',
setup(_, { expose }) {
expose({ completionVisible: true, completionText: '完成', completionLoading: false, completionDisabled: false, submitCompletion })
return () => null
},
})
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'LOADING', reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: panel } } }); await flushPromises()
wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'transport'); await flushPromises()
const footer = wrapper.get('.app-dialog__footer')
expect(footer.findAll('button').map(button => button.text())).toEqual(['关闭', '完成'])
await footer.findAll('button')[1]!.trigger('click')
expect(submitCompletion).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('offers pickup detail, transport vehicle maintenance and the shared review workflow', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
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)
@@ -52,7 +71,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
expect(listDispatches).toHaveBeenCalledTimes(2)
wrapper.unmount(); confirm.mockRestore()
})
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
@@ -338,11 +357,20 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '运输操作' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '维护车辆' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
})
it('opens local truck delivery stage directly on the delivery photo node', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'DELIVERING', reviewState: 'APPROVED' })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'transport', label: '上传送达照片' }), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupStage: 'DELIVERING', editResources: false })
wrapper.unmount()
})
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
@@ -474,3 +502,13 @@ it.each([
expect(getDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('shows only details after %s delivery completes', async taskType => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType, stage: 'DELIVERED', reviewState: 'APPROVED', actions: {} })]))
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(['detail'])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
wrapper.unmount()
})
@@ -35,6 +35,11 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('')
const transportEditTitle = ref('维护取货车辆信息'), transportResourceMode = ref(false)
const transportPanel = ref<InstanceType<typeof TransportExecutionPanel> | null>(null)
const transportCompletionVisible = computed(() => transportPanel.value?.completionVisible ?? false)
const transportCompletionText = computed(() => transportPanel.value?.completionText ?? '完成')
const transportCompletionLoading = computed(() => transportPanel.value?.completionLoading ?? false)
const transportCompletionDisabled = computed(() => transportPanel.value?.completionDisabled ?? true)
const activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
const cancellingPickup = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
@@ -125,7 +130,16 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
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 }]
if (row.taskType === 'LOCAL_TRUCK') {
const reviewActions = row.status === 'ACTIVE'
? rowActionOrder.filter(value => ['SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'CANCEL'].includes(value) && (value !== 'CANCEL' || row.stage === 'BEFORE_LOADING') && canDispatchAction(row.actions, value, access.can))
: []
return [
...(row.status === 'ACTIVE' && row.stage !== 'DELIVERED' && (access.can(dispatchActionPermissions.EDIT) || access.can(dispatchActionPermissions.CANCEL) || access.can(dispatchActionPermissions.DEPART)) ? [{ key: 'transport', label: row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '维护车辆', priority: 700 }] : []),
...reviewActions.map((value, index) => ({ key: value, label: dispatchActionLabels[value], ariaLabel: dispatchActionLabels[value], priority: 600 - index * 10 })),
{ key: 'detail', label: '详情', priority: 500 },
]
}
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)
@@ -138,26 +152,27 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
loading: value === 'CANCEL' && cancellingPickup.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) {
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return ''
if (row.stage === 'DELIVERED') return '结束资源任务'
if (row.stage === 'DELIVERED') return ''
if (row.reviewState !== 'APPROVED') return ''
return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
}
function openTransport(row: DispatchListRow, resources = false) {
transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? '运输操作' : pickupExecutionLabel(row)
transportEditTitle.value = resources ? '维护取货车辆信息' : row.taskType === 'LOCAL_TRUCK' ? (row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传送达照片' : '运输操作') : pickupExecutionLabel(row)
transportEditOpen.value = true
}
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row); return }
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row, row.taskType === 'LOCAL_TRUCK' && row.stage === 'BEFORE_LOADING'); return }
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
if (value === 'bol') return void openBol(row)
if (!rowActions(row).some(item => item.key === value)) return
rejectionError.value = null
if (value === 'route-order' || value === 'SUBMIT_REVIEW' && row.taskType !== 'PICKUP') return
if (value === 'route-order') return
operation.value = undefined
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
const requested = value as DispatchAction
@@ -233,7 +248,7 @@ onBeforeUnmount(() => { sequence++ })
</DataTable></div>
</ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="false" cancel-text="关闭"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" @saved="load" /></AppDialog>
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="transportCompletionVisible" :confirm-text="transportCompletionText" :confirm-loading="transportCompletionLoading" :confirm-disabled="transportCompletionDisabled" cancel-text="关闭" @confirm="transportPanel?.submitCompletion()"><TransportExecutionPanel v-if="transportEditOpen" ref="transportPanel" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" :pickup-stage="activeTransport?.stage" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
@@ -442,7 +442,7 @@ onMounted(load)
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable clearable :loading="feeItemsLoading" :disabled="feeItemsLoading || !!feeItemsError" placeholder="请选择费用项目"><el-option v-for="item in feeItemOptions" :key="item.feeCode" :label="item.feeTypeName" :value="item.feeCode" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>