Files
admin-web/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue
T
2026-10-30 18:47:19 +08:00

279 lines
24 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 { 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'
import { overseasWarehousePermissions as permission } from '../permissions'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean; actionsTarget?: string }>()
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 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)
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)
const processingMethod = ref('standard')
const otherMethod = computed(() => processingMethod.value === 'other' && allowed('FORCE_CLOSE'))
// 已生成的退回单由 PDA 验收;生成入口与后台完成入口不能同时开放。
const canComplete = computed(() => allowed('COMPLETE_EXCEPTION') && !detail.value?.returnOrderNo && !allowed('GENERATE_RETURN_INBOUND'))
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()
processingMethod.value = 'standard'
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 (current(ticket)) error.value = getApiErrorDetails(cause, '证据上传失败,请重新选择文件').message
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
}
async function execute(action: ExceptionAction) {
if (action === 'complete' && !canComplete.value) return
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION', 'force-close': 'FORCE_CLOSE' }[action]
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
if (pending.value && pending.value.action !== action) return
if (!pending.value) {
if (action === 'force-close' && !notes.value.trim()) { error.value = '请填写其他处理方式的完结原因'; return }
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
}
if (action === 'complete' && warehouseReturn.value && selected.some(row => row.returnCartonId && (row.quantity !== 1 || row.maxReturnPackages !== 1))) { error.value = '原退货箱必须按原箱整箱实收,请核对原箱及数量'; return }
try { if (action === 'complete' && warehouseReturn.value) selected.forEach(returnSourceReference) }
catch { 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 === 'force-close') 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, ...returnSourceReference(row), 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 === 'force-close' ? '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
error.value = text
} finally {
if (sameAttempt()) pendingByScope.delete(scope)
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)
}
/** 生成动作只建单,实物入账由PDA完成。 */
async function generateReturn() {
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
const ticket = sequence, scope = identity.value
busy.value = true; error.value = ''
try {
const result = await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!)
if (!result?.orderNo) throw new Error('生成结果无法核对,请重新打开详情')
if (current(ticket)) {
busy.value = false; emit('changed'); await load()
if (props.active && identity.value === scope) message.value = `已生成退回尾程单 ${result.orderNo},请在 PDA 退货入库中验收`
}
}
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])
}
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>
<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>
<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 workbench-evidence">
<h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
<el-descriptions class="density-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 class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item v-if="allowed('FORCE_CLOSE')" label="处理方式">
<el-select v-model="processingMethod" data-test="processing-method" :disabled="locked">
<el-option value="standard" :label="allowed('GENERATE_RETURN_INBOUND') ? '退回海外仓' : '按流程处理'" />
<el-option value="other" label="其他" />
</el-select>
</el-form-item>
<el-form-item :label="otherMethod ? '完结原因(必填)' : '处理说明 / 取消原因(选填)'" :required="!!otherMethod" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" :placeholder="otherMethod ? '请填写其他处理方式及完结原因' : '请输入处理说明或取消原因(选填)'" /></el-form-item>
<template v-if="canComplete && !otherMethod">
<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="returnSourceKey(row)">
<p>{{ row.sourcePalletNo }} · <span v-if="row.returnCartonId">原退货箱 {{ row.cartonMark || '—' }}(须整箱实收)</span><span v-else>SKU {{ row.skuCode || '—' }}</span> · 剩余可收 {{ 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>
<p v-if="otherMethod">填写原因后直接完结此异常单,无需上传资料。关联退回单及货物实收仍按实际业务处理。</p>
</el-form>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer">
<el-button v-if="otherMethod" data-test="force-close" type="primary" :disabled="locked" @click="execute('force-close')">完结异常处理</el-button>
<template v-else>
<el-button v-if="allowed('GENERATE_RETURN_INBOUND') && !detail.returnOrderNo" data-test="generate-return" 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="canComplete" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</template>
</div>
</Teleport>
</template>
</template>
</section>
</template>
<style scoped>
.exception-processing-form { margin-top: var(--admin-space-5); }
.exception-detail-fields :deep(.el-descriptions__table) { table-layout: fixed; }
.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(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
</style>