146 lines
11 KiB
JavaScript
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, 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))
|
|
}
|