Files
admin-web/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue
T

239 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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 { getPallet, listPallets, type PalletDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
const dispatchStatusLabels: Record<string, string> = { PENDING_DEPARTURE: '待发车', PICKING: '拣货中', READY_TO_DEPART: '待发车', DEPARTED: '已发车', IN_TRANSIT: '在途', COMPLETED: '已完成', CANCELLED: '已取消' }
const dispatchStatusLabel = (status: string) => dispatchStatusLabels[status] || '—'
const identity = computed(() => `${props.customerId}:${props.exceptionNo}`)
const detail = ref<ExceptionDetail | null>(null), loading = ref(false), busy = ref(false), error = ref(''), message = ref('')
type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity: number | undefined; target: string; palletNo: string; locationId: string; locationCode: string }
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
const pendingByScope = reactive(new Map<string, Attempt>())
const unknownUploads = reactive(new Set<string>())
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
const selectedHistory = ref<string[]>([])
const inFlight = reactive(new Map<string, string>())
const uploadingScopes = reactive(new Set<string>())
const pending = computed(() => pendingByScope.get(identity.value))
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
let sequence = 0
const targetSequences = new Map<number, number>()
const current = (ticket: number) => props.active && ticket === sequence
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
async function load() {
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
try {
const result = await getExceptionDetail(props.customerId, props.exceptionNo)
if (!current(ticket)) return
if (result.order.exceptionNo !== props.exceptionNo || result.order.customerId !== props.customerId || !Number.isSafeInteger(result.order.version)) throw new Error('异常详情归属或版本无效,请重新加载')
detail.value = result
receipts.value = result.returnSources.map(row => ({ ...row, sourcePalletNo: row.palletNo || '—', quantity: undefined, target: '', palletNo: '', locationId: '', locationCode: '' }))
evidence.value = [...(savedEvidence.get(identity.value) || [])]
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '异常详情加载失败').message }
finally { if (current(ticket)) loading.value = false }
}
async function searchTargets(keyword: string) {
const ticket = sequence, warehouse = detail.value?.order.overseasWarehouseId
if (!warehouse || locked.value || !canProcess.value) return
try {
const result = await listPallets(warehouse, { page: 1, size: 100, keyword })
if (current(ticket)) targets.value = result.records.filter(row => String(row.warehouseId) === String(warehouse) && !!row.locationId && row.status !== 'SCRAPPED')
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '目标托盘加载失败').message }
}
function onTargetVisibility(visible: boolean) {
if (visible) void searchTargets('')
}
function onTargetChange(index: number, value: unknown) {
void chooseTarget(index, String(value))
}
async function chooseTarget(index: number, palletId: string) {
const ticket = sequence, row = receipts.value[index], warehouse = detail.value?.order.overseasWarehouseId
if (!row || !warehouse || locked.value) return
const targetTicket = (targetSequences.get(index) || 0) + 1; targetSequences.set(index, targetTicket)
row.target = palletId; row.palletNo = ''; row.locationId = ''; row.locationCode = ''
const valid = () => current(ticket) && targetSequences.get(index) === targetTicket
try {
const pallet = await getPallet(warehouse, palletId)
if (!valid()) return
if (String(pallet.warehouseId) !== String(warehouse) || !pallet.locationId || String(pallet.id) !== palletId) throw new Error('托盘仓库或库位不匹配')
const location = await getOverseasLocation(warehouse, String(pallet.locationId))
if (!valid()) return
if (String(location.id) !== String(pallet.locationId) || String(location.warehouseId) !== String(warehouse) || !location.locationCode)
throw new Error('目标托盘实际库位归属不匹配')
row.palletNo = pallet.palletCode; row.locationId = String(location.id); row.locationCode = location.locationCode
} catch (cause) { if (valid()) error.value = getApiErrorDetails(cause, '托盘库位核对失败').message }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]
if (!file || !canProcess.value || locked.value) return
const ticket = sequence, scope = identity.value, customer = props.customerId, number = props.exceptionNo
uploadingScopes.add(scope)
busy.value = true; error.value = ''
try {
const result = await uploadExceptionEvidence(customer, number, file)
if (!result || !/^[1-9]\d*$/.test(String(result.fileId)) || !result.fileName) throw new Error('证据上传结果无法核对')
const fileResult = { fileId: String(result.fileId), fileName: result.fileName }
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
if (current(ticket)) evidence.value.push(fileResult)
} catch (cause) {
if (!definite(cause)) unknownUploads.add(scope)
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
}
async function execute(action: ExceptionAction) {
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
if (pending.value && pending.value.action !== action) return
if (!pending.value) {
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
const selected = receipts.value.filter(row => Number(row.quantity) > 0)
if (action === 'complete' && warehouseReturn.value && (!selected.length || selected.some(row => !Number.isSafeInteger(row.quantity) || Number(row.quantity) > row.maxReturnPackages || !row.palletNo || !row.locationId || !row.locationCode))) {
error.value = '请按剩余可收量填写本次实收,并选择已核对真实库位的目标托盘'; return
}
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
if (action === 'complete') {
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, inventoryPositionId: row.inventoryPositionId, receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
}
pendingByScope.set(identity.value, { action, payload })
}
const ticket = sequence, scope = identity.value, attempt = pendingByScope.get(scope)!, customer = props.customerId, number = props.exceptionNo
let responseSequence = ticket
const requestKey = attempt.payload.idempotencyKey
const sameAttempt = () => pendingByScope.get(scope)?.payload.idempotencyKey === requestKey
inFlight.set(scope, requestKey)
busy.value = true; error.value = ''; message.value = ''
try {
const result = await commandException(customer, number, attempt.action, attempt.payload)
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
throw new Error('处理结果无法核对,请保留原请求重试')
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
if (!current(ticket)) return
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
emit('changed'); responseSequence = sequence + 1; await load()
} catch (cause) {
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
if (!current(ticket)) return
const text = getApiErrorDetails(cause, '处理请求失败').message
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
} finally {
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
if (current(responseSequence)) busy.value = false
}
}
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)
}
function removeEvidence(index: number) {
if (locked.value) return
evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value])
}
watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++sequence; busy.value = false; message.value = ''; if (props.active) void load() }, { immediate: true })
</script>
<template>
<section v-loading="loading" aria-label="异常处理详情">
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
<template v-if="detail">
<el-descriptions :column="2" border>
<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>
<el-descriptions-item label="仓库">{{ detail.order.overseasWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="尾程订单号">{{ detail.order.lastMileOrderNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="原入仓码">{{ detail.order.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="派送单号">{{ detail.order.deliveryNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="出库单号">{{ detail.order.outboundNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="异常箱数">{{ detail.order.packageCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="SKU">{{ detail.order.skuCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="退回物流单号">{{ detail.order.returnLogisticsNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ detail.order.handlerName && !/^\d+$/.test(detail.order.handlerName) ? detail.order.handlerName : '—' }}</el-descriptions-item>
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detail.order.exceptionAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detail.order.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="异常原因" :span="2">{{ detail.order.reason }}</el-descriptions-item>
<el-descriptions-item label="处理动作" :span="2">{{ detail.order.handlingAction || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理结果" :span="2">{{ detail.order.handlingResult || '—' }}</el-descriptions-item>
</el-descriptions>
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history">
<h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
<el-descriptions :column="3" border>
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
</el-descriptions>
<p v-if="!receipt.redispatches.length">尚未关联重派排车</p>
<table v-else class="exception-redispatch-table">
<thead><tr><th>重派排车单</th><th>排车状态</th><th>选定箱数</th><th>实际拣货</th><th>重派派送单</th></tr></thead>
<tbody><tr v-for="dispatch in receipt.redispatches" :key="dispatch.dispatchNo">
<td><RouterLink v-if="access.can(permission.dispatchList)" :to="`/overseas-warehouse/dispatches/${encodeURIComponent(dispatch.dispatchNo)}`">{{ dispatch.dispatchNo }}</RouterLink><span v-else>{{ dispatch.dispatchNo }}</span></td>
<td>{{ dispatchStatusLabel(dispatch.status) }}</td><td>{{ dispatch.selectedPackages }} 箱</td><td>{{ dispatch.pickedPackages }} 箱</td>
<td><span v-if="!dispatch.deliveries.length">—</span><div v-for="delivery in dispatch.deliveries" :key="delivery.deliveryNo">
<RouterLink v-if="access.can(permission.deliveryList)" :to="{ path: `/overseas-warehouse/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, query: { customerId } }">{{ delivery.deliveryNo }}</RouterLink><span v-else>{{ delivery.deliveryNo }}</span>
</div></td>
</tr></tbody>
</table>
</div>
</section>
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess">
<el-form label-position="top" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="可补充实际情况;留空按本次操作记录标准说明" /></el-form-item>
<template v-if="allowed('COMPLETE_EXCEPTION')">
<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>
<template v-if="warehouseReturn">
<p>只填写本批实际收到的箱数;未收齐保持处理中。目标库位取所选托盘的真实库位。</p>
<div v-for="(row, index) in receipts" :key="`${row.outboundPalletItemId}:${row.inventoryPositionId}`">
<p>{{ row.sourcePalletNo }} · SKU {{ row.skuCode || '—' }} · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
<!-- Element Plus initializes number-input ARIA on mount; remount when the form lock changes. -->
<el-form-item label="本批实收箱数"><el-input-number :key="locked ? 'locked' : 'editable'" v-model="row.quantity" :data-test="`quantity-${index}`" :min="0" :max="row.maxReturnPackages" :precision="0" placeholder="未收到可留空" /></el-form-item>
<el-form-item label="实际收货目标托盘"><el-select :model-value="row.target" :data-test="`target-${index}`" filterable remote :remote-method="searchTargets" placeholder="输入托盘码查询" @visible-change="onTargetVisibility" @update:model-value="onTargetChange(index, $event)"><el-option v-for="target in targets" :key="target.id" :value="String(target.id)" :label="target.palletCode" /></el-select></el-form-item>
<p>目标托盘:{{ row.palletNo || '—' }};实际库位:{{ row.locationCode || '—' }}</p>
</div>
</template>
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template>
</el-form>
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</template>
</template>
</section>
</template>
<style scoped>
.exception-return-history { margin: 12px 0; }
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
.exception-redispatch-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.exception-redispatch-table th, .exception-redispatch-table td { padding: 6px 8px; border: 1px solid var(--el-border-color-lighter); text-align: left; }
.exception-redispatch-table th { background: var(--el-fill-color-light); }
</style>