2026-09-14 12:00:48 +08:00
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
2026-10-22 15:41:50 +08:00
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
2026-10-22 14:37:59 +08:00
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
import { createPicker , disablePicker , resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
2026-10-23 04:48:02 +08:00
import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs'
2026-09-14 12:00:48 +08:00
2026-08-25 00:24:43 +08:00
const MODULE = 'phase4-overseas-client-services'
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
const OPTIONS = { scenario : SCENARIO , deploymentBlocker : true }
const ORDERS = '/api/merchant/v1/last-leg/orders'
const RELABEL = '/api/merchant/v1/last-leg/relabel-applications'
const TRANSFER = '/api/merchant/v1/last-leg/warehouse-transfers'
const DISCARD = '/api/merchant/v1/last-leg/cargo-discards'
function id ( value ) { return value == null ? '' : String ( value ) }
function nonBlank ( value ) { return typeof value === 'string' && value . trim () !== '' }
function codeOf ( response ) { return response ? . body ? . code == null ? '' : String ( response . body . code ) }
function success ( response ) { return response ? . http === 200 && codeOf ( response ) === '200' }
function conflict ( response ) { return response ? . http === 409 || ( response ? . http === 200 && codeOf ( response ) === 'A0409' ) }
function forbidden ( response ) { return response ? . http === 403 || ( response ? . http === 200 && codeOf ( response ) === 'A0403' ) }
function records ( value ) { return Array . isArray ( value ? . records ) ? value . records : [] }
function trace ( response ) {
return `HTTP= ${ response ? . http ?? 'missing' } , code= ${ codeOf ( response ) || 'missing' } , requestId= ${ response ? . requestId || 'missing' } , traceId= ${ response ? . traceId || 'missing' } `
}
function query ( path , values ) {
const params = new URLSearchParams ()
for ( const [ key , value ] of Object . entries ( values || {})) {
if ( value !== undefined && value !== null && String ( value ) !== '' ) params . set ( key , String ( value ))
}
return params . size ? ` ${ path } ? ${ params } ` : path
}
function future ( days ) { return new Date ( Date . now () + days * 86_400_000 ). toISOString (). slice ( 0 , 19 ) }
async function merchant ( context , token , method , name , path , body ) {
2026-09-29 19:48:34 +08:00
const response = await context . request ( method , path , body , token , {})
2026-08-25 00:24:43 +08:00
const data = context . apiSuccess ( MODULE , name , response , trace ( response ), OPTIONS )
return { response , data }
}
function blocker ( context , name , evidence , response = null ) {
context . addStep ( MODULE , name , response , false , ` ${ evidence } ; ${ trace ( response ) } ` , OPTIONS )
context . addCoverageGap ( MODULE , SCENARIO , name , evidence , 'fixture' , { deploymentBlocker : true })
}
async function loginMerchant ( context ) {
const fixture = context . context ? . merchantQualificationFixture
const pda = context . createdResources ? . phase4OverseasPda
if ( ! fixture ? . username || ! fixture ? . password || fixture ? . marker !== context . marker
|| pda ? . marker !== context . marker || pda ? . finalStatus !== 'COMPLETED' ) {
blocker ( context , '取得本轮客户账号与海外PDA库存前置' ,
`merchantMarker= ${ fixture ? . marker || 'missing' } , pdaMarker= ${ pda ? . marker || 'missing' } , pdaStatus= ${ pda ? . finalStatus || 'missing' } ` )
return null
}
const response = await context . request ( 'POST' , '/api/auth/login' , {
2026-09-01 21:55:27 +08:00
loginId : fixture . username , password : fixture . password ,
2026-08-25 00:24:43 +08:00
}, '' )
const auth = context . apiSuccess ( MODULE , '客户工作台账号登录' , response , trace ( response ), OPTIONS )
2026-10-22 14:37:59 +08:00
context . assert ( MODULE , '客户登录返回海外访问Token' , response , nonBlank ( auth ? . accessToken ) && nonBlank ( auth ? . refreshToken ),
2026-09-29 19:48:34 +08:00
`tokenPresent= ${ Boolean ( auth ? . accessToken ) } ; ${ trace ( response ) } ` , OPTIONS )
2026-10-22 14:37:59 +08:00
return nonBlank ( auth ? . accessToken ) && nonBlank ( auth ? . refreshToken ) ? auth : null
2026-08-25 00:24:43 +08:00
}
2026-10-23 04:48:02 +08:00
export async function currentOrders ( context , token ) {
2026-08-25 00:24:43 +08:00
const pda = context . createdResources . phase4OverseasPda
const containerNos = [... new Set ([...( pda . completedContainers || []), pda . containerNo ]. map ( id ). filter ( Boolean ))]
const result = []
for ( const containerNo of containerNos ) {
2026-10-23 04:48:02 +08:00
const all = [], seen = new Set (); let total
for ( let pageNo = 1 ; pageNo <= 100 ; pageNo ++ ) {
const page = await merchant ( context , token , 'GET' , `查询本轮完成柜 ${ containerNo } 的客户尾程订单` ,
query ( ORDERS , { page : pageNo , size : 200 , containerNo }))
const count = Number ( page . data ? . total )
if ( page . data ? . total == null || ! Number . isSafeInteger ( count ) || count < 0 || ! Array . isArray ( page . data ? . records )
|| ( total !== undefined && count !== total ) || ( ! page . data . records . length && all . length < count ))
throw Error ( '客户尾程订单分页不完整或总数变化' )
total ??= count
for ( const row of page . data . records ) {
if ( ! id ( row . id ) || seen . has ( id ( row . id ))) throw Error ( '客户尾程订单分页身份重复或缺失' )
seen . add ( id ( row . id )); all . push ( row )
}
if ( all . length >= total ) break
}
if ( all . length !== total ) throw Error ( '客户尾程订单分页未完整读取' )
for ( const row of all ) {
2026-08-25 00:24:43 +08:00
if ( id ( row ? . containerNo ) !== containerNo || ! id ( row ? . id )) continue
const detail = await merchant ( context , token , 'GET' , `读取本轮尾程订单 ${ id ( row . id ) } 详情` ,
` ${ ORDERS } / ${ encodeURIComponent ( id ( row . id )) } ` )
if ( success ( detail . response ) && id ( detail . data ? . order ? . id ) === id ( row . id )) {
result . push ({ row : detail . data . order , cargo : Array . isArray ( detail . data ? . cargo ) ? detail . data . cargo : [] })
}
}
}
const ownedIds = new Set ( result . map (( value ) => id ( value . row ? . id )))
context . assert ( MODULE , '客户Token只取得本轮PDA完成柜的自身尾程事实' , null ,
result . length > 0 && ( ! pda . orderId || ownedIds . has ( id ( pda . orderId ))),
`containers= ${ containerNos . join ( ',' ) || 'missing' } , orders= ${ [... ownedIds ]. join ( ',' ) || 'missing' } ` , OPTIONS )
return result
}
function usableCargo ( order ) {
return order . cargo . find (( item ) => nonBlank ( item ? . skuCode )
&& nonBlank ( item ? . productNameZh ) && Number ( item ? . packageCount ) > 0 )
}
2026-10-23 04:48:02 +08:00
async function verifyRelabel ( context , token , orders ) {
const boxed = await readCurrentBoxedInventory ( context , token ,
context . createdResources . phase4OverseasPda . overseasWarehouseId , MODULE )
const source = boxed . find ( row => orders . some ( order => order . row . inboundCode === row . inboundCode && usableCargo ( order ) ? . skuCode === row . skuCode ))
const order = source && orders . find ( order => order . row . inboundCode === source . inboundCode && usableCargo ( order ) ? . skuCode === source . skuCode )
if ( ! order ) throw Error ( '客户换标缺少本轮未占用真实箱库存' )
const carton = source . cartons [ 0 ]
2026-08-25 00:24:43 +08:00
const cargo = usableCargo ( order )
if ( ! cargo || ! nonBlank ( order . row ? . overseasWarehouseId )) {
blocker ( context , '本轮贴标申请候选' ,
`orderId= ${ id ( order . row ? . id ) || 'missing' } , sku= ${ cargo ? . skuCode || 'missing' } , warehouse= ${ order . row ? . overseasWarehouseId || 'missing' } ` )
return false
}
2026-10-23 04:48:02 +08:00
const position = source . positions . find ( item => id ( item . positionId ) === carton . positionId )
context . assert ( MODULE , '贴标申请使用当前客户真实库存余额、位置和来源入库单' , null ,
2026-09-09 21:13:10 +08:00
Boolean ( source && position ),
`balanceId= ${ id ( source ? . inventoryBalanceId ) || 'missing' } , positionId= ${ id ( position ? . positionId ) || 'missing' } , inboundOrderId= ${ id ( source ? . inboundOrderId ) || 'missing' } ` , OPTIONS )
if ( ! source || ! position ) return false
2026-10-22 14:37:59 +08:00
if ( ! await fundRelabelWallet ( context , id ( context . context . merchantQualificationFixture . tenantId ))) return false
2026-08-25 00:24:43 +08:00
const body = {
customerOrderNo : order . row . orderNo ,
2026-09-09 21:13:10 +08:00
operationWarehouseId : order . row . overseasWarehouseId ,
2026-08-25 00:24:43 +08:00
labelDeadline : future ( 7 ), contactName : '客户贴标验收联系人' , contactPhone : '13800000006' ,
2026-09-06 12:24:42 +08:00
chargeAmount : 0.01 ,
2026-09-11 01:50:46 +08:00
deliveryMethod : 'TRUCK' , printShippingLabel : false ,
2026-08-25 00:24:43 +08:00
remark : context . marker , idempotencyKey : ` ${ context . marker } -CLIENT-RELABEL` . slice ( 0 , 255 ),
items : [{
2026-09-09 21:13:10 +08:00
sourceInventoryBalanceId : id ( source . inventoryBalanceId ),
sourceInventoryPositionId : id ( position . positionId ),
sourceInboundOrderId : id ( source . inboundOrderId ),
2026-08-25 00:24:43 +08:00
skuCode : cargo . skuCode , productImageFileId : null , productImageFileName : null , productImageUrl : null ,
productNameZh : cargo . productNameZh , totalPackageCount : 1 , notifiedPackageCount : 1 ,
shipmentNo : cargo . shipmentNo || ` ${ context . marker } -RELABEL-SHIP` , trackingCode : cargo . fbaTrackingNo || null ,
newSkuCode : ` ${ cargo . skuCode } -NEW` . slice ( 0 , 128 ), labelRequirement : context . marker ,
2026-10-22 14:37:59 +08:00
operationType : source . destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL' ,
2026-10-23 04:48:02 +08:00
skuItems : carton . items . map ( item => ({ skuCode : item . skuCode , newSkuCode : ` ${ item . skuCode } -NEW` . slice ( 0 , 128 ), quantity : Number ( item . quantity ), remark : context . marker })),
2026-08-25 00:24:43 +08:00
relabelFileId : null , relabelFileName : null , relabelFileUrl : null ,
2026-10-23 04:48:02 +08:00
cartonMark : carton . cartonMark ,
2026-10-22 14:37:59 +08:00
fbaCartonMark : source . destinationFbaWarehouseCode ? `FBA- ${ String ( context . marker ). slice ( - 32 ) } ` : null ,
2026-09-09 21:13:10 +08:00
destinationFbaWarehouseCode : source . destinationFbaWarehouseCode ,
2026-08-25 00:24:43 +08:00
}],
}
const created = await merchant ( context , token , 'POST' , '客户创建贴标申请' , RELABEL , body )
const relabelNo = id ( created . data ? . application ? . relabelNo )
context . assert ( MODULE , '客户贴标申请进入合法待处理状态' , created . response ,
2026-09-29 14:18:20 +08:00
Boolean ( relabelNo ) && created . data ? . application ? . status === 'PENDING_REVIEW'
2026-08-25 00:24:43 +08:00
&& created . data ? . application ? . customerOrderNo === order . row . orderNo ,
`relabelNo= ${ relabelNo || 'missing' } , status= ${ created . data ? . application ? . status || 'missing' } ; ${ trace ( created . response ) } ` , OPTIONS )
if ( ! relabelNo ) return false
const replay = await merchant ( context , token , 'POST' , '客户贴标申请幂等重放' , RELABEL , body )
context . assert ( MODULE , '贴标重放复用同一申请' , replay . response ,
id ( replay . data ? . application ? . relabelNo ) === relabelNo , trace ( replay . response ), OPTIONS )
2026-09-29 19:48:34 +08:00
const hashConflict = await context . request ( 'POST' , RELABEL , { ... body , remark : ` ${ context . marker } -DIFFERENT` }, token , {})
2026-08-25 00:24:43 +08:00
context . addStep ( MODULE , '贴标同幂等键异载荷返回A0409' , hashConflict , conflict ( hashConflict ), trace ( hashConflict ), OPTIONS )
const page = await merchant ( context , token , 'GET' , '客户查询自身贴标申请列表' ,
query ( RELABEL , { page : 1 , size : 20 , relabelNo , customerId : 0 }))
context . assert ( MODULE , '伪造customerId不改变贴标客户范围' , page . response ,
records ( page . data ). some (( item ) => id ( item ? . relabelNo ) === relabelNo ), trace ( page . response ), OPTIONS )
const detail = await merchant ( context , token , 'GET' , '客户查询自身贴标申请详情' ,
` ${ RELABEL } / ${ encodeURIComponent ( relabelNo ) } ` )
context . assert ( MODULE , '贴标详情保留明细与创建历史' , detail . response ,
2026-09-29 14:18:20 +08:00
id ( detail . data ? . application ? . relabelNo ) === relabelNo && detail . data ? . application ? . status === 'PENDING_REVIEW'
2026-08-25 00:24:43 +08:00
&& Array . isArray ( detail . data ? . items ) && detail . data . items . length === 1
2026-09-29 14:18:20 +08:00
&& Array . isArray ( detail . data ? . history ) && detail . data . history . some (( item ) => item ? . toStatus === 'PENDING_REVIEW' ),
2026-08-25 00:24:43 +08:00
`items= ${ detail . data ? . items ? . length ?? 'missing' } , histories= ${ detail . data ? . history ? . length ?? 'missing' } ; ${ trace ( detail . response ) } ` , OPTIONS )
context . createdResources . phase4OverseasClientServices . relabelNo = relabelNo
2026-10-22 14:37:59 +08:00
const proof = context . createdResources . phase4OverseasClientServices . relabelFulfillment = {
marker : context . marker , relabelNo , customerId : id ( context . context . merchantQualificationFixture . tenantId ),
sourceOrderNo : order . row . orderNo , warehouseId : id ( order . row . overseasWarehouseId ),
sourcePositionId : id ( position . positionId ), sourcePalletNo : position . palletNo , status : 'PENDING_REVIEW' ,
}
const access = await resolvePickerAccess ( context )
if ( ! access ) throw new Error ( '贴标后续缺少有效PDA角色' )
const picker = await createPicker ( context , 94 , proof . warehouseId , access )
try {
if ( ! picker ? . token ) throw new Error ( '贴标后续未取得本仓PDA会话' )
await verifyClientRelabelFulfillment ( context , { proof , merchantToken : token , pdaToken : picker . token })
2026-10-22 15:41:50 +08:00
if ( ! context . options ? . modules ? . includes ( 'phase5-overseas-fulfillment' )) context . addCoverageGap ( MODULE , 'client-relabel-delivery' , '客户原贴标申请的排车至交付' ,
'当前定向范围仅到客户确认;完整默认链继续使用本申请targetPalletIds排车并签收,不能借其他换标单结果' ,
'fixture' , { blocking : false })
2026-10-22 14:37:59 +08:00
} finally {
if ( picker ? . membershipId ) await disablePicker ( context , picker , 94 )
}
2026-08-25 00:24:43 +08:00
return true
}
2026-10-22 14:37:59 +08:00
// 提交只发送一次,未知结果保留单号并停止,不能用详情回查掩盖失败。
export async function submitClientTransfer ( context , token , transferNo ) {
if ( ! nonBlank ( transferNo )) throw new Error ( '仓储转运提交缺少单号' )
if ( context . createdResources . phase4OverseasClientServices . transferSubmission ) throw new Error ( '本轮仓储转运已有提交证据,禁止重复提交' )
const proof = { transferNo , marker : context . marker , status : 'SUBMITTING' }
context . createdResources . phase4OverseasClientServices . transferSubmission = proof
try {
const response = await context . request ( 'POST' , ` ${ TRANSFER } / ${ encodeURIComponent ( transferNo ) } /submit` , undefined , token , {})
Object . assign ( proof , { http : response ? . http , code : codeOf ( response ), requestId : response ? . requestId , traceId : response ? . traceId })
const data = context . apiSuccess ( MODULE , '客户提交仓储转运申请' , response , trace ( response ), OPTIONS )
if ( ! success ( response )) {
proof . status = ! response ? . http || response . http >= 500 || ! response . body ? 'UNKNOWN_RESULT' : 'REJECTED'
throw new Error ( `仓储转运提交未成功,停止后续写入: ${ transferNo } ; ${ trace ( response ) } ` )
}
if ( id ( data ? . transferNo ) !== transferNo || data ? . status !== 'PENDING_REVIEW' ) {
proof . status = 'UNEXPECTED_RESULT'
throw new Error ( `仓储转运提交返回单号或状态不符: ${ transferNo } ` )
}
proof . status = 'PENDING_REVIEW'
return data
} catch ( error ) {
if ( proof . status === 'SUBMITTING' ) proof . status = 'UNKNOWN_RESULT'
throw error
}
}
2026-08-25 00:24:43 +08:00
async function verifyTransfer ( context , token , orders ) {
2026-10-23 04:48:02 +08:00
const boxed = await readCurrentBoxedInventory ( context , token ,
context . createdResources . phase4OverseasPda . overseasWarehouseId , MODULE )
const source = boxed . find ( row => row . destinationFbaWarehouseCode && orders . some ( order => order . row . inboundCode === row . inboundCode
&& usableCargo ( order ) ? . skuCode === row . skuCode ))
const order = source && orders . find ( value => value . row . inboundCode === source . inboundCode && usableCargo ( value ) ? . skuCode === source . skuCode )
const carton = source ? . cartons [ 0 ]
2026-08-25 00:24:43 +08:00
const cargo = order ? usableCargo ( order ) : null
if ( ! order || ! cargo ) {
blocker ( context , '本轮仓储转运申请候选' ,
`orders= ${ orders . length } , fbaOrders= ${ orders . filter (( value ) => nonBlank ( value . row ? . fbaWarehouseCode )). length } ; 禁止借历史入仓单` )
return false
}
const packages = 1
const volume = Number ( cargo . volumeCbm ) > 0 ? Number ( cargo . volumeCbm ) : 0.0001
const body = {
inboundCode : order . row . inboundCode , sourceLastlegOrderIds : [ id ( order . row . id )],
operatingWarehouseId : order . row . overseasWarehouseId , fbaWarehouseCode : order . row . fbaWarehouseCode ,
deliveryAddress : ` ${ order . row . fbaWarehouseCode } ${ context . marker } ` . slice ( 0 , 1000 ),
packageCount : packages , volumeCbm : volume , deliveryMethod : 'TRUCK' , estimatedArrivalAt : future ( 10 ),
fbaShipmentNo : cargo . shipmentNo || ` ${ context . marker } -TRANSFER-SHIP` , trackingNo : cargo . fbaTrackingNo || null ,
2026-10-23 04:48:02 +08:00
fbaBoxNos : carton . cartonMark , remark : context . marker , needRelabel : true ,
2026-09-23 20:28:26 +08:00
labelDeadline : future ( 7 ), relabelContactName : '客户贴标验收联系人' , relabelContactPhone : '13800000006' ,
relabelChargeAmount : 0.01 , relabelDeliveryMethod : 'TRUCK' ,
2026-08-25 00:24:43 +08:00
idempotencyKey : ` ${ context . marker } -CLIENT-TRANSFER` . slice ( 0 , 255 ),
items : [{ skuCode : cargo . skuCode , productNameZh : cargo . productNameZh ,
2026-10-23 04:48:02 +08:00
packageCount : packages , shippingMark : carton . cartonMark }],
relabelItems : [{ operationType : 'CARTON_AND_SKU' , customerCartonMarks : [ carton . cartonMark ],
originalSku : cargo . skuCode , newSku : ` ${ cargo . skuCode } -NEW` . slice ( 0 , 128 ), quantity : Number ( carton . items [ 0 ]. quantity ),
2026-09-23 20:28:26 +08:00
fbaCartonMark : `FBA- ${ context . marker } ` . slice ( 0 , 255 ), destinationWarehouseCode : order . row . fbaWarehouseCode ,
remark : context . marker }],
2026-08-25 00:24:43 +08:00
}
const created = await merchant ( context , token , 'POST' , '客户创建仓储转运申请' , TRANSFER , body )
const transferNo = id ( created . data ? . transferNo )
2026-09-23 20:37:00 +08:00
if ( transferNo && created . data ? . status === 'DRAFT' ) {
2026-10-22 14:37:59 +08:00
created . data = await submitClientTransfer ( context , token , transferNo )
2026-09-23 20:37:00 +08:00
}
2026-08-25 00:24:43 +08:00
context . assert ( MODULE , '仓储转运申请进入合法待审核状态且绑定本轮来源订单' , created . response ,
2026-08-25 08:21:47 +08:00
Boolean ( transferNo ) && created . data ? . status === 'PENDING_REVIEW'
2026-08-25 00:24:43 +08:00
&& created . data ? . sourceOrders ? . some (( item ) => id ( item ? . lastlegOrderId ) === id ( order . row . id )),
2026-08-25 08:21:47 +08:00
`transferNo= ${ transferNo || 'missing' } , status= ${ created . data ? . status || 'missing' } ; ${ trace ( created . response ) } ` , OPTIONS )
2026-08-25 00:24:43 +08:00
if ( ! transferNo ) return false
const replay = await merchant ( context , token , 'POST' , '客户仓储转运申请幂等重放' , TRANSFER , body )
context . assert ( MODULE , '转运重放复用同一申请' , replay . response ,
id ( replay . data ? . transferNo ) === transferNo , trace ( replay . response ), OPTIONS )
2026-09-29 19:48:34 +08:00
const hashConflict = await context . request ( 'POST' , TRANSFER , { ... body , remark : ` ${ context . marker } -DIFFERENT` }, token , {})
2026-08-25 00:24:43 +08:00
context . addStep ( MODULE , '转运同幂等键异载荷返回A0409' , hashConflict , conflict ( hashConflict ), trace ( hashConflict ), OPTIONS )
const page = await merchant ( context , token , 'GET' , '客户查询自身仓储转运列表' ,
query ( TRANSFER , { page : 1 , size : 20 , transferNo , customerId : 0 }))
context . assert ( MODULE , '伪造customerId不改变转运客户范围' , page . response ,
records ( page . data ). some (( item ) => id ( item ? . transferNo ) === transferNo ), trace ( page . response ), OPTIONS )
const detail = await merchant ( context , token , 'GET' , '客户查询自身仓储转运详情' ,
` ${ TRANSFER } / ${ encodeURIComponent ( transferNo ) } ` )
context . assert ( MODULE , '转运详情保持待审核且不产生出库履约事实' , detail . response ,
2026-08-25 08:21:47 +08:00
id ( detail . data ? . transferNo ) === transferNo && detail . data ? . status === 'PENDING_REVIEW'
2026-08-25 00:24:43 +08:00
&& detail . data ? . outboundAt == null && Array . isArray ( detail . data ? . allocations ) && detail . data . allocations . length === 0 ,
2026-08-25 08:21:47 +08:00
`status= ${ detail . data ? . status || 'missing' } , allocations= ${ detail . data ? . allocations ? . length ?? 'missing' } ; ${ trace ( detail . response ) } ` , OPTIONS )
2026-08-25 00:24:43 +08:00
context . createdResources . phase4OverseasClientServices . transferNo = transferNo
2026-10-22 15:41:50 +08:00
if ( ! await verifyWarehouseTransferReview ( context , token , order . row . customerId , body , transferNo )) return false
const transferProof = context . createdResources . phase4OverseasClientServices . transferFulfillment = {
marker : context . marker , customerId : id ( order . row . customerId ), warehouseId : id ( order . row . overseasWarehouseId ),
transferNo , inboundCode : order . row . inboundCode , skuCode : cargo . skuCode , status : 'APPROVED' ,
2026-10-23 04:48:02 +08:00
requiredSourcePositionId : carton . positionId , sourceCartonMark : carton . cartonMark ,
2026-10-22 15:41:50 +08:00
}
await allocateWarehouseTransfer ( context , { proof : transferProof , merchantToken : token })
const relabelProof = transferProof . relabelFulfillment = {
marker : context . marker , relabelNo : transferProof . relabelNo , customerId : transferProof . customerId ,
warehouseId : transferProof . warehouseId , sourceOrderNo : transferNo ,
sourcePositionId : transferProof . sourcePositionId , sourcePalletNo : transferProof . sourcePalletNo ,
status : 'PENDING_REVIEW' ,
}
const access = await resolvePickerAccess ( context )
if ( ! access ) throw new Error ( '转运换标后续缺少有效PDA角色' )
const picker = await createPicker ( context , 96 , transferProof . warehouseId , access )
try {
if ( ! picker ? . token ) throw new Error ( '转运换标未取得本仓PDA会话' )
await verifyClientRelabelFulfillment ( context , { proof : relabelProof , transferProof , merchantToken : token , pdaToken : picker . token })
} finally {
if ( picker ? . membershipId ) await disablePicker ( context , picker , 96 )
}
await outboundWarehouseTransfer ( context , { proof : transferProof , merchantToken : token })
await receiveWarehouseTransfer ( context , { proof : transferProof , merchantToken : token })
return true
2026-08-25 00:24:43 +08:00
}
2026-10-03 06:26:33 +08:00
export async function verifyDiscard ( context , token , markerOrders ) {
2026-09-29 19:48:34 +08:00
const response = await context . request ( 'GET' , ` ${ DISCARD } /inventory-candidates` , undefined , token , {})
2026-08-25 00:24:43 +08:00
const candidates = context . apiSuccess ( MODULE , '客户查询货物废弃库存候选' , response , trace ( response ), OPTIONS )
const markerOrderIds = new Set ( markerOrders . map (( value ) => id ( value . row ? . id )))
const candidate = Array . isArray ( candidates ) ? candidates . find (( value ) => markerOrderIds . has ( id ( value ? . orderId ))
2026-10-23 04:48:02 +08:00
&& value . cartonSelectionSupported === true && value . cartons ? . some ( box => id ( box . cartonId )
&& box . skus ? . some ( sku => nonBlank ( sku . skuCode ) && Number ( sku . availablePackages ) > 0 ))) : null
2026-08-25 00:24:43 +08:00
if ( ! candidate ) {
blocker ( context , '本轮货物废弃库存候选' ,
`candidateOrders= ${ Array . isArray ( candidates ) ? candidates . map (( value ) => id ( value ? . orderId )). join ( ',' ) : 'missing' } , markerOrders= ${ [... markerOrderIds ]. join ( ',' ) || 'missing' } ; 禁止借历史一件代发库存` , response )
return false
}
2026-10-23 04:48:02 +08:00
const carton = candidate . cartons . find ( box => id ( box . cartonId )
&& box . skus ? . some ( sku => nonBlank ( sku . skuCode ) && Number ( sku . availablePackages ) > 0 ))
const sku = carton . skus . find ( value => nonBlank ( value . skuCode ) && Number ( value . availablePackages ) > 0 )
2026-08-25 00:24:43 +08:00
const body = {
2026-10-23 04:48:02 +08:00
items : [{ orderId : id ( candidate . orderId ), skuCode : sku . skuCode , discardPackages : 1 , cartonIds : [ id ( carton . cartonId )] }],
2026-08-25 00:24:43 +08:00
idempotencyKey : ` ${ context . marker } -CLIENT-DISCARD` . slice ( 0 , 255 ), remark : context . marker ,
}
2026-10-03 06:26:33 +08:00
const proof = context . createdResources . phase4OverseasClientServices . discardApproval = {
sourceOrderId : id ( candidate . orderId ), customerId : id ( context . context . merchantQualificationFixture . tenantId ),
warehouseId : candidate . overseasWarehouseId , inboundCode : candidate . inboundCode ,
skuCode : sku . skuCode , snapshots : {}, status : 'RUNNING' ,
2026-10-08 17:07:57 +08:00
contract : 'Approval retains the existing reservation without deducting stock. Actual PDA confirmation deducts the confirmed quantity; completed confirmation replay must not write again. Review has no idempotencyKey.' ,
2026-10-03 06:26:33 +08:00
}
const before = await discardSnapshot ( context , token , proof )
proof . snapshots . before = before
2026-08-25 00:24:43 +08:00
const created = await merchant ( context , token , 'POST' , '客户创建货物废弃申请' , DISCARD , body )
const discardNo = id ( created . data ? . summary ? . discardNo )
context . assert ( MODULE , '废弃申请只锁定库存并进入合法待审核状态' , created . response ,
Boolean ( discardNo ) && created . data ? . summary ? . status === 'PENDING_REVIEW'
&& Number ( created . data ? . summary ? . totalPackages ) === 1 ,
`discardNo= ${ discardNo || 'missing' } , status= ${ created . data ? . summary ? . status || 'missing' } ; ${ trace ( created . response ) } ` , OPTIONS )
if ( ! discardNo ) return false
const replay = await merchant ( context , token , 'POST' , '客户货物废弃申请幂等重放' , DISCARD , body )
context . assert ( MODULE , '废弃重放复用同一申请' , replay . response ,
id ( replay . data ? . summary ? . discardNo ) === discardNo , trace ( replay . response ), OPTIONS )
const page = await merchant ( context , token , 'GET' , '客户查询自身货物废弃列表' ,
query ( DISCARD , { page : 1 , size : 20 , discardNo , customerId : 0 }))
context . assert ( MODULE , '伪造customerId不改变废弃客户范围' , page . response ,
records ( page . data ). some (( item ) => id ( item ? . discardNo ) === discardNo ), trace ( page . response ), OPTIONS )
const detail = await merchant ( context , token , 'GET' , '客户查询自身货物废弃详情' ,
` ${ DISCARD } / ${ encodeURIComponent ( discardNo ) } ` )
context . assert ( MODULE , '废弃详情保留候选快照和创建历史且未审核扣减' , detail . response ,
id ( detail . data ? . summary ? . discardNo ) === discardNo && detail . data ? . summary ? . status === 'PENDING_REVIEW'
&& Array . isArray ( detail . data ? . items ) && detail . data . items . length === 1
&& Array . isArray ( detail . data ? . histories ) && detail . data . histories . some (( item ) => item ? . toStatus === 'PENDING_REVIEW' ),
`items= ${ detail . data ? . items ? . length ?? 'missing' } , histories= ${ detail . data ? . histories ? . length ?? 'missing' } ; ${ trace ( detail . response ) } ` , OPTIONS )
context . createdResources . phase4OverseasClientServices . discardNo = discardNo
2026-10-22 14:37:59 +08:00
try {
await verifyDiscardApproval ( context , token , body , discardNo , proof )
const access = await resolvePickerAccess ( context )
if ( ! access ) throw new Error ( '废弃执行缺少有效PDA角色' )
const picker = await createPicker ( context , 93 , proof . warehouseId , access )
try {
if ( ! picker ? . token ) throw new Error ( '废弃执行未取得本仓PDA会话' )
await verifyCargoDiscardFulfillment ( context , { proof , merchantToken : token , pdaToken : picker . token ,
snapshot : () => discardSnapshot ( context , token , proof , proof . approvedNo ) })
context . addCoverageGap ( MODULE , 'discard-carton-storage-evidence' , '废弃底层箱、SKU和箱级流水独立取证' ,
'已接入PDA执行、客户完成、公共库存总账和终态重放;未执行底层独立取证' , 'prerequisite' , { blocking : false })
} finally {
if ( picker ? . membershipId ) await disablePicker ( context , picker , 93 )
}
}
2026-10-03 06:26:33 +08:00
catch ( error ) { proof . status = 'FAILED' ; proof . failure = error . message ; throw error }
2026-08-25 00:24:43 +08:00
return true
}
2026-10-03 06:26:33 +08:00
function stable ( value ) {
if ( Array . isArray ( value )) return JSON . stringify ( value . map ( stable ). sort ())
if ( value && typeof value === 'object' ) return JSON . stringify ( Object . fromEntries ( Object . keys ( value ). sort (). map ( k => [ k , stable ( value [ k ])])))
return value
}
function discardRequire ( context , name , passed , evidence = '' ) {
context . assert ( MODULE , name , null , Boolean ( passed ), evidence , OPTIONS )
if ( ! passed ) throw new Error ( name )
}
async function discardCommand ( context , token , method , name , path , body ) {
const result = await merchant ( context , token , method , name , path , body )
if ( ! success ( result . response )) throw new Error ( ` ${ name } : ${ trace ( result . response ) } ` )
return result . data
}
2026-10-23 04:48:02 +08:00
export async function discardSnapshot ( context , token , proof , discardNo ) {
2026-10-03 06:26:33 +08:00
const all = async ( kind ) => {
const rows = []
let total
for ( let page = 1 ; page <= 100 ; page ++ ) {
const data = await discardCommand ( context , context . token , 'GET' , `废弃核对完整 ${ kind } ` ,
query ( `/api/platform/v1/last-leg/inventory/ ${ kind } ` , { customerId : proof . customerId ,
overseasWarehouseId : proof . warehouseId , page , size : 200 }))
discardRequire ( context , '废弃库存分页作用域与总数稳定' , Array . isArray ( data ? . records )
&& Number . isSafeInteger ( Number ( data . total )) && Number ( data . total ) >= 0
&& ( total === undefined || total === Number ( data . total ))
&& data . records . every ( row => id ( row . customerId ) === proof . customerId && row . overseasWarehouseId === proof . warehouseId ))
total = Number ( data . total ); rows . push (... data . records )
if ( rows . length === total ) return rows
discardRequire ( context , '废弃库存分页不能截断' , rows . length < total && data . records . length > 0 )
}
throw new Error ( '废弃库存分页超过上限' )
}
const balances = await all ( 'balances' ), ledger = await all ( 'ledger' )
2026-10-23 04:48:02 +08:00
// 列表按客户、仓、目的仓和SKU聚合,不携带入仓码;用代表余额ID回查真实来源并核对汇总数,不能给聚合行补造来源。
for ( let index = 0 ; index < balances . length ; index ++ ) {
const aggregate = balances [ index ]
if ( aggregate . skuCode !== proof . skuCode ) continue
const raw = await discardCommand ( context , context . token , 'GET' , '废弃核对实际来源余额详情' ,
query ( `/api/platform/v1/last-leg/inventory/balances/ ${ encodeURIComponent ( id ( aggregate . id )) } ` , { customerId : proof . customerId }))
discardRequire ( context , '废弃来源余额身份与聚合数量一致' , id ( raw ? . id ) === id ( aggregate . id )
&& id ( raw . customerId ) === proof . customerId && raw . overseasWarehouseId === proof . warehouseId
&& raw . inboundCode === proof . inboundCode && raw . skuCode === proof . skuCode
&& [ 'totalPackages' , 'availablePackages' , 'holdPackages' , 'allocatedPackages' , 'pickedPackages' , 'inTransitPackages' , 'receivingPackages' , 'procurementPackages' ]
. every ( field => /^ \ d + $ / . test ( String ( raw [ field ])) && Number ( raw [ field ]) === Number ( aggregate [ field ])))
balances [ index ] = raw
}
2026-10-03 06:26:33 +08:00
const candidates = await discardCommand ( context , token , 'GET' , '废弃回查本轮客户真实候选' , ` ${ DISCARD } /inventory-candidates` )
const source = candidates . find ( row => id ( row . orderId ) === proof . sourceOrderId )
const detail = discardNo ? await discardCommand ( context , token , 'GET' , '废弃回查申请真实状态' , ` ${ DISCARD } / ${ encodeURIComponent ( discardNo ) } ` ) : null
return { balances , ledger , source : source || null , detail }
}
export async function verifyDiscardApproval ( context , token , body , discardNo , proof ) {
const require = ( name , passed ) => discardRequire ( context , name , passed , `discardNo= ${ discardNo } ` )
const sameStock = ( a , b ) => stable ( a . balances ) === stable ( b . balances ) && stable ( a . ledger ) === stable ( b . ledger )
const base = proof . snapshots . before
require ( '废弃来源属于本轮客户及实际收货仓' , base . source ? . overseasWarehouseId === proof . warehouseId
&& base . source ? . inboundCode === proof . inboundCode && Number ( base . source . availablePackages ) >= 1 )
let pending = await discardSnapshot ( context , token , proof , discardNo )
proof . snapshots . pending = pending
require ( '申请仅锁定一箱且余额流水未扣减' , sameStock ( base , pending )
&& Number ( pending . source ? . availablePackages || 0 ) === Number ( base . source . availablePackages ) - 1 )
const conflictBody = { ... body , remark : ` ${ body . remark } -DIFFERENT` }
const response = await context . request ( 'POST' , DISCARD , conflictBody , token , {})
context . addStep ( MODULE , '废弃创建同键异载荷拒绝' , response , conflict ( response ), trace ( response ), OPTIONS )
require ( '废弃创建异载荷有明确冲突诊断' , conflict ( response ) && nonBlank ( response . requestId ) && nonBlank ( response . traceId ))
require ( '废弃创建异载荷拒绝后事实不变' , stable ( await discardSnapshot ( context , token , proof , discardNo )) === stable ( pending ))
const root = '/api/platform/v1/last-leg/cargo-discards'
const review = ( number , decision ) => discardCommand ( context , context . token , 'POST' , `废弃审批 ${ decision } ` ,
query ( ` ${ root } / ${ encodeURIComponent ( number ) } /review` , { customerId : proof . customerId }), { decision , remark : context . marker })
await review ( discardNo , 'REJECT' )
const rejected = await discardSnapshot ( context , token , proof , discardNo ); proof . snapshots . rejected = rejected
require ( '废弃拒绝释放原箱且全部库存数量流水不变' , rejected . detail ? . summary ? . status === 'REJECTED'
&& sameStock ( base , rejected ) && stable ( base . source ) === stable ( rejected . source ))
await review ( discardNo , 'REJECT' )
require ( '废弃重复拒绝不重复释放' , stable ( await discardSnapshot ( context , token , proof , discardNo )) === stable ( rejected ))
const approvedBody = { ... body , idempotencyKey : ` ${ body . idempotencyKey } -APPROVE` }
const created = await discardCommand ( context , token , 'POST' , '创建独立待批准废弃申请' , DISCARD , approvedBody )
const approvedNo = id ( created ? . summary ? . discardNo ); require ( '批准对照使用本轮新申请' , nonBlank ( approvedNo ) && approvedNo !== discardNo )
proof . approvedNo = approvedNo
await review ( approvedNo , 'APPROVE' )
const approved = await discardSnapshot ( context , token , proof , approvedNo ); proof . snapshots . approved = approved
2026-10-05 12:00:09 +08:00
require ( '批准只保留锁箱且库存数量流水不变' , approved . detail ? . summary ? . status === 'APPROVED'
&& sameStock ( base , approved )
&& Number ( approved . source ? . availablePackages || 0 ) === Number ( base . source . availablePackages ) - 1 )
2026-10-03 06:26:33 +08:00
await review ( approvedNo , 'APPROVE' )
require ( '重复批准不会提前扣库存或新增历史' , stable ( await discardSnapshot ( context , token , proof , approvedNo )) === stable ( approved ))
const reverse = await context . request ( 'POST' , query ( ` ${ root } / ${ encodeURIComponent ( approvedNo ) } /review` ,{ customerId : proof . customerId }),{ decision : 'REJECT' , remark : context . marker }, context . token ,{})
require ( '批准后反向审核拒绝且事实不变' , conflict ( reverse ) && nonBlank ( reverse . requestId ) && nonBlank ( reverse . traceId )
&& stable ( await discardSnapshot ( context , token , proof , approvedNo )) === stable ( approved ))
proof . status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
2026-10-08 17:07:57 +08:00
proof . remainingEvidence = 'Approval reservation only. Actual PDA confirm, completed-confirm recovery/replay and independent ll_dropship_carton/ll_dropship_sku_inventory/ll_dropship_carton_ledger evidence remain required; public ledger is not carton ledger.'
2026-10-03 06:26:33 +08:00
}
2026-08-25 00:24:43 +08:00
export async function run ( context ) {
2026-10-22 14:37:59 +08:00
const auth = await loginMerchant ( context )
if ( ! auth ) return
try {
return await runWithSession ( context , auth . accessToken )
} finally {
const logout = await context . request ( 'POST' , '/api/auth/logout' , { refreshToken : auth . refreshToken }, auth . accessToken )
context . apiSuccess ( MODULE , '退出本轮客户服务会话' , logout , trace ( logout ), OPTIONS )
}
}
async function runWithSession ( context , token ) {
2026-08-25 00:24:43 +08:00
context . createdResources . phase4OverseasClientServices = {
marker : context . marker , relabelNo : null , transferNo : null , discardNo : null ,
finalStatuses : { relabel : null , transfer : null , discard : null },
2026-10-22 15:41:50 +08:00
cleanup : '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行' ,
2026-08-25 00:24:43 +08:00
}
for ( const [ name , path ] of [[ '贴标' , RELABEL ], [ '仓储转运' , TRANSFER ], [ '货物废弃' , DISCARD ]]) {
2026-09-29 19:48:34 +08:00
const denied = await context . request ( 'GET' , query ( path , { page : 1 , size : 1 }))
context . addStep ( MODULE , `平台统一Token不能冒充客户访问 ${ name } ` , denied , forbidden ( denied ), trace ( denied ), OPTIONS )
2026-08-25 00:24:43 +08:00
}
const orders = await currentOrders ( context , token )
if ( orders . length === 0 ) {
blocker ( context , '取得本轮客户尾程与库存事实' , '客户Token未查到本轮phase4 PDA完成柜,禁止借历史订单' )
return
}
const relabelOrder = orders . find (( value ) => usableCargo ( value ) && nonBlank ( value . row ? . overseasWarehouseId ))
if ( ! relabelOrder ) {
blocker ( context , '本轮贴标订单明细' , `orders= ${ orders . length } ; 缺少真实SKU/品名/箱数或实际海外仓` )
return
}
2026-10-23 04:48:02 +08:00
const relabelDone = await verifyRelabel ( context , token , orders )
2026-08-25 00:24:43 +08:00
const transferDone = await verifyTransfer ( context , token , orders )
const discardDone = await verifyDiscard ( context , token , orders )
context . createdResources . phase4OverseasClientServices . finalStatuses = {
2026-10-22 14:37:59 +08:00
relabel : relabelDone ? 'READY_FOR_OUTBOUND' : null ,
2026-10-22 15:41:50 +08:00
transfer : transferDone ? context . createdResources . phase4OverseasClientServices . transferFulfillment ? . status ?? null : null ,
2026-10-22 14:37:59 +08:00
discard : discardDone ? 'COMPLETED' : null ,
2026-08-25 00:24:43 +08:00
}
}