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 ) {
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-09-29 19:48:34 +08:00
context . assert ( MODULE , '客户登录返回海外访问Token' , response , nonBlank ( auth ? . accessToken ),
`tokenPresent= ${ Boolean ( auth ? . accessToken ) } ; ${ trace ( response ) } ` , OPTIONS )
return nonBlank ( auth ? . accessToken ) ? auth . accessToken : null
2026-08-25 00:24:43 +08:00
}
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 ,
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
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 ,
2026-09-23 20:28:26 +08:00
fbaBoxNos : cargo . shippingMark || null , remark : context . marker , needRelabel : true ,
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 ,
packageCount : packages , shippingMark : cargo . shippingMark || null }],
2026-09-23 20:28:26 +08:00
relabelItems : [{ operationType : 'CARTON_AND_SKU' , customerCartonMarks : [ cargo . shippingMark || `CM- ${ context . marker } ` ],
originalSku : cargo . skuCode , newSku : ` ${ cargo . skuCode } -NEW` . slice ( 0 , 128 ), quantity : 1 ,
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-09-23 20:50:07 +08:00
// The overseas gateway occasionally drops a connection while the submit
// request is still being processed. Retry transport failures only, then
// reconcile from the authoritative detail before asserting the state.
let submitted = null
for ( let attempt = 1 ; attempt <= 3 ; attempt += 1 ) {
2026-09-29 19:48:34 +08:00
const response = await context . request ( 'POST' , ` ${ TRANSFER } / ${ encodeURIComponent ( transferNo ) } /submit` , undefined , token , {})
2026-09-23 20:50:07 +08:00
if ( response ? . http === 0 && attempt < 3 ) {
await new Promise ( resolve => setTimeout ( resolve , 800 * attempt ))
continue
}
const data = context . apiSuccess ( MODULE , '客户提交仓储转运申请' , response , trace ( response ), OPTIONS )
submitted = { response , data }
break
}
if ( submitted ? . data ) created . data = submitted . data
if ( created . data ? . status !== 'PENDING_REVIEW' ) {
const reconciled = await merchant ( context , token , 'GET' , '提交后回查仓储转运申请' ,
` ${ TRANSFER } / ${ encodeURIComponent ( transferNo ) } ` )
if ( reconciled . data ) created . data = reconciled . data
}
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-09-14 12:00:48 +08:00
return verifyWarehouseTransferReview ( context , token , order . row . customerId , body , transferNo )
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 ))
&& 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 ,
}
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' ,
contract : 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.' ,
}
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-03 06:26:33 +08:00
try { await verifyDiscardApproval ( context , token , body , discardNo , proof ) }
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
}
async function discardSnapshot ( context , token , proof , discardNo ) {
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' )
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'
proof . remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger'
}
2026-08-25 00:24:43 +08:00
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-10-05 12:00:09 +08:00
cleanup : 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch 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 ]]) {
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
}
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 = {
2026-09-29 14:18:20 +08:00
relabel : relabelDone ? 'PENDING_REVIEW' : null ,
2026-09-14 12:00:48 +08:00
transfer : transferDone ? 'APPROVED' : null ,
2026-10-03 06:26:33 +08:00
discard : discardDone ? 'APPROVED' : null ,
2026-08-25 00:24:43 +08:00
}
}