184 lines
18 KiB
Vue
184 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import axios from 'axios'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||
import { getPallet, listPallets, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||
import { getOverseasLocation } from '../api/locations'
|
||
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
|
||
|
||
const props = defineProps<{ customerId: string; detail: WarehouseTransferDetailDto }>()
|
||
const emit = defineEmits<{ updated: [detail: WarehouseTransferDetailDto] }>()
|
||
const access = useAccessPreviewStore()
|
||
type Pending = { type: 'outbound'; body: OutboundWarehouseTransferPayload } | { type: 'receipt'; body: ReceiveWarehouseTransferPayload }
|
||
const pending = ref<Pending | null>(null)
|
||
const definiteRejection = ref(false), reselectAllowed = ref(false), rejectionEvidence = ref<ApiErrorDetails | null>(null)
|
||
function definiteBusinessRejection(cause: unknown) {
|
||
const codes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||
const statusKnown = (status?: number) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||
if (cause instanceof ApiResponseError) return codes.includes(cause.code) && statusKnown(cause.status)
|
||
return axios.isAxiosError<{ code?: string }>(cause) && !!cause.response && statusKnown(cause.response.status) && codes.includes(cause.response.data?.code || '')
|
||
}
|
||
const completedTargets = computed(() => props.detail.receiptCommands?.filter(c => c.status === 'COMPLETED').map(c => c.target).filter((t, i, all) => all.findIndex(v => v.warehouseId === t.warehouseId && v.palletId === t.palletId && v.locationId === t.locationId) === i) ?? [])
|
||
const previous = computed(() => completedTargets.value[0] ?? null)
|
||
function acknowledgedTarget(value: ReceiptTarget) { return completedTargets.value.find(t => t.warehouseId === value.warehouse.id && t.palletId === value.pallet.id && t.locationId === value.location.id) ?? null }
|
||
const unfinished = computed(() => props.detail.receiptCommands?.filter(c => c.status !== 'COMPLETED') ?? [])
|
||
const busy = ref(false), error = ref<ApiErrorDetails | null>(null), notice = ref(''), open = ref(false)
|
||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]), warehouseId = ref(''), keyword = ref(''), page = ref(1), total = ref(0)
|
||
const pallets = ref<PalletDto[]>([]), target = ref<ReceiptTarget | null>(null), sourceId = ref('')
|
||
let generation = 0
|
||
const canManage = computed(() => access.can('lastleg:warehouse-transfer:manage'))
|
||
const canResources = computed(() => canManage.value && access.can('platform:overseas-warehouse:warehouse:list') && access.can('platform:overseas-warehouse:pallet:list') && access.can('platform:overseas-warehouse:location:list'))
|
||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED'))
|
||
const outboundReady = computed(() => canManage.value && canOutboundTransfer(props.detail))
|
||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0 && !unfinished.value.length)
|
||
const selectedValid = computed(() => target.value && shipped.value.some(a => a.inventoryPositionId === sourceId.value) && receiptTargetValid(props.detail, target.value, acknowledgedTarget(target.value)))
|
||
const storageKey = () => `warehouse-transfer-movement:${props.customerId}:${props.detail.transferNo}`
|
||
function persist() { sessionStorage.setItem(storageKey(), JSON.stringify({ pending: pending.value, definiteRejection: definiteRejection.value })) }
|
||
function restore() {
|
||
generation++; busy.value = false; pending.value = null; error.value = null; notice.value = ''; open.value = false
|
||
definiteRejection.value = false; reselectAllowed.value = false; rejectionEvidence.value = null
|
||
warehouses.value = []; pallets.value = []; warehouseId.value = ''; target.value = null; sourceId.value = ''; keyword.value = ''; page.value = 1; total.value = 0
|
||
try {
|
||
const saved = sessionStorage.getItem(storageKey())
|
||
if (!saved) { recoverServerCommand(); return }
|
||
const value: { pending: Pending | null; definiteRejection?: boolean } = JSON.parse(saved)
|
||
if (value.pending && (!['outbound', 'receipt'].includes(value.pending.type) || !value.pending.body?.idempotencyKey)) throw new Error('invalid saved command')
|
||
pending.value = value.pending; definiteRejection.value = value.definiteRejection === true; recoverServerCommand()
|
||
} catch { error.value = { message: '本次操作记录无法读取,请联系管理员核对原操作,暂不能提交新操作。' }; busy.value = true }
|
||
}
|
||
function recoverServerCommand(command?: WarehouseTransferReceiptCommandDto) {
|
||
if (pending.value?.type === 'receipt' && props.detail.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === pending.value?.body.idempotencyKey)) { pending.value = null; persist() }
|
||
const server = command ?? (unfinished.value.length === 1 ? unfinished.value[0] : undefined)
|
||
if (server && canManage.value) {
|
||
definiteRejection.value = false; reselectAllowed.value = false
|
||
pending.value = { type: 'receipt', body: JSON.parse(JSON.stringify(server.request)) as ReceiveWarehouseTransferPayload }
|
||
notice.value = '已从本票服务端记录恢复原实收操作。'
|
||
}
|
||
}
|
||
watch(() => [props.customerId, props.detail.transferNo], restore, { immediate: true })
|
||
watch(() => props.detail.receiptCommands, () => { if (!busy.value) recoverServerCommand() })
|
||
async function checkCurrent() {
|
||
if (busy.value) return
|
||
const current = generation, customerId = props.customerId, no = props.detail.transferNo
|
||
busy.value = true; error.value = null
|
||
try {
|
||
const value = await getWarehouseTransfer(customerId, no)
|
||
if (current !== generation) return
|
||
if (value.transferNo !== no) throw new Error('返回的转运单不一致')
|
||
const command = pending.value
|
||
reselectAllowed.value = !!(command?.type === 'receipt' && definiteRejection.value && Array.isArray(value.receiptCommands)
|
||
&& !value.receiptCommands.some(c => c.request.idempotencyKey === command.body.idempotencyKey)
|
||
&& !value.receiptCommands.some(c => c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)
|
||
&& value.allocations?.some(a => a.inventoryPositionId === command.body.sourceInventoryPositionId && a.status === 'SHIPPED'))
|
||
if ((command?.type === 'outbound' && ['OUTBOUNDED', 'PARTIALLY_RECEIVED', 'RECEIVED'].includes(value.status))
|
||
|| (command?.type === 'receipt' && value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey))) { pending.value = null; persist() }
|
||
emit('updated', value)
|
||
notice.value = reselectAllowed.value ? '服务端确认原请求未建立实收命令,来源仍待实收,可重新选择目标。' : pending.value ? '已核对当前状态;原操作仍保留,可使用原内容确认结果。' : '当前转运状态已刷新。'
|
||
}
|
||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态核对失败') }
|
||
finally { if (current === generation) busy.value = false }
|
||
}
|
||
async function reselectTarget() {
|
||
if (!reselectAllowed.value || busy.value || pending.value?.type !== 'receipt' || !definiteRejection.value) return
|
||
const original = pending.value
|
||
if (error.value) rejectionEvidence.value = error.value
|
||
try { pending.value = null; definiteRejection.value = false; persist() }
|
||
catch (cause) { pending.value = original; definiteRejection.value = true; error.value = getApiErrorDetails(cause, '原操作记录无法更新,暂不能重新选择'); return }
|
||
reselectAllowed.value = false; target.value = null; sourceId.value = ''; keyword.value = ''; pallets.value = []; error.value = null
|
||
await openReceipt()
|
||
}
|
||
async function openReceipt() {
|
||
if (!receiptReady.value || busy.value || pending.value) return
|
||
open.value = true; busy.value = true; error.value = null; const current = generation
|
||
try { const result = await listTransferReceivingWarehouses(); if (current !== generation) return; warehouses.value = result.records; if (previous.value) { warehouseId.value = previous.value.warehouseId; await selectPallet(previous.value.palletId) } }
|
||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货仓加载失败') }
|
||
finally { if (current === generation) busy.value = false }
|
||
}
|
||
async function searchPallets(nextPage = 1) {
|
||
if (!warehouseId.value || pending.value || !canResources.value || busy.value) return
|
||
const current = generation, warehouse = warehouseId.value
|
||
busy.value = true; error.value = null; target.value = null
|
||
try { const result = await listPallets(warehouse, { page: nextPage, size: 20, keyword: keyword.value.trim() || undefined, status: 'IDLE' }); if (current !== generation || warehouse !== warehouseId.value) return; pallets.value = result.records; total.value = result.total; page.value = nextPage }
|
||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '目标托盘查询失败') }
|
||
finally { if (current === generation) busy.value = false }
|
||
}
|
||
async function selectPallet(id: string) {
|
||
if (pending.value || !canResources.value) return
|
||
const current = generation, warehouse = warehouseId.value
|
||
target.value = null; error.value = null; busy.value = true
|
||
try {
|
||
const w = warehouses.value.find(x => x.id === warehouse); if (!w) throw new Error('请选择有效收货仓')
|
||
const p = await getPallet(warehouse, id); if (!p.locationId) throw new Error('目标托盘尚未绑定库位,请在托盘管理中核对')
|
||
const l = await getOverseasLocation(warehouse, p.locationId)
|
||
if (current !== generation || warehouse !== warehouseId.value) return
|
||
const value = { warehouse: w, pallet: p, location: l }
|
||
if (!receiptTargetValid(props.detail, value, acknowledgedTarget(value))) throw new Error('目标托盘、库位状态或 FBA 目的仓不符合本票要求')
|
||
target.value = value
|
||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货资源核对失败') }
|
||
finally { if (current === generation) busy.value = false }
|
||
}
|
||
function changeWarehouse() { target.value = null; pallets.value = []; page.value = 1; total.value = 0 }
|
||
async function submit(type: 'outbound' | 'receipt') {
|
||
if (busy.value || !canManage.value || (type === 'receipt' && !canResources.value)) return
|
||
if (!pending.value) {
|
||
definiteRejection.value = false; reselectAllowed.value = false
|
||
try {
|
||
if (type === 'outbound') { if (!outboundReady.value) return; pending.value = { type, body: { idempotencyKey: crypto.randomUUID() } } }
|
||
else { if (!receiptReady.value || !target.value || !selectedValid.value) return; pending.value = { type, body: createReceiptCommand(props.detail, sourceId.value, target.value, crypto.randomUUID(), acknowledgedTarget(target.value)) } }
|
||
persist() // Preserve the exact command before transport, including across close/navigation/reload.
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '原操作记录保存失败,未提交请求'); pending.value = null; return }
|
||
}
|
||
const command = pending.value
|
||
if (command.type !== type) return
|
||
const current = generation, customer = props.customerId, no = props.detail.transferNo
|
||
busy.value = true; error.value = null
|
||
try {
|
||
const value = command.type === 'outbound' ? await outboundWarehouseTransfer(customer, no, command.body) : await receiveWarehouseTransfer(customer, no, command.body)
|
||
if (current !== generation) return
|
||
if (value.transferNo !== no) throw new ApiResponseError('操作响应与当前转运单不一致,请核对原操作', 'TRANSFER_IDENTITY_MISMATCH')
|
||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请保留原操作并只读核对', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||
pending.value = null; persist(); emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||
notice.value = command.type === 'outbound' ? '本票库存已确认出库。' : '本来源目标实收已确认。'
|
||
} catch (cause) { if (current === generation) { pending.value = command; definiteRejection.value = definiteBusinessRejection(cause); reselectAllowed.value = false; error.value = getApiErrorDetails(cause, '操作结果尚未确认,已保留原内容,请核对后重试原操作'); if (definiteRejection.value) rejectionEvidence.value = error.value; try { persist() } catch { /* Existing pre-transport record remains authoritative for retry. */ } } }
|
||
finally { if (current === generation) busy.value = false }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section aria-label="转运出库与目标实收" class="movement-panel">
|
||
<template v-if="canManage">
|
||
<el-button v-if="outboundReady && !pending" :disabled="busy" @click="open = !open">确认本票出库</el-button>
|
||
<el-button v-if="receiptReady && !pending" :disabled="busy" @click="openReceipt">登记目标仓实收</el-button>
|
||
<div v-if="open && outboundReady && !pending" aria-label="出库确认"><p>{{ detail.transferNo }} · {{ detail.packageCount }} 箱。确认实际移出后,全部已分配来源将发运。</p><el-button :loading="busy" @click="submit('outbound')">确认实际出库</el-button></div>
|
||
<div v-if="open && receiptReady && !pending" aria-label="目标仓实收选择">
|
||
<el-form label-position="top" class="movement-fields">
|
||
<el-form-item label="已发运来源"><el-select v-model="sourceId" aria-label="已发运来源" :disabled="busy" placeholder="请选择来源"><el-option v-for="a in shipped" :key="a.inventoryPositionId" :value="a.inventoryPositionId" :label="`${a.inboundCode || '—'} / ${a.palletNo || '—'} / ${a.skuCode || '—'} · ${a.packageCount} 箱`" /></el-select></el-form-item>
|
||
<el-form-item label="真实收货仓"><el-select v-model="warehouseId" aria-label="真实收货仓" :disabled="busy" placeholder="请选择收货仓" @change="changeWarehouse"><el-option v-for="w in warehouses" :key="w.id" :value="w.id" :label="`${w.warehouseCode} · ${w.warehouseName}`" /></el-select></el-form-item>
|
||
<el-form-item label="目标托盘码查询"><el-input v-model="keyword" aria-label="目标托盘码查询" :disabled="busy" placeholder="输入托盘码查询" @keyup.enter="searchPallets()" /><el-button :disabled="busy || !warehouseId" @click="searchPallets()">查询目标托盘</el-button></el-form-item>
|
||
<el-form-item label="目标托盘"><el-select :model-value="target?.pallet.id || ''" aria-label="目标托盘" :disabled="busy" placeholder="请选择查询结果" @update:model-value="selectPallet(String($event))"><el-option v-for="p in pallets" :key="p.id" :value="p.id" :label="`${p.palletCode || '—'} · ${p.fbaWarehouseCode || '—'}`" /></el-select></el-form-item>
|
||
</el-form>
|
||
<el-button v-if="page > 1" :disabled="busy" @click="searchPallets(page - 1)">上一页</el-button><el-button v-if="page * 20 < total" :disabled="busy" @click="searchPallets(page + 1)">下一页</el-button>
|
||
<template v-for="t in completedTargets" :key="t.palletId"><el-button v-if="warehouseId === t.warehouseId" :disabled="busy" @click="selectPallet(t.palletId)">核对本票已收目标 {{ t.palletNo }}</el-button></template>
|
||
<p v-if="target">目标:{{ target.warehouse.warehouseCode }} / {{ target.pallet.palletCode }} / {{ target.location.locationCode }} · FBA {{ target.pallet.fbaWarehouseCode }}</p>
|
||
<p>按当前来源全部箱数实收,库位使用目标托盘已绑定的真实库位。</p><el-button :disabled="busy || !selectedValid" :loading="busy" @click="submit('receipt')">确认本来源实收</el-button>
|
||
</div>
|
||
<p v-if="pending">原操作内容已保留,请先核对当前状态,再使用原内容确认结果。{{ pending.type === 'receipt' ? `${pending.body.destinationPalletNo} / ${pending.body.warehouseLocationCode}` : detail.transferNo }}</p>
|
||
<template v-if="unfinished.length > 1 && !pending"><p>本票存在多条未完成实收,请选择需要恢复的原操作。</p><el-button v-for="c in unfinished" :key="c.request.idempotencyKey" :disabled="busy" @click="recoverServerCommand(c)">恢复 {{ c.target.palletNo }} / {{ c.target.locationCode }}</el-button></template>
|
||
<el-button v-if="pending" :disabled="busy || (pending.type === 'receipt' && !canResources)" :loading="busy" @click="submit(pending.type)">重试原操作</el-button>
|
||
<el-button v-if="pending" :disabled="busy" @click="checkCurrent">只读核对当前状态</el-button>
|
||
<el-button v-if="reselectAllowed && pending?.type === 'receipt'" :disabled="busy" @click="reselectTarget">重新选择目标</el-button>
|
||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !canResources">登记目标实收需要收货仓、托盘、库位查询及转运管理权限。</p>
|
||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !Array.isArray(detail.receiptCommands)">本票尚未返回服务端实收操作记录,请联系管理员核对接口后再登记实收。</p>
|
||
</template>
|
||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><div v-if="rejectionEvidence"><p>上次目标选择被拒绝:{{ rejectionEvidence.message }}</p><ApiErrorMeta :details="rejectionEvidence" /></div>
|
||
</section>
|
||
</template>
|
||
<style scoped>
|
||
.movement-panel { margin-block: var(--admin-space-3); }
|
||
.movement-fields { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: var(--admin-space-3); }
|
||
.movement-fields :deep(.el-form-item) { margin-bottom: var(--admin-space-2); }
|
||
</style>
|