This commit is contained in:
2026-10-30 18:47:19 +08:00
parent 51d0877a45
commit 19a618c9d2
4 changed files with 94 additions and 13 deletions
@@ -30,12 +30,17 @@ 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
@@ -94,10 +99,12 @@ async function upload(event: Event) {
} 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 (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)
@@ -110,6 +117,7 @@ async function execute(action: ExceptionAction) {
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 })) : []
@@ -124,7 +132,7 @@ async function execute(action: ExceptionAction) {
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']
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) }
@@ -149,9 +157,16 @@ function selectHistory(fileId: string, event: Event) {
/** 生成动作只建单,实物入账由PDA完成。 */
async function generateReturn() {
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
const ticket = sequence
const ticket = sequence, scope = identity.value
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() } }
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 }
}
@@ -210,8 +225,14 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<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 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') && !detail.returnOrderNo">
<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>
@@ -227,13 +248,17 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</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="allowed('GENERATE_RETURN_INBOUND')" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
<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="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</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>