183 lines
8.5 KiB
JavaScript
183 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
|
||
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||
|
|
import { dirname, resolve } from 'node:path'
|
||
|
|
import { spawnSync } from 'node:child_process'
|
||
|
|
|
||
|
|
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||
|
|
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||
|
|
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||
|
|
const transferNo = process.env.WAREHOUSE_TRANSFER_NO || 'WT-WH-20260919-673986'
|
||
|
|
const relabelNo = process.env.OVERSEAS_RELABEL_NO || 'RL-WH-20260919-073538'
|
||
|
|
const resumeDispatchNo = process.env.OVERSEAS_DISPATCH_NO || ''
|
||
|
|
const output = resolve(process.env.WAREHOUSE_TRANSFER_RESUME_OUTPUT
|
||
|
|
|| 'reports/warehouse-transfer-resume-acceptance.json')
|
||
|
|
const marker = `WT-RESUME-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||
|
|
const report = { marker, customerId, transferNo, relabelNo, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||
|
|
|
||
|
|
function id(value) { return value == null ? '' : String(value) }
|
||
|
|
function assert(value, message) { if (!value) throw new Error(message) }
|
||
|
|
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
|
||
|
|
|
||
|
|
async function api(base, token, method, path, body) {
|
||
|
|
const response = await fetch(`${base}${path}`, {
|
||
|
|
method,
|
||
|
|
signal: AbortSignal.timeout(15000),
|
||
|
|
headers: {
|
||
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||
|
|
},
|
||
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||
|
|
})
|
||
|
|
const text = await response.text()
|
||
|
|
let value
|
||
|
|
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||
|
|
if (!response.ok || String(value?.code) !== '200') {
|
||
|
|
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||
|
|
}
|
||
|
|
return value.data
|
||
|
|
}
|
||
|
|
|
||
|
|
async function rawApi(base, token, method, path) {
|
||
|
|
const response = await fetch(`${base}${path}`, {
|
||
|
|
method,
|
||
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
|
|
})
|
||
|
|
const text = await response.text()
|
||
|
|
let value
|
||
|
|
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
||
|
|
return { http: response.status, code: String(value?.code || '') }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createPda(adminToken, warehouseId) {
|
||
|
|
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
||
|
|
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
||
|
|
assert(role, 'Active overseas PDA role not found')
|
||
|
|
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
||
|
|
const username = `pda_resume_${suffix}`
|
||
|
|
const password = `PdaResume${suffix}A9`
|
||
|
|
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||
|
|
username,
|
||
|
|
displayName: `Warehouse transfer resume acceptance ${suffix}`,
|
||
|
|
phone: `17${suffix.slice(-9)}`,
|
||
|
|
email: `${username}@example.com`,
|
||
|
|
tenantId: '0',
|
||
|
|
roleIds: [id(role.id)],
|
||
|
|
overseasWarehouseId: warehouseId,
|
||
|
|
password,
|
||
|
|
status: 'ACTIVE',
|
||
|
|
})
|
||
|
|
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
||
|
|
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
||
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||
|
|
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
|
||
|
|
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
|
||
|
|
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
|
||
|
|
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
|
||
|
|
}
|
||
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||
|
|
}
|
||
|
|
throw new Error('Temporary PDA security projection did not converge')
|
||
|
|
}
|
||
|
|
|
||
|
|
await mkdir(dirname(output), { recursive: true })
|
||
|
|
let admin
|
||
|
|
let pda
|
||
|
|
try {
|
||
|
|
admin = await api(domestic, '', 'POST', '/api/auth/login', {
|
||
|
|
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||
|
|
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||
|
|
})
|
||
|
|
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
|
||
|
|
const ticket = await api(domestic, admin.accessToken, 'POST',
|
||
|
|
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} resume acceptance` })
|
||
|
|
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||
|
|
assert(merchant.overseasAccessToken, 'Merchant impersonation did not return an overseas token')
|
||
|
|
|
||
|
|
const transfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||
|
|
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||
|
|
assert(transfer.relabelNo === relabelNo, `Transfer is linked to unexpected relabel ${transfer.relabelNo}`)
|
||
|
|
assert(['READY_FOR_DISPATCH', 'OUTBOUNDED', 'COMPLETED'].includes(transfer.status),
|
||
|
|
`Unexpected transfer status ${transfer.status}`)
|
||
|
|
const warehouseId = id(transfer.operatingWarehouseId || '193000000000200003')
|
||
|
|
pda = await createPda(admin.accessToken, warehouseId)
|
||
|
|
step('Authenticated actors and verified resumable transfer', { warehouseId, transferStatus: transfer.status })
|
||
|
|
|
||
|
|
let dispatch
|
||
|
|
if (resumeDispatchNo) {
|
||
|
|
dispatch = { dispatchNo: resumeDispatchNo, status: 'RESUMED' }
|
||
|
|
step('Resumed existing smart pre-dispatch', dispatch)
|
||
|
|
} else {
|
||
|
|
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||
|
|
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL'
|
||
|
|
&& String(value.orderRefs || '').split(',').includes(relabelNo))
|
||
|
|
assert(rows.length === 2, `Expected two relabel dispatch candidates, got ${rows.length}`)
|
||
|
|
assert(rows.every((value) => Number(value.packageCount) > 0), 'A dispatch candidate has no packages')
|
||
|
|
step('Verified smart-dispatch candidates', {
|
||
|
|
pallets: rows.map((value) => ({ warehousePalletId: id(value.warehousePalletId), palletNo: value.palletNo,
|
||
|
|
packageCount: Number(value.packageCount) })),
|
||
|
|
})
|
||
|
|
|
||
|
|
dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
|
||
|
|
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
||
|
|
planPalletIds: rows.map((value) => id(value.warehousePalletId)),
|
||
|
|
route: marker,
|
||
|
|
relabelNo,
|
||
|
|
})
|
||
|
|
assert(dispatch.dispatchNo, 'Pre-dispatch did not return a dispatch number')
|
||
|
|
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||
|
|
}
|
||
|
|
|
||
|
|
const sourceReport = resolve('reports', `${marker}-source.json`)
|
||
|
|
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
|
||
|
|
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
|
||
|
|
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
|
||
|
|
}] }, null, 2), 'utf8')
|
||
|
|
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
|
||
|
|
cwd: resolve('.'),
|
||
|
|
stdio: 'inherit',
|
||
|
|
env: {
|
||
|
|
...process.env,
|
||
|
|
ADMIN_TEST_GATEWAY: domestic,
|
||
|
|
ADMIN_OVERSEAS_GATEWAY: overseas,
|
||
|
|
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken,
|
||
|
|
MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
|
||
|
|
PDA_OVERSEAS_ACCESS_TOKEN: pda.token,
|
||
|
|
OVERSEAS_TEST_CUSTOMER_ID: customerId,
|
||
|
|
OVERSEAS_ACCEPTANCE_SOURCE: sourceReport,
|
||
|
|
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
if (child.error) throw child.error
|
||
|
|
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
|
||
|
|
|
||
|
|
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||
|
|
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||
|
|
Object.assign(report, {
|
||
|
|
passed: true,
|
||
|
|
dispatchNo: dispatch.dispatchNo,
|
||
|
|
transferStatus: finalTransfer.status,
|
||
|
|
fulfillmentReport,
|
||
|
|
finishedAt: new Date().toISOString(),
|
||
|
|
})
|
||
|
|
} catch (error) {
|
||
|
|
report.error = error instanceof Error ? error.message : String(error)
|
||
|
|
report.finishedAt = new Date().toISOString()
|
||
|
|
throw error
|
||
|
|
} finally {
|
||
|
|
if (admin?.accessToken && pda?.membershipId) {
|
||
|
|
try {
|
||
|
|
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
|
||
|
|
status: 'DISABLED',
|
||
|
|
})
|
||
|
|
report.pdaCleanup = 'DISABLED'
|
||
|
|
} catch (error) {
|
||
|
|
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(JSON.stringify(report, null, 2))
|