This commit is contained in:
2026-10-30 17:59:39 +08:00
parent 8cc8b4fb94
commit bef074097a
14 changed files with 104 additions and 15 deletions
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getDispatchReturnOptions } from '../api/dispatches'
import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts'
import { getApiErrorDetails } from '@/shared/api/http'
const props = defineProps<{ dispatchNo: string; disabled: boolean }>()
const selections = defineModel<DispatchReturnSelection[]>({ default: () => [] })
const candidates = ref<DispatchReturnCandidate[]>([]), loading = ref(false), error = ref('')
const sourceId = ref(''), quantities = ref<number[]>([])
const source = computed(() => candidates.value.find(row => row.lastMileOrderId === sourceId.value))
/** 原单切换时清空旧货物,避免把另一单数量提交到当前来源。 */
function choose() { quantities.value = []; selections.value = [] }
/** 仅提交用户明确填写的原出库来源与退回箱数。 */
function update() {
const row = source.value
selections.value = row ? [{ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
items: row.sources.flatMap((item, index) => quantities.value[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[index]! }] : []) }] : []
}
/** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */
async function load() {
loading.value = true; error.value = ''; selections.value = []
try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) }
catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message }
finally { loading.value = false }
}
onMounted(load)
</script>
<template>
<section v-loading="loading" aria-label="选择退回货物">
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试</el-button></p>
<el-form-item label="来源订单" required><el-select v-model="sourceId" :disabled="disabled || loading" placeholder="请选择本车来源订单" @change="choose">
<el-option v-for="row in candidates" :key="row.lastMileOrderId" :value="row.lastMileOrderId" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
</el-select></el-form-item>
<table v-if="source" class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
</tbody></table>
</section>
</template>
@@ -15,7 +15,7 @@ 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 }
interface State { returnSelections: import('../dispatchContracts').DispatchReturnSelection[]; 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, 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()
@@ -209,6 +209,8 @@ it('requires an exception type and submits optional direction, description and i
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择异常类型')
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
state.returnSelections = [{ deliveryNo: 'DL1', lastMileOrderId: '9', items: [{ outboundPalletItemId: '1', inventoryPositionId: '2', returnCartonId: null, packageCount: 1 }] }]
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
@@ -1,7 +1,10 @@
<script setup lang="ts">
import DispatchReturnCargo from './DispatchReturnCargo.vue'
import type { DispatchReturnSelection } from '../dispatchContracts'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
const returnSelections = ref<DispatchReturnSelection[]>([])
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -96,6 +99,7 @@ async function load() {
}
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
fillDestinationAppointments(value)
returnSelections.value = []
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
@@ -135,7 +139,8 @@ function payload(): object {
switch (currentAction.value) {
case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
if (exception.returnDirection === 'OVERSEAS_WAREHOUSE' && (!returnSelections.value.length || returnSelections.value.some(row => !row.items.length))) throw new DispatchInputError('请选择来源订单及退回货物数量')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds], returnSelections: exception.returnDirection === 'OVERSEAS_WAREHOUSE' ? returnSelections.value : [] } satisfies MarkDispatchException
case 'EDIT':
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车牌图片;当前图片尚未保存')
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
@@ -260,6 +265,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template v-else-if="currentAction === 'MARK_EXCEPTION'">
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<DispatchReturnCargo v-if="exception.returnDirection === 'OVERSEAS_WAREHOUSE'" :key="currentNo" v-model="returnSelections" :dispatch-no="currentNo" :disabled="frozen || loading || !allowed" />
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
</template>
@@ -3,7 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
import { generateExceptionReturn, commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
import { getPallet, listPallets, type PalletDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -146,6 +146,15 @@ function selectHistory(fileId: string, event: Event) {
if (locked.value) return
selectedHistory.value = (event.target as HTMLInputElement).checked ? [...new Set([...selectedHistory.value, fileId])] : selectedHistory.value.filter(id => id !== fileId)
}
/** 生成动作只建单,实物入账由PDA完成。 */
async function generateReturn() {
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
const ticket = sequence
busy.value = true; error.value = ''
try { await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!); if (current(ticket)) { busy.value = false; emit('changed'); await load() } }
catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '生成退回入库单失败').message }
finally { if (current(ticket)) busy.value = false }
}
function removeEvidence(index: number) {
if (locked.value) return
evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value])
@@ -158,6 +167,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<template v-if="detail">
<el-descriptions class="density-descriptions exception-detail-fields" :column="2" border>
<el-descriptions-item v-if="detail.returnOrderNo" label="退回尾程单">{{ detail.returnOrderNo }}(PDA退货入库验收)</el-descriptions-item>
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
@@ -201,7 +211,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<template v-if="canProcess">
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
<template v-if="allowed('COMPLETE_EXCEPTION')">
<template v-if="allowed('COMPLETE_EXCEPTION') && !detail.returnOrderNo">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
@@ -220,6 +230,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</el-form>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer">
<el-button v-if="allowed('GENERATE_RETURN_INBOUND')" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>