Files
baiyue-firstleg/scripts/local-return-relabel-acceptance.mjs

146 lines
11 KiB
JavaScript

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, expectedReceivedQuantity: 0, 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))
}