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') const receiptBody = { returnNo, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` } 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)) }