2026-09-22 08:49:21 +08:00
import { mkdir , writeFile } from 'node:fs/promises'
import { dirname , resolve } from 'node:path'
import { createHash } from 'node:crypto'
import { AcceptanceRun , parseArguments , buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { resolvePickerAccess , createPicker , disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { approveReturnFixture } from './admin-test-env-modules/phase4-overseas-return-receipt.mjs'
import { completeReturnRelabel , outboundRelabelledReturn } from './admin-test-env-modules/phase4-overseas-return-followups.mjs'
const options = parseArguments ( process . argv . slice ( 2 ))
for ( const url of [ options . gateway , options . overseasGateway ]) {
if ( ! [ '127.0.0.1' , 'localhost' ]. includes ( new URL ( url ). hostname )) throw new Error ( 'Local gateways required' )
}
const customerId = process . env . OVERSEAS_TEST_CUSTOMER_ID
if ( ! customerId || ! options . username || ! options . password || ! process . env . INTERNAL_SERVICE_TOKEN ) throw new Error ( 'Explicit customer, credentials and internal token required' )
const context = new AcceptanceRun ( options )
const verifyRemaining = process . env . LOCAL_RETURN_VERIFY_REMAINING === 'true'
const receiptQuantity = verifyRemaining ? 3 : 1
const output = resolve ( options . output || `reports/local-return-relabel-single- ${ context . marker } .json` )
const moduleName = 'local-return-relabel-single'
let picker
let fatal = null
const facts = { marker : context . marker , customerId , scope : verifyRemaining ? 'REMAINING_RETURN_RELABEL' : 'ONE_NEW_RETURN_SKU_ON_EXISTING_LOCAL_CUSTOMER' , photoEvidence : 'SYNTHETIC_TEST_IMAGE' }
context . context . merchantQualificationFixture = { marker : context . marker , tenantId : customerId }
context . createdResources . phase4OverseasReturnReceipt = facts
const success = response => response . http === 200 && String ( response . body ? . code ) === '200'
async function save () {
await mkdir ( dirname ( output ), { recursive : true })
const summary = buildAcceptanceSummary ( context . steps , [], [])
await writeFile ( output , JSON . stringify ({ success : ! fatal && ( verifyRemaining ? facts . remainingRelabelPassed === true : facts . relabelOutboundNo != null ) && summary . failed === 0 , fatal , summary , facts , steps : context . steps }, null , 2 ))
}
function check ( name , condition ) {
context . assert ( moduleName , name , null , Boolean ( condition ), '' , { deploymentBlocker : true })
if ( ! condition ) throw new Error ( name )
}
async function command ( region , token , method , path , body , headers = {}) {
const response = region === 'cn' ? await context . request ( method , path , body , token )
: await context . requestOverseas ( method , path , body , headers , token )
context . addStep ( moduleName , ` ${ method } ${ path } ` , response , success ( response ), '' , { deploymentBlocker : true })
if ( ! success ( response )) throw new Error ( ` ${ method } ${ path } : ${ response . body ? . code } ${ response . body ? . message } ; requestId= ${ response . requestId } ; traceId= ${ response . traceId } ` )
return response . body . data
}
async function upload ( token , path , bytes , name , type ) {
const form = new FormData ()
form . append ( 'file' , new Blob ([ bytes ], { type }), name )
const response = await context . requestOverseasMultipart ( 'POST' , path , form , token )
context . addStep ( moduleName , `Upload ${ path } ` , response , success ( response ), '' , { deploymentBlocker : true })
if ( ! success ( response )) throw new Error ( `Upload failed: ${ response . body ? . code } ${ response . body ? . message } ` )
return response . body . data
}
try {
const login = await command ( 'cn' , '' , 'POST' , '/api/auth/login' , { loginId : options . username , password : options . password })
context . token = login . accessToken
context . overseasToken = login . overseasAccessToken
const ticket = await command ( 'cn' , context . token , 'POST' , `/api/platform/v1/iam/tenants/ ${ customerId } /impersonation` , { reason : ` ${ context . marker } 本地单票退货换标验收` })
const merchant = await command ( 'cn' , '' , 'POST' , '/api/auth/impersonations/exchange' , { code : ticket . code })
const token = merchant . overseasAccessToken
const permissions = await command ( 'cn' , merchant . accessToken , 'GET' , '/api/merchant/v1/iam/authorization/permissions' )
check ( 'Merchant return create and relabel permissions' , permissions . includes ( 'merchant:overseas-warehouse:return-order:create' ) && permissions . includes ( 'merchant:overseas-warehouse:return-order:relabel' ))
await command ( 'os' , token , 'GET' , '/api/merchant/v1/last-leg/return-orders?page=1&size=1' )
const warehouses = await command ( 'cn' , context . token , 'GET' , '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=OVERSEAS&status=ACTIVE&capabilityCode=RECEIVING' )
const warehouse = warehouses . records . find ( row => row . id === process . env . OVERSEAS_TEST_WAREHOUSE_ID ) || warehouses . records [ 0 ]
check ( 'Existing active overseas receiving warehouse' , warehouse ? . id )
const warehouseId = String ( warehouse . id )
facts . warehouseId = warehouseId
let destination
for ( let page = 1 ; page <= 20 ; page ++ ) {
const data = await command ( 'cn' , context . token , 'GET' , `/api/platform/v1/warehouse-admin?page= ${ page } &size=200&warehouseType=FBA&status=ACTIVE` )
destination = data . records . find ( row => row . warehouseCode === 'SCK8' )
if ( destination || page * 200 >= Number ( data . total )) break
}
check ( 'Existing active SCK8 destination' , destination ? . id )
const access = await resolvePickerAccess ( context )
check ( 'PDA role available' , access )
picker = await createPicker ( context , 93 , warehouseId , access )
check ( 'PDA session ready' , picker ? . token )
const skuCode = `LOCAL-RETURN- ${ Date . now () } `
facts . skuCode = skuCode
const root = '/api/merchant/v1/last-leg/return-orders'
const receipts = '/api/pda/v1/last-leg/return-receipts'
const created = await command ( 'os' , token , 'POST' , root , { overseasWarehouseId : warehouseId , warehouseContactName : '本地验收' , warehouseContactPhone : '0000000000' , customerReferenceNo : context . marker , needRelabel : false , remark : context . marker , idempotencyKey : ` ${ context . marker } -CREATE` , items : [{ skuCode , productNameZh : '本地退货换标验收商品' , expectedQuantity : receiptQuantity }] })
const returnNo = created . order . returnNo
facts . returnNo = returnNo
await save ()
await approveReturnFixture ( context , token , created )
await command ( 'os' , picker . token , 'POST' , ` ${ receipts } /start` , { returnNo })
const pallet = await command ( 'os' , picker . token , 'POST' , ` ${ receipts } /pallets` , { returnNo , skuCode })
const png = Buffer . from ( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==' , 'base64' )
await upload ( picker . token , ` ${ receipts } / ${ returnNo } /photos/files` , png , 'synthetic-receipt.png' , 'image/png' )
2026-10-22 23:06:00 +08:00
const receiptBody = { returnNo , expectedReceivedQuantity : 0 , startedBy : 'RETURN_NO' , palletCode : pallet . palletCode , items : [{ skuCode , receivedQuantity : receiptQuantity }], idempotencyKey : ` ${ context . marker } -RECEIPT` }
2026-09-22 08:49:21 +08:00
const received = await command ( 'os' , picker . token , 'POST' , ` ${ receipts } /confirm` , receiptBody )
const replay = await command ( 'os' , picker . token , 'POST' , ` ${ receipts } /confirm` , receiptBody )
check ( 'Receipt completed once with one stock and fee batch' , received . orderStatus === 'COMPLETED' && Number ( received . receivedQuantity ) === receiptQuantity && received . receiptNo === replay . receiptNo && received . receiptBatchNo === replay . receiptBatchNo )
facts . receiptNo = received . receiptNo
facts . receiptBatchNo = received . receiptBatchNo
facts . sourcePalletCode = pallet . palletCode
await save ()
const relabel = await command ( 'os' , token , 'POST' , ` ${ root } / ${ returnNo } /relabel` , { items : [{ skuCode , quantity : 1 }], fileScope : 'CARTON_TOTAL' , destinationFbaWarehouseId : String ( destination . id ), destinationFbaWarehouseCode : destination . warehouseCode , cartonRemark : context . marker , idempotencyKey : ` ${ context . marker } -RELABEL` })
const relabelNo = relabel . relabelNo
check ( 'Relabel references the new return' , relabel . returnNo === returnNo && relabel . status === 'PENDING_REVIEW' && relabelNo )
facts . relabelNo = relabelNo
await save ()
const rendered = await fetch ( 'http://127.0.0.1:9207/internal/print/v1/pdfs/inbound-marks' , { method : 'POST' , headers : { 'X-Internal-Service-Token' : process . env . INTERNAL_SERVICE_TOKEN , 'X-Operator-Id' : '1' , 'Content-Type' : 'application/json' }, body : JSON . stringify ({ templateCode : 'INBOUND_MARK_V1' , pageSpec : { widthMm : 100 , heightMm : 100 }, downloadFileName : 'local-return-relabel.pdf' , labels : [{ topCode : skuCode , warehouseText : destination . warehouseCode , cartonText : 'QTY: 1' , routeText : 'LOCAL ACCEPTANCE' , qrContent : relabelNo }] }) })
const pdf = Buffer . from ( await rendered . arrayBuffer ())
check ( 'Print service returned real PDF' , rendered . ok && pdf . subarray ( 0 , 5 ). toString () === '%PDF-' )
facts . labelPdfSha256 = createHash ( 'sha256' ). update ( pdf ). digest ( 'hex' )
const uploaded = await upload ( token , `/api/merchant/v1/last-leg/relabel-applications/ ${ relabelNo } /files?scope=CARTON_TOTAL` , pdf , 'local-return-relabel.pdf' , 'application/pdf' )
facts . labelFileId = uploaded . fileId
await completeReturnRelabel ( context , token , picker . token , relabelNo , returnNo )
await save ()
if ( verifyRemaining ) {
const candidates = await command ( 'os' , token , 'GET' , ` ${ root } / ${ returnNo } /relabel-candidates` )
check ( 'Two unlabelled pieces remain on the source pallet' , candidates . filter ( row => row . cartonMark === pallet . palletCode ). flatMap ( row => row . items ). reduce (( sum , row ) => sum + Number ( row . receivedQuantity ), 0 ) === 2 )
const body = { items : [{ skuCode , quantity : 1 }], fileScope : 'CARTON_TOTAL' , destinationFbaWarehouseId : String ( destination . id ), destinationFbaWarehouseCode : destination . warehouseCode , cartonRemark : ` ${ context . marker } -SECOND` , idempotencyKey : ` ${ context . marker } -RELABEL-SECOND` }
const second = await command ( 'os' , token , 'POST' , ` ${ root } / ${ returnNo } /relabel` , body )
check ( 'Second application accepts remaining goods on same return' , second . returnNo === returnNo && second . relabelNo !== relabelNo && second . status === 'PENDING_REVIEW' )
facts . secondRelabelNo = second . relabelNo
await save ()
const repeated = await command ( 'os' , token , 'POST' , ` ${ root } / ${ returnNo } /relabel` , body )
check ( 'Second application replay does not duplicate allocation' , repeated . relabelNo === second . relabelNo )
await upload ( token , `/api/merchant/v1/last-leg/relabel-applications/ ${ second . relabelNo } /files?scope=CARTON_TOTAL` , pdf , 'local-return-second.pdf' , 'application/pdf' )
context . marker = ` ${ facts . marker } -SECOND`
try {
await completeReturnRelabel ( context , token , picker . token , second . relabelNo , returnNo )
} finally {
context . marker = facts . marker
}
const after = await command ( 'os' , token , 'GET' , ` ${ root } / ${ returnNo } /relabel-candidates` )
check ( 'Exactly one unlabelled piece remains after two completed batches' , after . filter ( row => row . cartonMark === pallet . palletCode ). flatMap ( row => row . items ). reduce (( sum , row ) => sum + Number ( row . receivedQuantity ), 0 ) === 1 )
facts . remainingRelabelPassed = true
} else {
await outboundRelabelledReturn ( context , token , picker . token , relabelNo , returnNo , warehouseId )
}
const final = ( await command ( 'os' , token , 'GET' , ` ${ root } / ${ returnNo } ` )). order
facts . finalReturnStatus = final . status
facts . finalRelabelStatus = final . relabelStatus
} catch ( error ) {
fatal = error . message
process . exitCode = 1
} finally {
if ( picker ? . membershipId ) {
try { await disablePicker ( context , picker , 93 ) } catch ( error ) { fatal = ` ${ fatal || '' } cleanup: ${ error . message } ` ; process . exitCode = 1 }
}
await save ()
console . log ( JSON . stringify ({ output , fatal , facts , summary : buildAcceptanceSummary ( context . steps , [], []), failures : context . steps . filter ( step => ! step . passed ) }, null , 2 ))
}