2026-10-05 06:16:21 +08:00
< script setup lang = "ts" >
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-10-05 06:16:21 +08:00
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 >
2026-10-20 15:48:23 +08:00
< el-form-item label = "目标托盘" >< template # label >< span > 目标托盘 < InlineHelp title = "目标实收说明" >< p > 按当前来源全部箱数实收 , 库位使用目标托盘已绑定的真实库位 。 </ p ></ InlineHelp ></ span ></ template >< 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 >
2026-10-05 06:16:21 +08:00
</ 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 >
2026-10-20 15:48:23 +08:00
< el-button : disabled = "busy || !selectedValid" :loading = "busy" @click ="submit('receipt')" > 确认本来源实收 </ el -button >
2026-10-05 06:16:21 +08:00
</ 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 : 2 fr 1 fr 1 fr 1 fr ; gap : var ( -- admin - space - 3 ); }
. movement - fields : deep (. el - form - item ) { margin - bottom : var ( -- admin - space - 2 ); }
</ style >