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

204 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
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'
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 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>
<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>
<el-form-item label="本批实收箱数"><el-input-number 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>