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

184 lines
18 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, 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>