优化
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||
import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
|
||||
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
|
||||
const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
|
||||
const options = { global: { plugins: [ElementPlus] } }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(getTransport).mockResolvedValue(facts())
|
||||
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
})
|
||||
it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => {
|
||||
vi.mocked(getTransport).mockResolvedValue(facts(arriving))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false)
|
||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传取货照片'))!.trigger('click'); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
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: 5 }) }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '上传取货照片')).toBe(false)
|
||||
expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => {
|
||||
const value = facts(arriving)
|
||||
value.execution.trip.vehicleId = null
|
||||
value.execution.trip.driverId = null
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, readonly: true } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('WT1')
|
||||
expect(wrapper.text()).toContain('洛杉矶仓')
|
||||
expect(wrapper.findAll('input, select, textarea')).toHaveLength(0)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.findAll('th').map(column => column.text())).not.toContain('操作')
|
||||
expect(commandTransport).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { Truck, Package, RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -10,16 +13,23 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const arrivalMode = ref(false)
|
||||
const editingResources = ref(props.editResources === true)
|
||||
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
|
||||
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
|
||||
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
|
||||
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
|
||||
const trip = computed(() => detail.value?.execution.trip)
|
||||
const editable = computed(() => access.can(permission.EDIT))
|
||||
const editable = computed(() => !props.readonly && access.can(permission.EDIT))
|
||||
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
|
||||
const resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
|
||||
const locked = computed(() => busy.value || !!pending.value)
|
||||
async function load() {
|
||||
error.value = null
|
||||
@@ -41,7 +51,7 @@ async function candidates() {
|
||||
async function assign() {
|
||||
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); emit('saved'); ElMessage.success('车辆司机已分配') }
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机已分配') }
|
||||
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
@@ -59,14 +69,14 @@ async function execute() {
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
|
||||
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
function editPickup(row: PickupTask) { selected.value = row; actual.value = Number(row.packageCount); evidence.value = []; pickupOpen.value = true }
|
||||
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = Number(row.actualPackages ?? row.packageCount); evidence.value = []; pickupOpen.value = true }
|
||||
async function download(file: PickupEvidence, row?: PickupTask) {
|
||||
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
|
||||
@@ -83,7 +93,8 @@ async function upload(event: Event, pickup: boolean) {
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
|
||||
const row = selected.value
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/picked`, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: reason.value.trim() } }; await execute()
|
||||
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
|
||||
}
|
||||
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
|
||||
async function release() {
|
||||
@@ -93,34 +104,71 @@ async function release() {
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
|
||||
</script>
|
||||
<template>
|
||||
<section class="detail-section" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">按原请求重试</el-button>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">核对原请求</el-button>
|
||||
<template v-if="detail && trip">
|
||||
<h2>{{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</h2>
|
||||
<p v-if="detail.orderNo">本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}</p>
|
||||
<p>调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}</p>
|
||||
<p v-for="(stop, index) in trip.stops" :key="index">{{ index + 1 }}. {{ stop.name }} {{ stop.address }}</p>
|
||||
<el-input v-if="editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" />
|
||||
<el-form v-if="trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form">
|
||||
<el-button :loading="candidateBusy" @click="candidates">加载车辆司机候选</el-button>
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable><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}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
|
||||
</el-form>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId || (detail.taskType === 'PICKUP' && !props.pickupApproved)" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
|
||||
<header class="transport-toolbar">
|
||||
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
|
||||
<div v-if="!readonly" class="density-actions transport-actions">
|
||||
<slot name="actions" />
|
||||
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable" :disabled="locked || !truckEvidence.length" @click="command('complete')">确认运输完成</el-button>
|
||||
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
|
||||
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
|
||||
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
|
||||
</div>
|
||||
<p v-if="detail.taskType === 'PICKUP'">{{ props.pickupApproved ? '两级审核已通过,可发车取货。' : '请维护车辆信息并提交调度审核,经调度和理货员审核通过后发车取货。' }}送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
|
||||
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
</header>
|
||||
<section class="workbench-panel is-compact transport-resources" aria-label="车辆信息">
|
||||
<div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div>
|
||||
<dl class="transport-facts">
|
||||
<div><dt>{{ detail.taskType === 'PICKUP' ? '收货仓库' : '所属仓库' }}</dt><dd>{{ trip.warehouseName || '—' }}</dd></div>
|
||||
<div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div>
|
||||
<div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div>
|
||||
<template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template>
|
||||
<div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div>
|
||||
<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>
|
||||
<div class="density-actions"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId">收起</el-button></div>
|
||||
</el-form>
|
||||
</section>
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
|
||||
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
|
||||
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
|
||||
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传取货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
|
||||
<el-table-column prop="transferNo" label="转运单号" width="210" />
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="预报/实提(箱)" width="145" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
|
||||
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">确认实提</el-button></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认任务结束并释放占用</el-button></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传取货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<p>{{ selected?.transferNo }}</p><el-form label-position="left" label-width="85px" :disabled="locked"><el-form-item v-if="!arrivalMode" label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="照片" required><input type="file" accept="image/*" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :disabled="locked"><el-form-item label="实际结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认结束</el-button></template></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); }
|
||||
.transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; }
|
||||
.transport-identity { color: var(--cc-text); }
|
||||
.transport-identity > svg { color: var(--el-color-primary); }
|
||||
.transport-actions { margin-left: auto; }
|
||||
.transport-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-7); margin: var(--admin-space-5) 0 0; }
|
||||
.transport-facts > div { display: flex; align-items: baseline; gap: var(--admin-space-4); min-width: 0; }
|
||||
.transport-facts dt { color: var(--cc-muted); flex-shrink: 0; }
|
||||
.transport-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.transport-phone { margin-left: var(--admin-space-3); color: var(--cc-muted); }
|
||||
.transport-resource-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(240px, 1fr) auto; align-items: start; gap: var(--admin-space-6); margin-top: var(--admin-space-5); }
|
||||
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
|
||||
@media (max-width: 1100px) { .transport-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .transport-resource-form { grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -39,17 +39,15 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已退回客服处理(车辆信息)')
|
||||
expect(wrapper.text()).toContain('司机资料需要更正')
|
||||
vi.mocked(getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
|
||||
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('司机资料需要更正')
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps cargo additions and removals out of the detail even when authorized', async () => {
|
||||
it('keeps every write action out of the detail even when authorized', async () => {
|
||||
can.mockReturnValue(true)
|
||||
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.get('[aria-label="排车操作"]').findAll('button').map(button => button.text())
|
||||
expect(actions).toContain('维护车辆信息')
|
||||
const actions = wrapper.findAll('button').map(button => button.text())
|
||||
expect(actions).not.toContain('维护车辆信息')
|
||||
expect(actions).not.toContain('添加货物')
|
||||
expect(actions).not.toContain('移除货物')
|
||||
wrapper.unmount()
|
||||
@@ -58,6 +56,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true)
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
@@ -124,7 +123,7 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
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 () => {
|
||||
it('shows stage facts without execution actions or unrelated permission refusals', async () => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: {
|
||||
LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null },
|
||||
@@ -136,8 +135,8 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
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: 'LOADING_COMPLETE' })
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
|
||||
@@ -161,45 +160,16 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
|
||||
})
|
||||
it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => {
|
||||
it('does not mount review or mutation workflows for an authorized administrator', async () => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交调度审核')!.trigger('click')
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } }))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' })
|
||||
else dialog.vm.$emit('saved')
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '提交调度审核')).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('input, textarea')).toHaveLength(0)
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
|
||||
if (event === 'rejected') {
|
||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('detail-reject-request')
|
||||
}
|
||||
expect(wrapper.text()).not.toContain('结果尚待核对')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a workflow rejection when the parent detail refresh also fails', async () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed'))
|
||||
wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已提交,请刷新')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('排车详情加载失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请补齐本趟资料')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('请补齐本趟资料')
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,11 +11,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import type { DispatchDetail } from '../dispatchContracts'
|
||||
import { stageLabel } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import { dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
@@ -25,11 +24,6 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||||
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||||
const visibleActions = computed(() => detailActions.filter(value => !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const vehicleStatus = computed(() => {
|
||||
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
|
||||
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
|
||||
@@ -72,20 +66,15 @@ async function load() {
|
||||
const appointmentReturn = computed(() => appointmentReturnTarget(route.query))
|
||||
const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表')
|
||||
function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
async function handleSaved() { rejectionError.value = null; await load() }
|
||||
watch(dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<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'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
|
||||
<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="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
|
||||
<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">
|
||||
<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 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>
|
||||
@@ -97,12 +86,10 @@ onBeforeUnmount(() => { generation++ })
|
||||
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-section { min-width: 0; padding: var(--admin-space-5); }
|
||||
.dispatch-detail { min-width: 0; min-height: 0; }
|
||||
.linked-documents { grid-column: 1 / -1; }
|
||||
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.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 type { DispatchPage } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
@@ -18,8 +19,62 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '<div />' }, routeLocation: (label: string) => ({ label }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
|
||||
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
|
||||
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('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(['维护车辆信息', '提交调度审核', '详情', '更多'])
|
||||
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)
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('confirms cancelling pickup and linked transfers using the transport version', async () => {
|
||||
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 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()
|
||||
expect(confirm.mock.calls[0]?.[0]).toContain('WT-1')
|
||||
expect(cancelTransport).toHaveBeenCalledWith('PC/1', { recordVersion: 4, reason: '平台取消取货排车及对应转运单' })
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
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) => {
|
||||
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')!
|
||||
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', pickupApproved: true, editResources: false })
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not expose pickup work for a cancelled order or an operator without edit permission', async () => {
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', status: 'CANCELLED', 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()
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', actions: {} })]))
|
||||
const readonly = mount(DispatchesView, options); await flushPromises()
|
||||
expect(readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
|
||||
readonly.unmount()
|
||||
})
|
||||
it('never offers a second arrangement or departure after loading completion', async () => {
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
|
||||
@@ -154,7 +209,7 @@ 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([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()
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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'
|
||||
@@ -24,7 +24,7 @@ import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import { transportLabels, type TransportType } from '../api/transportTasks'
|
||||
import { cancelTransport, getTransport, transportLabels, type TransportType } from '../api/transportTasks'
|
||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
@@ -36,6 +36,9 @@ const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutat
|
||||
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 activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
|
||||
const cancellingPickup = ref('')
|
||||
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
@@ -53,7 +56,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
@@ -87,20 +90,35 @@ 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 [{ 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 primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
return [...available.map((value, index) => ({
|
||||
const execution = pickupExecutionLabel(row)
|
||||
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...available.map((value, index) => ({
|
||||
key: value,
|
||||
label: dispatchActionLabels[value],
|
||||
ariaLabel: dispatchActionLabels[value],
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: value === 'CANCEL' ? '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 }])]
|
||||
}
|
||||
/** 详情只读,照片和资源结束操作从列表进入对应运输面板。 */
|
||||
function pickupExecutionLabel(row: DispatchListRow) {
|
||||
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) 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)
|
||||
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 === '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 }
|
||||
@@ -108,9 +126,22 @@ function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
||||
const requested = value as DispatchAction
|
||||
if (!canDispatchAction(row.actions, requested, access.can)) return
|
||||
if (row.taskType === 'PICKUP' && requested === 'EDIT') { transportEditNo.value = row.dispatchNo; transportEditOpen.value = true; return }
|
||||
if (row.taskType === 'PICKUP' && requested === 'CANCEL') return void cancelPickup(row)
|
||||
if (row.taskType === 'PICKUP' && requested === 'EDIT') { openTransport(row, true); return }
|
||||
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
/** 取货取消会同时取消本车全部对应转运单,确认后按运输当前版本提交。 */
|
||||
async function cancelPickup(row: DispatchListRow) {
|
||||
if (cancellingPickup.value) return
|
||||
cancellingPickup.value = row.dispatchNo; mutationError.value = null
|
||||
try {
|
||||
const value = await getTransport(row.dispatchNo)
|
||||
try { await ElMessageBox.confirm(`取消排车后,对应转运单(${value.pickups.map(item => item.transferNo).join('、')})也将取消。确认继续?`, '取消取货排车及转运单', { type: 'warning', customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '保留' }) } catch { return }
|
||||
await cancelTransport(row.dispatchNo, { recordVersion: value.execution.trip.recordVersion, reason: '平台取消取货排车及对应转运单' })
|
||||
ElMessage.success('排车及对应转运单已取消'); await load()
|
||||
} catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消未完成,请刷新核对排车及转运单状态') }
|
||||
finally { cancellingPickup.value = '' }
|
||||
}
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
async function handleSaved() { rejectionError.value = null; await load() }
|
||||
function recoverPending() {
|
||||
@@ -145,7 +176,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<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>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
@@ -154,7 +185,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
|
||||
<AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog>
|
||||
<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>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
|
||||
@@ -27,6 +27,7 @@ const overseasConsumers = [
|
||||
'overseas-warehouse/pages/InboundOrderDetailView.vue',
|
||||
'overseas-warehouse/pages/DispatchesView.vue',
|
||||
'overseas-warehouse/pages/DispatchDetailView.vue',
|
||||
'overseas-warehouse/components/TransportExecutionPanel.vue',
|
||||
'overseas-warehouse/pages/OutboundsView.vue',
|
||||
'overseas-warehouse/pages/OutboundDetailView.vue',
|
||||
'overseas-warehouse/pages/DeliveryOrdersView.vue',
|
||||
|
||||
Reference in New Issue
Block a user