2026-09-14 12:00:48 +08:00
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
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 ) {
const response = await context . requestOverseas ( method , path , body , {}, token )
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 )
context . assert ( MODULE , '客户登录返回海外访问Token' , response , nonBlank ( auth ? . overseasAccessToken ),
`tokenPresent= ${ Boolean ( auth ? . overseasAccessToken ) } ; ${ trace ( response ) } ` , OPTIONS )
return nonBlank ( auth ? . overseasAccessToken ) ? auth . overseasAccessToken : null
}
async function currentOrders ( context , token ) {
const pda = context . createdResources . phase4OverseasPda
const containerNos = [... new Set ([...( pda . completedContainers || []), pda . containerNo ]. map ( id ). filter ( Boolean ))]
const result = []
for ( const containerNo of containerNos ) {
const page = await merchant ( context , token , 'GET' , `查询本轮完成柜 ${ containerNo } 的客户尾程订单` ,
query ( ORDERS , { page : 1 , size : 200 , containerNo }))
for ( const row of records ( page . data )) {
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 )
}
async function verifyRelabel ( context , token , order ) {
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-09-09 21:13:10 +08:00
const inventory = await merchant ( context , token , 'GET' , '客户查询贴标权威库存候选' ,
query ( ` ${ RELABEL } /inventory-candidates` , {
operationWarehouseId : order . row . overseasWarehouseId ,
inboundCode : order . row . inboundCode ,
}))
const source = ( Array . isArray ( inventory . data ) ? inventory . data : []). find (( item ) =>
id ( item ? . inventoryBalanceId ) && id ( item ? . inboundOrderId )
&& item ? . skuCode === cargo . skuCode
&& item ? . positions ? . some ? .(( position ) => id ( position ? . positionId ) && Number ( position ? . availablePackages ) > 0 ))
const position = source ? . positions ? . find ? .(( item ) => id ( item ? . positionId ) && Number ( item ? . availablePackages ) > 0 )
context . assert ( MODULE , '贴标申请使用当前客户真实库存余额、位置和来源入库单' , inventory . response ,
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-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 ,
relabelFileId : null , relabelFileName : null , relabelFileUrl : null ,
2026-09-07 14:03:00 +08:00
cartonMark : `CM- ${ String ( context . marker ). slice ( - 32 ) } ` ,
fbaCartonMark : `FBA- ${ String ( context . marker ). slice ( - 32 ) } ` ,
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 ,
Boolean ( relabelNo ) && created . data ? . application ? . status === 'PENDING_PROCESSING'
&& 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 )
const hashConflict = await context . requestOverseas ( 'POST' , RELABEL , { ... body , remark : ` ${ context . marker } -DIFFERENT` }, {}, token )
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 ,
id ( detail . data ? . application ? . relabelNo ) === relabelNo && detail . data ? . application ? . status === 'PENDING_PROCESSING'
&& Array . isArray ( detail . data ? . items ) && detail . data . items . length === 1
&& Array . isArray ( detail . data ? . history ) && detail . data . history . some (( item ) => item ? . toStatus === 'PENDING_PROCESSING' ),
`items= ${ detail . data ? . items ? . length ?? 'missing' } , histories= ${ detail . data ? . history ? . length ?? 'missing' } ; ${ trace ( detail . response ) } ` , OPTIONS )
context . createdResources . phase4OverseasClientServices . relabelNo = relabelNo
return true
}
async function verifyTransfer ( context , token , orders ) {
const order = orders . find (( value ) => nonBlank ( value . row ? . inboundCode )
&& nonBlank ( value . row ? . fbaWarehouseCode ) && nonBlank ( value . row ? . overseasWarehouseId ) && usableCargo ( value ))
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 ,
fbaBoxNos : cargo . shippingMark || null , remark : context . marker , needRelabel : false ,
idempotencyKey : ` ${ context . marker } -CLIENT-TRANSFER` . slice ( 0 , 255 ),
items : [{ skuCode : cargo . skuCode , productNameZh : cargo . productNameZh ,
packageCount : packages , shippingMark : cargo . shippingMark || null }],
}
const created = await merchant ( context , token , 'POST' , '客户创建仓储转运申请' , TRANSFER , body )
const transferNo = id ( created . data ? . transferNo )
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 )
const hashConflict = await context . requestOverseas ( 'POST' , TRANSFER , { ... body , remark : ` ${ context . marker } -DIFFERENT` }, {}, token )
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-09-14 12:00:48 +08:00
return verifyWarehouseTransferReview ( context , token , order . row . customerId , body , transferNo )
2026-08-25 00:24:43 +08:00
}
async function verifyDiscard ( context , token , markerOrders ) {
const response = await context . requestOverseas ( 'GET' , ` ${ DISCARD } /inventory-candidates` , undefined , {}, token )
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 ))
&& Array . isArray ( value ? . skus ) && value . skus . some (( sku ) => nonBlank ( sku ? . skuCode ) && Number ( sku ? . availablePackages ) > 0 )) : null
if ( ! candidate ) {
blocker ( context , '本轮货物废弃库存候选' ,
`candidateOrders= ${ Array . isArray ( candidates ) ? candidates . map (( value ) => id ( value ? . orderId )). join ( ',' ) : 'missing' } , markerOrders= ${ [... markerOrderIds ]. join ( ',' ) || 'missing' } ; 禁止借历史一件代发库存` , response )
return false
}
const sku = candidate . skus . find (( value ) => nonBlank ( value ? . skuCode ) && Number ( value ? . availablePackages ) > 0 )
const body = {
items : [{ orderId : id ( candidate . orderId ), skuCode : sku . skuCode , discardPackages : 1 }],
idempotencyKey : ` ${ context . marker } -CLIENT-DISCARD` . slice ( 0 , 255 ), remark : context . marker ,
}
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
return true
}
export async function run ( context ) {
context . createdResources . phase4OverseasClientServices = {
marker : context . marker , relabelNo : null , transferNo : null , discardNo : null ,
finalStatuses : { relabel : null , transfer : null , discard : null },
2026-09-14 12:00:48 +08:00
cleanup : 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked' ,
2026-08-25 00:24:43 +08:00
}
const token = await loginMerchant ( context )
if ( ! token ) return
for ( const [ name , path ] of [[ '贴标' , RELABEL ], [ '仓储转运' , TRANSFER ], [ '货物废弃' , DISCARD ]]) {
const denied = await context . requestOverseas ( 'GET' , query ( path , { page : 1 , size : 1 }))
context . addStep ( MODULE , `平台海外Token不能冒充客户访问 ${ name } ` , denied , forbidden ( denied ), trace ( denied ), OPTIONS )
}
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
}
const relabelDone = await verifyRelabel ( context , token , relabelOrder )
const transferDone = await verifyTransfer ( context , token , orders )
const discardDone = await verifyDiscard ( context , token , orders )
context . createdResources . phase4OverseasClientServices . finalStatuses = {
relabel : relabelDone ? 'PENDING_PROCESSING' : null ,
2026-09-14 12:00:48 +08:00
transfer : transferDone ? 'APPROVED' : null ,
2026-08-25 00:24:43 +08:00
discard : discardDone ? 'PENDING_REVIEW' : null ,
}
}