优化
This commit is contained in:
@@ -4,7 +4,7 @@ import type { Resources, ReviewAttachments, Version } from '../dispatchContracts
|
||||
|
||||
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
|
||||
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; departureEvidence?: PickupEvidence[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
|
||||
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
|
||||
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
|
||||
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
|
||||
@@ -13,7 +13,7 @@ const base = '/platform/v1/last-leg/dispatches/transport-tasks'
|
||||
const path = (number: string) => `${base}/${encodeURIComponent(number)}`
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
|
||||
const normalize = (value: WireDetail): TransportDetail => ({ ...value, departureEvidence: value.departureEvidence || [], completionEvidence: value.completionEvidence || [], execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
|
||||
export const getTransport = (number: string) => get<WireDetail>(path(number)).then(normalize)
|
||||
export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
|
||||
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
|
||||
|
||||
@@ -151,16 +151,17 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.dispatch-evidence--photos { min-width: 0; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; }
|
||||
.dispatch-evidence--photos { min-width: 0; display: flex; flex-direction: column; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; flex: 1; display: flex; flex-direction: column; }
|
||||
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
|
||||
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.evidence-photo-actions { justify-content: center; }
|
||||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; background: var(--el-fill-color-lighter); overflow: hidden; }
|
||||
.evidence-photo-empty { background: var(--el-fill-color-lighter); }
|
||||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
|
||||
.evidence-photo-card figcaption { margin-top: auto; padding-top: var(--admin-space-3); font-weight: 600; }
|
||||
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
|
||||
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
|
||||
</style>
|
||||
|
||||
@@ -12,13 +12,25 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
||||
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
||||
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() }))
|
||||
import * as transportApi from '../api/transportTasks'
|
||||
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
async function open(action: DispatchAction, allowed = true) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) {
|
||||
vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
||||
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
return { wrapper, state: wrapper.vm as unknown as State }
|
||||
}
|
||||
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
||||
it('shows only source pickup destinations in warehouse-transfer review submission', async () => {
|
||||
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', taskType: 'PICKUP', execution: {} as never, customerId: null, orderNo: null, pickups: [{ pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }, { pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }] as never, completionEvidence: [], dispatchRecordVersion: 0, resources: null })
|
||||
const { wrapper } = await open('SUBMIT_REVIEW', true, { taskType: 'PICKUP', dispatchInfo: { ...detail().dispatchInfo, route: '到仓取货 · 1单 · US-LAX-01' } })
|
||||
const text = wrapper.getComponent({ name: 'ElDialogContent' }).text()
|
||||
expect(text).toContain('取货目的地')
|
||||
expect(text).toContain('US-LAX-01 · 123 Warehouse Ave')
|
||||
expect(text).not.toContain('起运地')
|
||||
expect(transportApi.getTransport).toHaveBeenCalledExactlyOnceWith('PC/1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
||||
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
||||
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
||||
|
||||
@@ -10,6 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { getTransport } from '../api/transportTasks'
|
||||
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
@@ -32,6 +33,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
|
||||
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
|
||||
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pickupDestinations = ref<string[]>([])
|
||||
const factsStale = ref(false)
|
||||
const preview = ref<LoadingPreview | null>(null)
|
||||
const loading = computed(() => working.value)
|
||||
@@ -66,7 +68,7 @@ const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
|
||||
const submitting = ref(false)
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const width = computed(() => vehicleEditor.value ? '1120px' : isReview.value ? '1100px' : currentAction.value === 'SUBMIT_REVIEW' ? '820px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
@@ -75,7 +77,7 @@ async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
|
||||
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
|
||||
route.value = null; stops.value = []
|
||||
route.value = null; stops.value = []; pickupDestinations.value = []
|
||||
try {
|
||||
if (cargoAction.value) return
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||||
@@ -83,6 +85,13 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
// 仓储转运取货显示原单取货地址,不把排车摘要当作起运地。
|
||||
if (action === 'SUBMIT_REVIEW' && value.taskType === 'PICKUP') {
|
||||
const transport = await getTransport(no)
|
||||
if (sequence !== generation) return
|
||||
if (transport.dispatchNo !== no) throw new DispatchInputError('取货资料与排车单不一致')
|
||||
pickupDestinations.value = [...new Set(transport.pickups.map(row => row.pickupAddress?.trim()).filter((address): address is string => Boolean(address)))]
|
||||
}
|
||||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||||
fillDestinationAppointments(value)
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
@@ -138,7 +147,11 @@ function payload(): object {
|
||||
if (!measurements.value.length || measurements.value.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new DispatchInputError('托盘卸货站尚未匹配,请调度核对路线')
|
||||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
|
||||
}
|
||||
case 'SUBMIT_REVIEW': return { ...common, remark: null }
|
||||
case 'SUBMIT_REVIEW':
|
||||
if (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId) {
|
||||
throw new DispatchInputError('车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核')
|
||||
}
|
||||
return { ...common, remark: null }
|
||||
default: return { ...common, remark: form.remark || null }
|
||||
}
|
||||
}
|
||||
@@ -257,6 +270,18 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
</DispatchVehicleMaintenanceForm>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'SUBMIT_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="提交审核依据">
|
||||
<h3 class="density-dialog-section__title">审核依据</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">车牌号</th><td>{{ detail.vehicleInfo.vehicle?.plate || '未维护' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '未维护' }}</td><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||||
<tr v-if="detail.taskType === 'PICKUP'"><th scope="row">取货目的地</th><td colspan="5"><div v-for="address in pickupDestinations" :key="address">{{ address }}</div><span v-if="!pickupDestinations.length">—</span></td></tr>
|
||||
<tr v-else><th scope="row">起运地</th><td>{{ detail.dispatchInfo.route?.split(' → ')[0] || '—' }}</td><th scope="row">目的地</th><td colspan="3">{{ detail.dispatchInfo.route?.split(' → ').slice(1).join(' → ') || '—' }}</td></tr>
|
||||
<tr><th scope="row">计划发车</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td><th scope="row">货量</th><td>{{ detail.counts.palletCount }} 托 / {{ detail.counts.packageCount }} 箱</td></tr>
|
||||
</tbody></table>
|
||||
<el-alert v-if="!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据">
|
||||
<h3 class="density-dialog-section__title">车辆与货物</h3>
|
||||
@@ -278,7 +303,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
|
||||
@@ -1,24 +1,126 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||
import { assignTransport, commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { getDriver } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), downloadDriverLicense: 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] } }
|
||||
it.each([false, true])('requires this truck stage own photo before footer completion (%s)', async arriving => {
|
||||
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const value = facts(arriving); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: arriving ? 'DELIVERING' : 'LOADING' } }); await flushPromises()
|
||||
expect(wrapper.vm.completionVisible).toBe(true)
|
||||
expect(wrapper.vm.completionDisabled).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => ['完成', '完成本单'].includes(button.text()))).toBe(false)
|
||||
await wrapper.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenCalledWith(arriving ? '请至少上传一张送达照片' : '请至少上传一张取货照片')
|
||||
expect(confirm).not.toHaveBeenCalled(); expect(commandTransport).not.toHaveBeenCalled()
|
||||
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.vm.submitCompletion(); await flushPromises()
|
||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `truck/${arriving ? 'complete' : 'depart'}`, expect.objectContaining({ recordVersion: 4, reason: arriving ? '确认已在 送达并完成本土卡车订单?' : '确认 取货完成', evidenceFileIds: ['88'] }))
|
||||
wrapper.unmount(); warning.mockRestore(); confirm.mockRestore()
|
||||
})
|
||||
it('confirms the B point action as pickup completion and explains the next stage', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); 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.vm.submitCompletion(); await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith('', '确认取货完成', expect.any(Object))
|
||||
wrapper.unmount(); confirm.mockRestore()
|
||||
})
|
||||
beforeEach(() => {
|
||||
can.mockReturnValue(true)
|
||||
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => 'blob:truck-photo'), revokeObjectURL: vi.fn() })
|
||||
vi.mocked(getTransport).mockResolvedValue(facts())
|
||||
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
|
||||
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
})
|
||||
it('keeps the footer action visible without departure permission and explains why submission is blocked', async () => {
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:dispatch:depart')
|
||||
const warning = vi.spyOn(ElMessage, 'warning').mockImplementation(() => ({ close: vi.fn() }))
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
|
||||
expect(wrapper.vm.completionVisible).toBe(true)
|
||||
await wrapper.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenLastCalledWith('请至少上传一张取货照片')
|
||||
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.vm.submitCompletion()
|
||||
expect(warning).toHaveBeenLastCalledWith('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
|
||||
expect(commandTransport).not.toHaveBeenCalled()
|
||||
wrapper.unmount(); warning.mockRestore()
|
||||
})
|
||||
it('shows B point pickup photos inline and accepts multiple files', async () => {
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.dispatchRecordVersion = 9
|
||||
value.execution.trip.stops = []; value.execution.trip.vehicleId = '1'; value.execution.trip.driverId = '2'
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(uploadTransport).mockResolvedValueOnce({ fileId: '88', fileName: '现场1.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
.mockResolvedValueOnce({ fileId: '89', fileName: '现场2.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'LOADING' } }); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
expect(input.attributes('multiple')).toBeDefined()
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['1'], '现场1.jpg', { type: 'image/jpeg' }), new File(['2'], '现场2.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(wrapper.findAll('.truck-evidence-item img')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('现场1.jpg'); expect(wrapper.text()).toContain('现场2.jpg')
|
||||
expect(vi.mocked(uploadTransport)).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows B point evidence separately and keeps delivery uploads in the C point section', async () => {
|
||||
const value = facts(true); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []
|
||||
value.departureEvidence = [{ fileId: '70', fileName: '取货.jpg', contentType: 'image/jpeg', sizeBytes: 10 }]
|
||||
value.completionEvidence = []
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERING' } }); await flushPromises()
|
||||
const pickup = wrapper.get('[aria-label="取货照片"]')
|
||||
const delivery = wrapper.get('[aria-label="送达照片上传"]')
|
||||
expect(pickup.text()).toContain('取货.jpg')
|
||||
expect(pickup.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(pickup.findAll('.truck-evidence-delete')).toHaveLength(0)
|
||||
expect(delivery.text()).toContain('上传送达照片')
|
||||
expect(delivery.findAll('.truck-evidence-item')).toHaveLength(0)
|
||||
expect(delivery.get('input[type="file"]').attributes('multiple')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
const photoOptions = { global: { plugins: [ElementPlus], stubs: { DispatchEvidenceFiles: true } } }
|
||||
it('requires uploaded truck license photos and hides pickup evidence during vehicle maintenance', async () => {
|
||||
const value = facts(); value.taskType = 'LOCAL_TRUCK'; value.pickups = []; value.execution.trip.stops = []; value.dispatchRecordVersion = 9
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(assignTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
|
||||
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(cards).toHaveLength(3)
|
||||
expect(cards[2]!.props('referenceIds')).toEqual([])
|
||||
expect(cards[2]!.props('modelValue')).toEqual([])
|
||||
expect(wrapper.text()).not.toContain('取货照片')
|
||||
cards[0]!.vm.$emit('update:modelValue', ['21']); cards[1]!.vm.$emit('update:modelValue', ['22']); await flushPromises()
|
||||
const save = wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
cards[2]!.vm.$emit('update:modelValue', ['23']); await flushPromises()
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ masterLicenseFileId: null, reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: ['23'] } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes the selected driver master license when reopening resource maintenance', async () => {
|
||||
const value = facts()
|
||||
value.resources = { vehicle: null, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['30'], masterLicenseFileId: '30' } } } as TransportDetail['resources']
|
||||
@@ -126,7 +228,7 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
|
||||
value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
|
||||
const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
|
||||
const rows = wrapper.get('[aria-label="取货明细"]').findAll('.pickup-detail-card')
|
||||
expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
|
||||
expect(rows[0]!.text()).toContain('取货联系人甲')
|
||||
expect(rows[0]!.text()).toContain('0000000001')
|
||||
@@ -137,3 +239,24 @@ it('keeps pickup addresses and contacts on their own transfer rows', async () =>
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders every pickup and arrival photo inline after completion and has no resource ending actions', async () => {
|
||||
const value = facts(true); value.execution.trip.status = 'COMPLETED'
|
||||
value.pickups[0]!.status = 'ARRIVED'
|
||||
value.pickups[0]!.evidence = [
|
||||
{ fileId: '101', fileName: '取货一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
|
||||
{ fileId: '102', fileName: '取货二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'PICKUP' },
|
||||
{ fileId: '103', fileName: '送达一.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
{ fileId: '104', fileName: '送达二.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
{ fileId: '105', fileName: '送达三.jpg', contentType: 'image/jpeg', sizeBytes: 10, phase: 'ARRIVAL' },
|
||||
]
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
vi.mocked(downloadTransportEvidence).mockResolvedValue(new Blob(['photo'], { type: 'image/jpeg' }))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, pickupStage: 'DELIVERED', readonly: true } }); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="取货照片"]').findAll('img')).toHaveLength(2)
|
||||
expect(wrapper.get('[aria-label="送达照片"]').findAll('img')).toHaveLength(3)
|
||||
for (const file of value.pickups[0]!.evidence) expect(downloadTransportEvidence).toHaveBeenCalledWith('PC1', file.fileId, expect.objectContaining({ customerId: 'C1', transferNo: 'WT1' }))
|
||||
expect(wrapper.text()).not.toContain('结束车辆任务'); expect(wrapper.text()).not.toContain('结束司机任务')
|
||||
const revoke = vi.mocked(URL.revokeObjectURL); revoke.mockClear()
|
||||
wrapper.unmount(); expect(revoke).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { Truck, Package, RefreshCw } from '@lucide/vue'
|
||||
import { Truck, Package, RefreshCw, Trash2 } 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'
|
||||
@@ -12,32 +12,63 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import TransportPhotoGallery from './TransportPhotoGallery.vue'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, 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; pickupReviewState?: string; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; pickupStage?: 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 vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
|
||||
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
|
||||
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
|
||||
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
|
||||
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
|
||||
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
|
||||
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
|
||||
const truckPreviewUrls = ref<Record<string, string>>({})
|
||||
const arrivalMode = ref(false)
|
||||
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
|
||||
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 pending = ref<{ suffix: string; body: object; kind?: 'cancel' } | null>(null)
|
||||
const trip = computed(() => detail.value?.execution.trip)
|
||||
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 canChangeResources = computed(() => trip.value?.status === 'PLANNED' && props.pickupStage !== 'LOADING' && !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 || licenseLoading.value || Object.values(photoBusy).some(Boolean))
|
||||
const completionAction = computed(() => {
|
||||
if (detail.value?.taskType !== 'LOCAL_TRUCK' || !editable.value || resourceFormOpen.value || !props.pickupApproved || !trip.value) return null
|
||||
if (props.pickupStage === 'LOADING' && trip.value.status === 'PLANNED') {
|
||||
return { action: 'depart' as const, label: '完成', missingPhotoMessage: '请至少上传一张取货照片' }
|
||||
}
|
||||
if (props.pickupStage === 'DELIVERING' && trip.value.status === 'IN_TRANSIT') {
|
||||
return { action: 'complete' as const, label: '完成本单', missingPhotoMessage: '请至少上传一张送达照片' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const completionVisible = computed(() => !!completionAction.value)
|
||||
const completionText = computed(() => completionAction.value?.label || '完成')
|
||||
const completionLoading = computed(() => busy.value && !!pending.value && ['truck/depart', 'truck/complete'].includes(pending.value.suffix))
|
||||
const completionDisabled = computed(() => locked.value || (completionAction.value?.action === 'depart' && (!trip.value?.vehicleId || !trip.value?.driverId)))
|
||||
async function submitCompletion() {
|
||||
const action = completionAction.value
|
||||
if (!action || completionDisabled.value) return
|
||||
const evidence = action.action === 'depart' ? truckPickupEvidence.value : truckDeliveryEvidence.value
|
||||
if (!evidence.length) {
|
||||
ElMessage.warning(action.missingPhotoMessage)
|
||||
return
|
||||
}
|
||||
if (action.action === 'depart' && !access.can(permission.DEPART)) {
|
||||
ElMessage.warning('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
|
||||
return
|
||||
}
|
||||
await command(action.action)
|
||||
}
|
||||
defineExpose({ completionVisible, completionText, completionLoading, completionDisabled, submitCompletion })
|
||||
/** 本车已保存的照片随资源带出;换车不能继续显示原车照片。 */
|
||||
function restoreVehiclePhotos() {
|
||||
const saved = detail.value?.resources?.vehicle
|
||||
@@ -49,8 +80,9 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
|
||||
let current = true
|
||||
onCleanup(() => { current = false })
|
||||
licenseLoading.value = false
|
||||
if (!open || detail.value?.taskType !== 'PICKUP') return
|
||||
const saved = detail.value.resources?.driver
|
||||
const transport = detail.value
|
||||
if (!open || !transport || !['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType)) return
|
||||
const saved = transport.resources?.driver
|
||||
const same = saved?.driverId === id ? saved.evidence : null
|
||||
driverPhotos.value = [...(same?.photoFileIds || [])]
|
||||
licensePhotos.value = (same?.fileIds || []).filter(file => file !== same?.masterLicenseFileId)
|
||||
@@ -63,9 +95,28 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
|
||||
} catch (cause) { if (current) error.value = getApiErrorDetails(cause, '驾驶证带出失败,可重新读取或上传照片') }
|
||||
finally { if (current) licenseLoading.value = false }
|
||||
}, { immediate: true })
|
||||
function clearTruckPreviewUrls() { Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = {} }
|
||||
async function refreshTruckPreviews(files: PickupEvidence[]) {
|
||||
const results = await Promise.allSettled(files.map(async file => [file.fileId, URL.createObjectURL(await downloadTransportEvidence(props.dispatchNo, file.fileId))] as const))
|
||||
const next: Record<string, string> = {}
|
||||
results.forEach(result => { if (result.status === 'fulfilled') next[result.value[0]] = result.value[1] })
|
||||
Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = next
|
||||
}
|
||||
function setTruckPreview(fileId: string, file: File) {
|
||||
const old = truckPreviewUrls.value[fileId]
|
||||
if (old) URL.revokeObjectURL(old)
|
||||
truckPreviewUrls.value = { ...truckPreviewUrls.value, [fileId]: URL.createObjectURL(file) }
|
||||
}
|
||||
function removeTruckEvidence(fileId: string, pickup: boolean) {
|
||||
if (pickup) truckPickupEvidence.value = truckPickupEvidence.value.filter(file => file.fileId !== fileId)
|
||||
else truckDeliveryEvidence.value = truckDeliveryEvidence.value.filter(file => file.fileId !== fileId)
|
||||
const url = truckPreviewUrls.value[fileId]
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
const next = { ...truckPreviewUrls.value }; delete next[fileId]; truckPreviewUrls.value = next
|
||||
}
|
||||
async function load() {
|
||||
error.value = null
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; restoreVehiclePhotos() }
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; truckPickupEvidence.value = [...(detail.value.departureEvidence || [])]; truckDeliveryEvidence.value = [...(detail.value.completionEvidence || [])]; clearTruckPreviewUrls(); await refreshTruckPreviews([...truckPickupEvidence.value, ...truckDeliveryEvidence.value]); restoreVehiclePhotos() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
|
||||
}
|
||||
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
|
||||
@@ -81,9 +132,10 @@ async function candidates() {
|
||||
finally { candidateBusy.value = false }
|
||||
}
|
||||
async function assign() {
|
||||
if (!trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
|
||||
const transport = detail.value
|
||||
if (!transport || !trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.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() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; 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() || '分配运输车辆及司机', ...(['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType) ? { dispatchRecordVersion: transport.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
@@ -92,33 +144,37 @@ async function execute() {
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
try {
|
||||
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
pending.value = null; pickupOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { pending.value = null; busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
if (!row && action === 'depart' && !truckPickupEvidence.value.length) { ElMessage.warning('请至少上传一张取货照片'); return }
|
||||
if (!row && action === 'complete' && !truckDeliveryEvidence.value.length) { ElMessage.warning('请至少上传一张送达照片'); return }
|
||||
const message = { depart: '', complete: '确认已在 送达并完成本土卡车订单?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
const title = action === 'depart' ? '确认取货完成' : '确认运输操作'
|
||||
try { await ElMessageBox.confirm(message, title, { 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) } : {}) } }
|
||||
const commandReason = reason.value.trim() || (action === 'depart' && prefix === 'truck' ? '确认 取货完成' : message || '确认运输操作')
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: commandReason, ...(prefix === 'truck' && action === 'depart' ? { evidenceFileIds: truckPickupEvidence.value.map(file => file.fileId) } : {}), ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckDeliveryEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
|
||||
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; 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, '凭证下载失败') }
|
||||
}
|
||||
async function upload(event: Event, pickup: boolean) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
|
||||
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); if (!files.length || locked.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
for (const file of files) {
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file)
|
||||
if (pickup && row) evidence.value.push(result)
|
||||
else if (trip.value?.status === 'PLANNED') { truckPickupEvidence.value.push(result); setTruckPreview(result.fileId, file) }
|
||||
else { truckDeliveryEvidence.value.push(result); setTruckPreview(result.fileId, file) }
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { pending.value = null; busy.value = false; input.value = '' }
|
||||
}
|
||||
@@ -128,12 +184,8 @@ async function picked() {
|
||||
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() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
|
||||
onBeforeUnmount(clearTruckPreviewUrls)
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
@@ -141,13 +193,10 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
|
||||
<template v-if="detail && trip">
|
||||
<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 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 link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -164,7 +213,7 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
<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: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
|
||||
@@ -175,22 +224,36 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
</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="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
|
||||
<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="180" 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>
|
||||
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
|
||||
<div v-for="row in detail.pickups" :key="`${row.customerId}:${row.transferNo}`" class="pickup-detail-card">
|
||||
<div class="pickup-detail-heading"><strong>{{ row.transferNo }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></div>
|
||||
<dl class="transport-facts pickup-detail-facts">
|
||||
<div><dt>取货地址</dt><dd>{{ row.pickupAddress || '—' }}</dd></div><div><dt>联系人</dt><dd>{{ row.contactName || '—' }}</dd></div><div><dt>联系电话</dt><dd>{{ row.contactPhone || '—' }}</dd></div><div><dt>预报 / 实际箱数</dt><dd>{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</dd></div>
|
||||
<div><dt>取货照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'PICKUP')" label="取货照片" /></dd></div><div><dt>送达照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'ARRIVAL')" label="送达照片" /></dd></div>
|
||||
</dl>
|
||||
<div v-if="!readonly" class="pickup-detail-actions"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template></div>
|
||||
</div>
|
||||
</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>
|
||||
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'LOADING' && trip.status === 'PLANNED'" class="truck-evidence-panel" aria-label="取货照片上传">
|
||||
<h2 class="workbench-section-title">取货照片</h2>
|
||||
<label class="truck-evidence-upload">上传取货照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除取货照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, true)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && !props.readonly && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT' && truckPickupEvidence.length" class="truck-evidence-panel" aria-label="取货照片">
|
||||
<h2 class="workbench-section-title">取货照片</h2>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-readonly-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</div>
|
||||
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT'" class="truck-evidence-panel" aria-label="送达照片上传">
|
||||
<h2 class="workbench-section-title">送达照片</h2>
|
||||
<label class="truck-evidence-upload">上传送达照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
|
||||
<div class="truck-evidence-list"><figure v-for="file in truckDeliveryEvidence" :key="`delivery-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除送达照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, false)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
|
||||
</section>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && (props.readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)) && (detail.departureEvidence?.length || detail.completionEvidence?.length)" class="truck-evidence-panel">
|
||||
<template v-if="detail.departureEvidence?.length"><h2 class="workbench-section-title">取货照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.departureEvidence || []" :key="`departure-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure>
|
||||
</div></template><template v-if="detail.completionEvidence?.length"><h2 class="workbench-section-title">送达照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.completionEvidence || []" :key="`completion-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div></template>
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="min(760px, calc(100vw - 40px))" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<p>{{ selected?.transferNo }}</p>
|
||||
<el-form label-position="left" label-width="150px" :disabled="locked">
|
||||
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
|
||||
@@ -203,7 +266,6 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
</el-form>
|
||||
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @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>
|
||||
|
||||
@@ -223,5 +285,24 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
||||
.transport-review-photos { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
|
||||
.transport-photo-hint, .transport-resource-save { grid-column: 1 / -1; }
|
||||
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
|
||||
.pickup-detail-card { display: grid; gap: var(--admin-space-4); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); }
|
||||
.pickup-detail-heading, .pickup-detail-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
|
||||
.pickup-detail-facts { margin: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.pickup-detail-facts > div { align-items: start; }
|
||||
.pickup-detail-facts dd { display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
||||
.truck-evidence-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); background: var(--cc-surface); }
|
||||
.truck-evidence-panel > h2 { flex: 0 0 100%; margin: 0; }
|
||||
.truck-evidence-panel label { display: inline-flex; align-items: center; gap: 10px; color: var(--cc-text); font-size: 13px; }
|
||||
.truck-evidence-upload { padding: 8px 14px; border: 1px solid var(--el-color-primary); border-radius: var(--cc-radius-sm); color: var(--el-color-primary) !important; cursor: pointer; }
|
||||
.truck-evidence-upload input[type='file'] { display: none; }
|
||||
.truck-evidence-list { display: flex; flex-wrap: wrap; gap: 12px; color: var(--cc-muted); font-size: 12px; }
|
||||
.truck-evidence-item { width: 132px; margin: 0; }
|
||||
.truck-evidence-item { position: relative; }
|
||||
.truck-evidence-item .el-image { display: block; width: 132px; height: 92px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
|
||||
.truck-evidence-item img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.truck-evidence-delete { position: absolute; z-index: 2; top: 4px; right: 4px; width: 26px; height: 26px; color: var(--el-color-danger) !important; background: rgb(255 255 255 / 88%) !important; box-shadow: 0 1px 4px rgb(0 0 0 / 18%); }
|
||||
.truck-evidence-item figcaption { overflow: hidden; margin-top: 4px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.truck-evidence-item .el-button { padding: 2px 0; }
|
||||
@media (max-width: 760px) { .pickup-detail-facts { grid-template-columns: 1fr; } }
|
||||
@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>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { PickupEvidence, PickupTask } from '@/modules/overseas-master-data'
|
||||
import { downloadTransportEvidence } from '../api/transportTasks'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string; row: PickupTask; files: PickupEvidence[]; label: string }>()
|
||||
const urls = ref<Record<string, string>>({}), failed = ref<string[]>([])
|
||||
let generation = 0
|
||||
function clear() { Object.values(urls.value).forEach(url => URL.revokeObjectURL(url)); urls.value = {} }
|
||||
// 按客户及转运单读取每张原始照片;切单、刷新或卸载后丢弃迟到响应并释放预览。
|
||||
async function load() {
|
||||
const request = ++generation, number = props.dispatchNo, row = props.row
|
||||
clear(); failed.value = []
|
||||
await Promise.all(props.files.map(async file => {
|
||||
try {
|
||||
const blob = await downloadTransportEvidence(number, file.fileId, row)
|
||||
if (request === generation) urls.value[file.fileId] = URL.createObjectURL(blob)
|
||||
} catch { if (request === generation) failed.value.push(file.fileId) }
|
||||
}))
|
||||
}
|
||||
watch(() => [props.dispatchNo, props.row.customerId, props.row.transferNo, props.files.map(file => file.fileId).join(',')], load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; clear() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-photo-gallery" :aria-label="label">
|
||||
<figure v-for="file in files" :key="file.fileId">
|
||||
<el-image v-if="urls[file.fileId]" :src="urls[file.fileId]" :preview-src-list="files.map(item => urls[item.fileId]).filter((url): url is string => !!url)" :initial-index="files.filter(item => !!urls[item.fileId]).findIndex(item => item.fileId === file.fileId)" preview-teleported fit="contain" :alt="`${label}:${file.fileName}`" />
|
||||
<el-button v-else-if="failed.includes(file.fileId)" link @click="load">加载失败,点击重试</el-button>
|
||||
<span v-else>照片加载中</span>
|
||||
<figcaption :title="file.fileName">{{ file.fileName }}</figcaption>
|
||||
</figure>
|
||||
<span v-if="!files.length">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-photo-gallery { display: flex; flex-wrap: wrap; gap: 12px; min-width: 0; }
|
||||
figure { width: 132px; margin: 0; }
|
||||
.el-image { display: block; width: 132px; height: 92px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
|
||||
figcaption { overflow: hidden; margin-top: 4px; font-size: 12px; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -4,14 +4,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
|
||||
const reviewedTransfer = { id: '1', transferNo: 'WT-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [], cartons: [], pallets: [], relabelBindings: [] }
|
||||
const reviewedTransfer = { id: '1', transferNo: 'WT-1', operatingWarehouseId: 'WH-1', packageCount: 2, needRelabel: false, status: 'WAIT_PDA_RECEIVING', items: [{ skuCode: 'SKU-1', packageCount: 2 }], cartons: [], pallets: [], relabelBindings: [] }
|
||||
|
||||
vi.mock('../api/warehouseTransfers', () => ({
|
||||
getWarehouseTransfer: vi.fn(),
|
||||
reviewWarehouseTransfer: vi.fn(),
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
|
||||
const AppDialogStub = defineComponent({
|
||||
name: 'AppDialog',
|
||||
@@ -33,17 +35,21 @@ function mountDialog() {
|
||||
}
|
||||
|
||||
describe('WarehouseTransferReviewDialog', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getWarehouseTransfer).mockResolvedValue(reviewedTransfer as never); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: 'WH-1', warehouseName: '洛杉矶操作仓' }] as never) })
|
||||
|
||||
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
|
||||
it('shows concise review facts without a detail table or editable review inputs', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('仓储转运单审核 · WT-1')
|
||||
expect(wrapper.text()).toContain('测试客户')
|
||||
expect(wrapper.text()).toContain('预报箱数')
|
||||
expect(wrapper.text()).toContain('洛杉矶操作仓')
|
||||
expect(wrapper.text()).not.toContain('货物')
|
||||
expect(wrapper.text()).not.toContain('审核通过后,转运单将进入海外仓收货打托作业。')
|
||||
expect(wrapper.find('.back').text()).toBe('返回')
|
||||
expect(wrapper.find('.approve').text()).toBe('审核通过')
|
||||
expect(wrapper.find('textarea, input, table').exists()).toBe(false)
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('20001', 'WT-1')
|
||||
})
|
||||
|
||||
it('return closes without approving or rejecting the order', async () => {
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; customerId: string; customerName: string; transferNo: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; reviewed: [] }>()
|
||||
const open = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||||
const operatingWarehouseName = ref('')
|
||||
|
||||
async function loadDetail() {
|
||||
if (!open.value || !props.customerId || !props.transferNo) return
|
||||
loading.value = true
|
||||
try { detail.value = await getWarehouseTransfer(props.customerId, props.transferNo) }
|
||||
catch { detail.value = null }
|
||||
try {
|
||||
const warehouseId = detail.value?.operatingWarehouseId
|
||||
if (warehouseId) {
|
||||
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
|
||||
operatingWarehouseName.value = warehouses.find(item => String(item.id) === String(warehouseId))?.warehouseName || ''
|
||||
} else operatingWarehouseName.value = ''
|
||||
} catch { operatingWarehouseName.value = '' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch(() => [open.value, props.customerId, props.transferNo] as const, ([visible]) => { if (visible) void loadDetail() }, { immediate: true })
|
||||
|
||||
const deliveryMethodLabel = computed(() => detail.value?.deliveryMethod === 'WAREHOUSE_PICKUP' ? '上门取货' : detail.value?.deliveryMethod === 'CUSTOMER_DELIVERY' ? '客户送货到仓' : detail.value?.deliveryMethod || '—')
|
||||
const destinationLabel = computed(() => detail.value?.destinationWarehouseName || detail.value?.destinationWarehouseCode || detail.value?.fbaWarehouseCode || '—')
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || !open.value || !props.customerId || !props.transferNo) return
|
||||
@@ -33,13 +57,35 @@ async function submit() {
|
||||
v-model="open"
|
||||
class="admin-density-dialog density-confirm-dialog admin-workbench is-dialog"
|
||||
:title="`仓储转运单审核 · ${transferNo || '—'}`"
|
||||
width="min(420px, calc(100vw - 28px))"
|
||||
width="min(680px, calc(100vw - 28px))"
|
||||
align-center
|
||||
cancel-text="返回"
|
||||
confirm-text="审核通过"
|
||||
:confirm-loading="saving"
|
||||
@confirm="submit"
|
||||
>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="转运审核对象"><p class="density-dialog-note">客户:{{ customerName || '—' }}</p></section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="转运审核明细">
|
||||
<div v-if="loading" class="density-dialog-note">正在读取转运明细…</div>
|
||||
<dl class="review-facts">
|
||||
<div><dt>客户</dt><dd>{{ customerName || '—' }}</dd></div>
|
||||
<div><dt>转运单号</dt><dd>{{ transferNo || '—' }}</dd></div>
|
||||
<div><dt>送货方式</dt><dd>{{ deliveryMethodLabel }}</dd></div>
|
||||
<div><dt>操作仓</dt><dd>{{ operatingWarehouseName || detail?.operatingWarehouseId || '—' }}</dd></div>
|
||||
<div><dt>目的仓</dt><dd>{{ destinationLabel }}</dd></div>
|
||||
<div><dt>预报箱数</dt><dd>{{ detail?.packageCount ?? '—' }} 箱</dd></div>
|
||||
<div><dt>换标</dt><dd>{{ detail?.needRelabel ? '需要' : '不需要' }}</dd></div>
|
||||
<div class="review-facts-wide"><dt>送货地址</dt><dd>{{ detail?.deliveryAddress || '—' }}</dd></div>
|
||||
<div class="review-facts-wide"><dt>备注</dt><dd>{{ detail?.remark || '—' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; margin: 0; }
|
||||
.review-facts > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; min-width: 0; }
|
||||
.review-facts dt { color: var(--cc-muted); }
|
||||
.review-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.review-facts-wide { grid-column: 1 / -1; }
|
||||
@media (max-width: 560px) { .review-facts { grid-template-columns: 1fr; } .review-facts-wide { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
||||
}
|
||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||
MARK_EXCEPTION: '标记异常', CREATE: '新建排车', EDIT: '维护车辆', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
SUBMIT_REVIEW: '提交审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
|
||||
}
|
||||
export const dispatchActionPermissions: Record<DispatchAction, string> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user