394 lines
21 KiB
JavaScript
394 lines
21 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'
|
||
|
|
let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || ''
|
||
|
|
let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || ''
|
||
|
|
const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8'
|
||
|
|
const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT
|
||
|
|
|| 'reports/warehouse-transfer-remaining-acceptance.json')
|
||
|
|
const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||
|
|
const report = { marker, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||
|
|
|
||
|
|
function id(value) { return value == null ? '' : String(value) }
|
||
|
|
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||
|
|
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(10000),
|
||
|
|
headers: {
|
||
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
|
|
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||
|
|
},
|
||
|
|
body: body === undefined ? undefined : body instanceof FormData ? body : 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) {
|
||
|
|
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_wt_${suffix}`
|
||
|
|
const password = `PdaWt${suffix}A9`
|
||
|
|
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
||
|
|
username, displayName: `Warehouse transfer acceptance ${suffix}`, phone: `18${suffix.slice(-9)}`,
|
||
|
|
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
||
|
|
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
|
||
|
|
})
|
||
|
|
const membershipId = id(created.membershipId)
|
||
|
|
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, token: login.overseasAccessToken }
|
||
|
|
}
|
||
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
|
||
|
|
}
|
||
|
|
throw new Error('Temporary PDA security projection did not converge')
|
||
|
|
}
|
||
|
|
|
||
|
|
async function findMerchantScope(adminToken) {
|
||
|
|
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
||
|
|
const ordered = [...(tenants.records || [])].sort((left, right) => {
|
||
|
|
if (customerId && id(left.id) === customerId) return -1
|
||
|
|
if (customerId && id(right.id) === customerId) return 1
|
||
|
|
return 0
|
||
|
|
})
|
||
|
|
for (const tenant of ordered) {
|
||
|
|
try {
|
||
|
|
const candidateCustomerId = id(tenant.id)
|
||
|
|
const ticket = await api(domestic, adminToken, 'POST',
|
||
|
|
`/api/platform/v1/iam/tenants/${candidateCustomerId}/impersonation`, {
|
||
|
|
reason: `${marker} warehouse transfer acceptance`,
|
||
|
|
})
|
||
|
|
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||
|
|
if (!merchant.overseasAccessToken) continue
|
||
|
|
try {
|
||
|
|
await api(overseas, merchant.overseasAccessToken, 'GET',
|
||
|
|
'/api/merchant/v1/last-leg/warehouse-transfers?page=1&size=1')
|
||
|
|
} catch {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
let warehouses = { records: [] }
|
||
|
|
try {
|
||
|
|
warehouses = await api(domestic, merchant.accessToken, 'GET',
|
||
|
|
'/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
|
||
|
|
} catch {
|
||
|
|
// The transfer service validates the shared operation warehouse independently.
|
||
|
|
}
|
||
|
|
const selected = (warehouses.records || []).find((value) => value.status === 'ACTIVE'
|
||
|
|
&& (!warehouseId || id(value.id) === warehouseId))
|
||
|
|
|| (warehouses.records || []).find((value) => value.status === 'ACTIVE')
|
||
|
|
const selectedWarehouseId = selected?.id ? id(selected.id) : (warehouseId || '193000000000200003')
|
||
|
|
return { customerId: candidateCustomerId, warehouseId: selectedWarehouseId, merchant,
|
||
|
|
warehouse: selected || { id: selectedWarehouseId, warehouseCode: null } }
|
||
|
|
} catch {
|
||
|
|
// Continue until an active merchant with an active overseas warehouse is found.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw new Error('No active merchant with an active overseas warehouse was found')
|
||
|
|
}
|
||
|
|
|
||
|
|
async function uploadPng(token, path, fileName) {
|
||
|
|
const form = new FormData()
|
||
|
|
form.append('file', new Blob([
|
||
|
|
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
|
||
|
|
], { type: 'image/png' }), fileName)
|
||
|
|
return api(overseas, token, 'POST', path, form)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function completeDelivery(platformToken, deliveryNo) {
|
||
|
|
let delivery = await api(overseas, platformToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||
|
|
if (delivery.order.status === 'WAIT_DELIVERY') {
|
||
|
|
await api(overseas, platformToken, 'PUT',
|
||
|
|
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
|
||
|
|
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
|
||
|
|
})
|
||
|
|
delivery = await api(overseas, platformToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||
|
|
}
|
||
|
|
if (delivery.order.status !== 'DELIVERED') {
|
||
|
|
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
|
||
|
|
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
|
||
|
|
const documents = await api(overseas, platformToken, 'GET', documentsPath)
|
||
|
|
const form = new FormData()
|
||
|
|
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
|
||
|
|
`${marker}-${deliveryNo}-receipt.txt`)
|
||
|
|
const receipt = await api(overseas, platformToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
|
||
|
|
const bindings = (documents.categories || []).map((category) => ({
|
||
|
|
category: category.category,
|
||
|
|
fileIds: (category.files || []).map((file) => id(file.fileId)),
|
||
|
|
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||
|
|
await api(overseas, platformToken, 'PUT', documentsPath, {
|
||
|
|
expectedVersion: Number(documents.version), idempotencyKey: key(`DELIVERY-${deliveryNo}`),
|
||
|
|
bolNo: documents.bolNo || null,
|
||
|
|
documents: [...bindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
|
||
|
|
})
|
||
|
|
delivery = await api(overseas, platformToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||
|
|
}
|
||
|
|
assert(delivery.order.status === 'DELIVERED', `${deliveryNo} did not reach DELIVERED`)
|
||
|
|
return delivery
|
||
|
|
}
|
||
|
|
|
||
|
|
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 scope = await findMerchantScope(admin.accessToken)
|
||
|
|
customerId = scope.customerId
|
||
|
|
warehouseId = scope.warehouseId
|
||
|
|
const merchant = scope.merchant
|
||
|
|
Object.assign(report, { customerId, warehouseId, warehouseCode: scope.warehouse.warehouseCode })
|
||
|
|
pda = await createPda(admin.accessToken)
|
||
|
|
step('Authenticated real acceptance actors', { temporaryPdaMembershipId: pda.membershipId })
|
||
|
|
|
||
|
|
const priorDeliveryNo = process.env.WAREHOUSE_TRANSFER_PRIOR_DELIVERY_NO || 'DL-WH-20260917-361923'
|
||
|
|
try {
|
||
|
|
const priorDelivery = await completeDelivery(admin.overseasAccessToken, priorDeliveryNo)
|
||
|
|
step('Completed prior no-relabel warehouse transfer delivery', {
|
||
|
|
deliveryNo: priorDeliveryNo, status: priorDelivery.order.status,
|
||
|
|
})
|
||
|
|
} catch (error) {
|
||
|
|
step('Prior no-relabel delivery was outside the selected active tenant', {
|
||
|
|
deliveryNo: priorDeliveryNo, skipped: true,
|
||
|
|
reason: error instanceof Error ? error.message : String(error),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`]
|
||
|
|
const transfer = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||
|
|
'/api/merchant/v1/last-leg/warehouse-transfers', {
|
||
|
|
operatingWarehouseId: warehouseId,
|
||
|
|
fbaWarehouseCode: destinationCode,
|
||
|
|
destinationType: 'FBA',
|
||
|
|
destinationWarehouseCode: destinationCode,
|
||
|
|
destinationWarehouseName: destinationCode,
|
||
|
|
deliveryAddress: `Acceptance inbound address ${marker}`,
|
||
|
|
packageCount: 2,
|
||
|
|
volumeCbm: 0.12,
|
||
|
|
deliveryMethod: 'CUSTOMER_DELIVERY',
|
||
|
|
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||
|
|
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19),
|
||
|
|
fbaShipmentNo: `SHIP-${marker}`.slice(0, 128),
|
||
|
|
trackingNo: `TRACK-${marker}`.slice(0, 128),
|
||
|
|
fbaBoxNos: cartonMarks.join(','),
|
||
|
|
remark: marker,
|
||
|
|
needRelabel: true,
|
||
|
|
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||
|
|
relabelContactName: 'Acceptance operator',
|
||
|
|
relabelContactPhone: '13800000006',
|
||
|
|
relabelDeliveryMethod: 'TRUCK',
|
||
|
|
idempotencyKey: key('CREATE'),
|
||
|
|
items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo',
|
||
|
|
packageCount: 2, shippingMark: cartonMarks.join(',') }],
|
||
|
|
relabelItems: cartonMarks.map((cartonMark, index) => ({
|
||
|
|
operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark],
|
||
|
|
originalSku: `SKU-${marker}`.slice(0, 128),
|
||
|
|
newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1,
|
||
|
|
fbaCartonMark: `FBA-${marker}-${index + 1}`.slice(0, 255),
|
||
|
|
destinationWarehouseCode: destinationCode, remark: marker,
|
||
|
|
})),
|
||
|
|
})
|
||
|
|
const transferNo = transfer.transferNo
|
||
|
|
assert(transferNo && transfer.status === 'DRAFT', 'Warehouse transfer draft was not created')
|
||
|
|
let transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||
|
|
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/submit`)
|
||
|
|
assert(transferDetail.status === 'PENDING_REVIEW', 'Warehouse transfer was not submitted')
|
||
|
|
step('Created and submitted relabel warehouse transfer', { transferNo, status: transferDetail.status })
|
||
|
|
|
||
|
|
transferDetail = await api(overseas, admin.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, {
|
||
|
|
decision: 'APPROVED', remark: marker,
|
||
|
|
})
|
||
|
|
assert(transferDetail.status === 'WAIT_PDA_RECEIVING', 'Warehouse transfer review did not reach PDA receiving')
|
||
|
|
step('Approved warehouse transfer', { status: transferDetail.status })
|
||
|
|
|
||
|
|
const draftDetail = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/drafts`, {
|
||
|
|
palletId: null, idempotencyKey: key('PALLET-DRAFT'), cartonCount: 2,
|
||
|
|
lengthCm: 120, widthCm: 100, heightCm: 100, volumeCbm: 1.2, grossWeightKg: 20,
|
||
|
|
})
|
||
|
|
const palletDraft = [...draftDetail.pallets].sort((left, right) => Number(right.sequenceNo) - Number(left.sequenceNo))[0]
|
||
|
|
assert(palletDraft?.id, 'Pallet draft was not returned')
|
||
|
|
const generatedDetail = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/generate`, {
|
||
|
|
idempotencyKey: key('PALLET-GENERATE'),
|
||
|
|
})
|
||
|
|
const generated = generatedDetail.pallets.find((value) => id(value.id) === id(palletDraft.id))
|
||
|
|
assert(generated?.palletNo, 'Physical pallet was not generated')
|
||
|
|
const boundDetail = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/bind`, {
|
||
|
|
palletCode: generated.palletNo, idempotencyKey: key('PALLET-BIND'),
|
||
|
|
})
|
||
|
|
assert(boundDetail.pallets.some((value) => id(value.id) === id(palletDraft.id) && value.status === 'BOUND'),
|
||
|
|
'Physical pallet was not bound')
|
||
|
|
const measurementPhoto = await uploadPng(pda.token,
|
||
|
|
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/measurement-photos/files`, `${marker}-measurement.png`)
|
||
|
|
transferDetail = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, {
|
||
|
|
idempotencyKey: key('PALLETIZE'), photoFileIds: [id(measurementPhoto.fileId)],
|
||
|
|
})
|
||
|
|
assert(transferDetail.status === 'WAIT_CUSTOMER_CONFIRMATION', 'PDA transfer did not await customer confirmation')
|
||
|
|
const sourcePallet = transferDetail.pallets.find((value) => value.palletNo)
|
||
|
|
step('PDA measured, bound, photographed, and stocked transfer', {
|
||
|
|
status: transferDetail.status, sourcePalletNo: sourcePallet?.palletNo,
|
||
|
|
})
|
||
|
|
|
||
|
|
transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||
|
|
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/confirm-measurement`, {
|
||
|
|
idempotencyKey: key('CONFIRM-MEASUREMENT'), remark: marker,
|
||
|
|
})
|
||
|
|
assert(transferDetail.status === 'WAIT_RELABEL' && transferDetail.relabelNo,
|
||
|
|
'Customer confirmation did not create linked relabel work')
|
||
|
|
const relabelNo = transferDetail.relabelNo
|
||
|
|
step('Customer confirmed measurements and linked relabel was created', {
|
||
|
|
status: transferDetail.status, relabelNo,
|
||
|
|
})
|
||
|
|
|
||
|
|
let relabel = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') })
|
||
|
|
assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`)
|
||
|
|
for (const [index, item] of relabel.items.entries()) {
|
||
|
|
relabel = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
|
||
|
|
cartonMark: item.cartonMark, expectedVersion: Number(relabel.version),
|
||
|
|
idempotencyKey: key(`RELABEL-CARTON-${index}`),
|
||
|
|
})
|
||
|
|
relabel = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
||
|
|
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
|
||
|
|
skuVerifications: (item.skuItems || []).map((sku) => ({
|
||
|
|
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
|
||
|
|
})),
|
||
|
|
expectedVersion: Number(relabel.version), idempotencyKey: key(`RELABEL-SKU-${index}`),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const targetPallets = []
|
||
|
|
for (const [index, item] of relabel.items.entries()) {
|
||
|
|
const target = await api(overseas, pda.token, 'POST',
|
||
|
|
'/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
||
|
|
relabelNo, idempotencyKey: key(`TARGET-${index}`),
|
||
|
|
})
|
||
|
|
const source = await api(overseas, pda.token, 'POST',
|
||
|
|
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: sourcePallet.palletNo })
|
||
|
|
const resolvedTarget = await api(overseas, pda.token, 'POST',
|
||
|
|
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: target.palletCode })
|
||
|
|
await api(overseas, pda.token, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
||
|
|
idempotencyKey: key(`MOVE-${index}`), relabelNo,
|
||
|
|
source: { palletCode: source.palletCode, expectedVersion: Number(source.palletVersion) },
|
||
|
|
target: { palletCode: resolvedTarget.palletCode, expectedVersion: Number(resolvedTarget.palletVersion) },
|
||
|
|
cartonMarks: [item.cartonMark], reason: marker,
|
||
|
|
})
|
||
|
|
targetPallets.push(target)
|
||
|
|
}
|
||
|
|
const relabelPhoto = await uploadPng(pda.token,
|
||
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, `${marker}-relabel.png`)
|
||
|
|
relabel = await api(overseas, pda.token, 'POST',
|
||
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
||
|
|
photoFileIds: [id(relabelPhoto.fileId)], expectedVersion: Number(relabel.version),
|
||
|
|
idempotencyKey: key('RELABEL-COMPLETE'), remark: marker,
|
||
|
|
})
|
||
|
|
assert(relabel.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', 'Linked relabel did not await customer confirmation')
|
||
|
|
const confirmedRelabel = await api(overseas, merchant.overseasAccessToken, 'POST',
|
||
|
|
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
||
|
|
idempotencyKey: key('RELABEL-CONFIRM'), remark: marker,
|
||
|
|
})
|
||
|
|
transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET',
|
||
|
|
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
|
||
|
|
assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready')
|
||
|
|
assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready')
|
||
|
|
step('Completed linked relabel and released transfer for dispatch', {
|
||
|
|
relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status,
|
||
|
|
})
|
||
|
|
|
||
|
|
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
|
||
|
|
const targetIds = new Set(targetPallets.map((value) => id(value.palletId)))
|
||
|
|
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId)))
|
||
|
|
assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`)
|
||
|
|
const 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,
|
||
|
|
})
|
||
|
|
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
|
||
|
|
|
||
|
|
const sourceReport = resolve('reports', `${marker}-relabel-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, transferNo, relabelNo, 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))
|