import { mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { randomInt, randomUUID } from 'node:crypto' const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '') if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('Explicit localhost Gateway required') if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD) throw new Error('Explicit local platform credentials required') const marker = `WT-BOX-${new Date().toISOString().replace(/\D/g, '').slice(0,14)}-${randomInt(1000,9999)}` const output = resolve(process.env.TRANSFER_CARTON_OUTPUT || `reports/${marker}.json`) const report = { marker, gateway, environment: 'LOCAL', photoEvidence: 'SYNTHETIC_TEST_PNG', passed: false, orders: [], checks: [] } let admin, pda, userId const ok = value => value.http === 200 && String(value.body?.code) === '200' async function call(token, method, path, body, expectedFailure = false) { const isForm = body instanceof FormData const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(!isForm && body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body) }) let value; try { value = await response.json() } catch { value = null } const result = { http: response.status, body: value, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId } // Never persist auth bodies, credentials, customer profiles or tokens. report.checks.push({ method, path, expectedFailure, http: result.http, code: value?.code, requestId: result.requestId, traceId: result.traceId, passed: expectedFailure ? !ok(result) && ['A0400','A0422','A0409','A0404','A0403','400','409','404','403'].includes(String(value?.code)) : ok(result), message: ok(result) ? undefined : value?.message }) if (expectedFailure ? report.checks.at(-1).passed !== true : !ok(result)) throw new Error(`${method} ${path}: ${response.status}/${value?.code} ${value?.message}; requestId=${result.requestId}; traceId=${result.traceId}`) return expectedFailure ? result : value.data } function assert(value, name) { report.checks.push({ name, passed: Boolean(value) }); if (!value) throw new Error(name) } async function save() { await mkdir(resolve(output, '..'), { recursive: true }); await writeFile(output, JSON.stringify(report,null,2)) } try { admin = (await call('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })).accessToken const tenants = await call(admin,'GET','/api/platform/v1/iam/tenants?page=1&size=200') const customer = tenants.records.find(row => row.status === 'ACTIVE' && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || row.id === process.env.OVERSEAS_TEST_CUSTOMER_ID)) assert(customer?.id, 'Existing active local customer') const customerId = customer.id const warehouses = (await call(admin,'GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0] assert(warehouse?.id, 'Existing overseas warehouse') report.customerId = customerId; report.warehouseId = warehouse.id const roles = await call(admin,'GET','/api/platform/v1/iam/roles') const role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE') assert(role?.id, 'Existing PDA role') const username = `pda_box_${marker.replace(/\D/g,'').slice(-18)}` const password = process.env.LOCAL_TRANSFER_PDA_PASSWORD if (!password) throw new Error('Explicit fixture PDA password required') const user = await call(admin,'POST','/api/platform/v1/iam/users', { username, password, displayName: marker, phone: `18${randomInt(100000000,999999999)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status:'ACTIVE' }) userId = user.membershipId pda = (await call('', 'POST', '/api/auth/login', { loginId: username, password })).accessToken assert(pda, 'Dedicated warehouse-bound PDA session') await writeFile(resolve('.env.transfer-carton-session'), `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\n`, { mode: 0o600 }) for (const scenario of [ { name:'single-box', counts:[1], actual:1 }, { name:'multi-box-multi-sku', counts:[2,1], actual:3, pallets:[1,2] }, { name:'short-receipt', counts:[3], actual:2, reason:'验收短收一箱' }, { name:'over-receipt', counts:[2], actual:3, reason:'验收超收一箱' } ]) { const key = `${marker}-${scenario.name}` const items = scenario.counts.map((packageCount,index) => ({ skuCode:`${key}-SKU${index+1}`,productNameZh:`验收货物${index+1}`,quantity:120+index*30,packageCount,shippingMark:`${key}-CUSTOMER${index+1}` })) let detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, { operatingWarehouseId:warehouse.id,destinationType:'PRIVATE',privateDestinationAddress:'100 Test Warehouse Road',privateDestinationPostalCode:'90001',privateDestinationContactName:'测试收件人',privateDestinationContactPhone:'0000000000',deliveryAddress:'100 Test Warehouse Road',pickupContactName:'测试操作员',pickupContactPhone:'0000000000',packageCount:scenario.counts.reduce((a,b)=>a+b,0),volumeCbm:0.1,deliveryMethod:'TRUCK',estimatedArrivalAt:new Date().toISOString().slice(0,19),estimatedShipAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:key,needRelabel:false,idempotencyKey:key,items }) assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity && item.packageCount === items[index].packageCount), 'SKU declared units and expected cartons persist independently') const no = detail.transferNo const fact = { scenario: scenario.name, transferNo:no, plannedBoxes:detail.packageCount, actualBoxes:scenario.actual, status:detail.status, palletNos:[] }; report.orders.push(fact); await save() detail = await call(admin,'POST',`/api/platform/v1/last-leg/warehouse-transfers/${no}/review?customerId=${customerId}`,{decision:'APPROVE',remark:key}) assert(detail.status === 'WAIT_PDA_RECEIVING', 'Review enters PDA receiving') const path = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(no)}` await call(pda,'POST',`${path}/palletize`,{idempotencyKey:`${key}-premature`,photoFileIds:['1']},true) detail = await call(pda,'GET',path) const palletAssignments = []; for (const [index, cartonCount] of (scenario.pallets || [scenario.actual]).entries()) { detail = await call(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:`${key}-pallet${index}`}) let pallet = detail.pallets.find(row=>row.status==='DRAFT') assert(pallet?.id,'Saved pallet draft') detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:`${key}-generate${index}`}) pallet=detail.pallets.find(row=>row.id===pallet.id) detail=await call(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:`${key}-bind${index}`,palletCode:pallet.palletNo}) fact.palletNos.push(pallet.palletNo); palletAssignments.push({ id: pallet.id, count: cartonCount }) } let allocationIndex = 0; const palletIds = palletAssignments.flatMap(pallet => Array(pallet.count).fill(pallet.id)); let boxes = detail.cartons.slice(0,scenario.actual).map((box,index)=>({ cartonMark:box.cartonMark,transferItemId:box.transferItemId,transferPalletId:palletIds[allocationIndex++],length:30+index,width:20+index,height:15+index,weight:2+index,skuCode:detail.items.find(row=>row.id===box.transferItemId).skuCode,quantity:5+index })) if(scenario.actual>boxes.length) boxes.push({cartonMark:null,transferItemId:detail.items[0].id,transferPalletId:palletIds[allocationIndex++],length:44,width:33,height:22,weight:4.5,skuCode:detail.items[0].skuCode,quantity:13}) const count = {idempotencyKey:`${key}-count`,boxes,discrepancyReason:scenario.reason || null} await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-bad-sku`,boxes:boxes.map((row,index)=>index?row:{...row,skuCode:'WRONG-SKU'})},true) if (scenario.reason) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-missing-reason`,discrepancyReason:null},true) await call(pda,'POST',`${path}/cartons/count`,{...count,idempotencyKey:`${key}-zero`,boxes:boxes.map((row,index)=>index?row:{...row,quantity:0})},true) detail = await call(pda,'POST',`${path}/cartons/count`,count) assert(detail.packageCount === scenario.counts.reduce((a,b)=>a+b,0), 'Actual receipt preserves declared expected carton count') assert(detail.items.every((item,index) => Number(item.quantity) === items[index].quantity), 'PDA actual units do not overwrite SKU declared units') const counted = detail.cartons.filter(row=>row.status==='COUNTED') for (const pallet of detail.pallets) { const assigned = counted.filter(box => String(box.transferPalletId) === String(pallet.id)); assert(assigned.length === pallet.cartonCount, 'Pallet count derived from assigned boxes'); assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.length)*Number(box.width)*Number(box.height)/1000000,0)-Number(pallet.volumeCbm)) < 0.000001, 'Pallet volume derived from boxes'); assert(Math.abs(assigned.reduce((sum,box)=>sum+Number(box.weight),0)-Number(pallet.grossWeightKg)) < 0.001, 'Pallet weight derived from boxes'); } assert(counted.length===scenario.actual && counted.every(row=>row.countedAt && Number(row.quantity)>0 && Number(row.length)>0), `${scenario.name} persisted box measurements and quantities`) fact.boxes = counted.map(row=>({cartonMark:row.cartonMark,length:row.length,width:row.width,height:row.height,weight:row.weight,quantity:row.quantity})) assert(detail.cartons.filter(row=>row.status==='NOT_RECEIVED').length===Math.max(0,detail.packageCount-scenario.actual),'Missing box facts retained') const replay = await call(pda,'POST',`${path}/cartons/count`,count) assert(JSON.stringify(replay.cartons)===JSON.stringify(detail.cartons),'Count replay preserves original box facts') await call(pda,'POST',`${path}/cartons/count`,{...count,boxes:boxes.map((row,index)=>index?row:{...row,quantity:row.quantity+1})},true) const label = await call(pda,'GET',`${path}/carton-label`) assert(counted.every(row=>label.printableHtml.includes(row.cartonMark)), 'Printable labels include every received carton') const form = new FormData();form.append('file',new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')],{type:'image/png'}),`${key}.png`) const photo=await call(pda,'POST',`${path}/measurement-photos/files`,form) const complete={idempotencyKey:`${key}-complete`,photoFileIds:[photo.fileId]} detail=await call(pda,'POST',`${path}/palletize`,complete) assert(detail.status==='WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus==='INBOUND_COMPLETED','Completed receiving waits for customer confirmation') assert(detail.pallets.reduce((sum,row)=>sum+Number(row.cartonCount),0)===scenario.actual,'Pallet box total equals counted receipts') await call(pda,'POST',`${path}/palletize`,complete) const readback=await call(admin,'GET',`/api/platform/v1/last-leg/warehouse-transfers/${no}?customerId=${customerId}`) assert(JSON.stringify(readback.cartons)===JSON.stringify(detail.cartons),'Platform readback matches persisted PDA box facts') assert(readback.pallets.every(row=>row.status==='COMPLETED'),'Completed pallets persisted') fact.status=readback.status;fact.orderStatus=readback.orderStatus;fact.passed=true await save() } report.passed = report.orders.length===4 && report.orders.every(row=>row.passed) && report.checks.every(row=>row.passed) } catch(error) { report.error=error.message; process.exitCode=1 } finally { if(userId && process.env.TRANSFER_KEEP_PDA !== 'true') { try {await call(admin,'PUT',`/api/platform/v1/iam/users/${userId}/status`,{status:'DISABLED'});report.fixturePdaDisabled=true} catch(error) {report.cleanupError=error.message;report.passed=false;process.exitCode=1} } if(userId) report.fixturePdaMembershipId=userId await save();console.log(JSON.stringify({passed:report.passed,output,orders:report.orders.map(row=>({scenario:row.scenario,transferNo:row.transferNo,status:row.status,passed:row.passed})),checks:report.checks.length,error:report.error})) }