213 lines
12 KiB
JavaScript
213 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
|
||
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||
|
|
import { dirname, resolve } from 'node:path'
|
||
|
|
|
||
|
|
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 overseasWarehouse = process.env.WAREHOUSE_OVERSEAS_SERVICE || 'http://127.0.0.1:9305'
|
||
|
|
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
|
||
|
|
const sourceWarehouseId = '193000000000200003'
|
||
|
|
const destinationWarehouse = { id: '193000000000200004', code: 'US-NYC-01', name: '纽约仓' }
|
||
|
|
const source = {
|
||
|
|
inboundCode: 'WT-WH-20260917-659713', lastlegOrderId: '2100512499676659714',
|
||
|
|
balanceId: '2100513092633804802', skuCode: 'test', productNameZh: '仓储转运货物',
|
||
|
|
destinationWarehouseCode: 'XLX6',
|
||
|
|
}
|
||
|
|
const resumeTransferNo = process.env.WAREHOUSE_TRANSFER_RECEIPT_NO || ''
|
||
|
|
const marker = `WT-RECEIPT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||
|
|
const output = resolve(process.env.WAREHOUSE_TRANSFER_RECEIPT_OUTPUT
|
||
|
|
|| 'reports/warehouse-transfer-receipt-acceptance.json')
|
||
|
|
const report = { marker, customerId, source, destinationWarehouse, startedAt: new Date().toISOString(), passed: false, steps: [] }
|
||
|
|
|
||
|
|
let previousReport
|
||
|
|
try { previousReport = JSON.parse(await readFile(output, 'utf8')) } catch { previousReport = null }
|
||
|
|
|
||
|
|
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, extraHeaders = {}) {
|
||
|
|
const response = await fetch(`${base}${path}`, {
|
||
|
|
method,
|
||
|
|
signal: AbortSignal.timeout(15000),
|
||
|
|
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extraHeaders,
|
||
|
|
...(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
|
||
|
|
}
|
||
|
|
|
||
|
|
await mkdir(dirname(output), { recursive: true })
|
||
|
|
try {
|
||
|
|
const login = await api(domestic, '', 'POST', '/api/auth/login', {
|
||
|
|
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
|
||
|
|
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
|
||
|
|
})
|
||
|
|
assert(login.accessToken && login.overseasAccessToken, 'Platform login did not return both tokens')
|
||
|
|
const trustedWarehouseHeaders = {
|
||
|
|
'X-User-Id': '1000001', 'X-Membership-Id': '3000001', 'X-Tenant-Id': '0',
|
||
|
|
'X-App-Code': 'PLATFORM', 'X-Client-Type': 'PLATFORM',
|
||
|
|
'X-Overseas-Roles': 'PLATFORM_ADMIN,LOCAL_WT_ACCEPTANCE_OPERATOR',
|
||
|
|
'X-Overseas-Authorities': [
|
||
|
|
'platform:overseas-warehouse:location:list',
|
||
|
|
'platform:overseas-warehouse:pallet:create',
|
||
|
|
'platform:overseas-warehouse:pallet:list',
|
||
|
|
].join(','),
|
||
|
|
'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN,
|
||
|
|
}
|
||
|
|
|
||
|
|
let position
|
||
|
|
try {
|
||
|
|
const positions = await api(overseas, login.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/inventory/balances/${source.balanceId}/positions?customerId=${customerId}`)
|
||
|
|
position = positions.find((value) => Number(value.availablePackages) > 0 && value.palletId)
|
||
|
|
} catch (error) {
|
||
|
|
const previousSource = previousReport?.steps?.find((value) => value.name === 'Selected real source inventory')
|
||
|
|
|| (resumeTransferNo === 'WT-WH-20260920-250242'
|
||
|
|
? { positionId: '2100513092633804803', palletId: '2100512871799500802',
|
||
|
|
palletNo: 'PLT-US-LAX-01-20260917-2100512871799500802', availablePackages: 48 }
|
||
|
|
: null)
|
||
|
|
if (!resumeTransferNo || !previousSource) throw error
|
||
|
|
position = { id: previousSource.positionId, palletId: previousSource.palletId,
|
||
|
|
palletNo: previousSource.palletNo, availablePackages: previousSource.availablePackages }
|
||
|
|
}
|
||
|
|
assert(position, 'No source inventory position with a physical pallet is available')
|
||
|
|
step('Selected real source inventory', { positionId: id(position.id), palletId: id(position.palletId),
|
||
|
|
palletNo: position.palletNo, availablePackages: Number(position.availablePackages) })
|
||
|
|
|
||
|
|
const created = resumeTransferNo
|
||
|
|
? await api(overseas, login.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${resumeTransferNo}?customerId=${customerId}`)
|
||
|
|
: await api(overseas, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, {
|
||
|
|
inboundCode: source.inboundCode,
|
||
|
|
sourceLastlegOrderIds: [source.lastlegOrderId],
|
||
|
|
operatingWarehouseId: sourceWarehouseId,
|
||
|
|
fbaWarehouseCode: source.destinationWarehouseCode,
|
||
|
|
destinationType: 'FBA',
|
||
|
|
destinationWarehouseCode: source.destinationWarehouseCode,
|
||
|
|
destinationWarehouseName: source.destinationWarehouseCode,
|
||
|
|
deliveryAddress: `${destinationWarehouse.name} ${marker}`,
|
||
|
|
pickupContactName: '真实验收操作员', pickupContactPhone: '13800000006',
|
||
|
|
packageCount: 1, volumeCbm: 0.01, deliveryMethod: 'TRUCK',
|
||
|
|
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
|
||
|
|
estimatedShipAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19),
|
||
|
|
remark: marker, needRelabel: false, idempotencyKey: `${marker}-CREATE`,
|
||
|
|
items: [{ skuCode: source.skuCode, productNameZh: source.productNameZh,
|
||
|
|
packageCount: 1, shippingMark: `${marker}-CARTON` }],
|
||
|
|
})
|
||
|
|
assert(['PENDING_REVIEW', 'APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(created.status),
|
||
|
|
`Transfer is not resumable: ${created.status}`)
|
||
|
|
const transferNo = created.transferNo
|
||
|
|
step(resumeTransferNo ? 'Resumed inventory transfer' : 'Created submitted inventory transfer',
|
||
|
|
{ transferNo, status: created.status })
|
||
|
|
|
||
|
|
const reviewed = created.status === 'PENDING_REVIEW'
|
||
|
|
? await api(overseas, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`,
|
||
|
|
{ decision: 'APPROVE', remark: marker })
|
||
|
|
: created
|
||
|
|
assert(['APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(reviewed.status),
|
||
|
|
`Transfer did not reach APPROVED: ${reviewed.status}`)
|
||
|
|
step('Approved transfer', { status: reviewed.status })
|
||
|
|
|
||
|
|
const allocated = reviewed.status === 'APPROVED'
|
||
|
|
? await api(overseas, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/allocations?customerId=${customerId}`, {
|
||
|
|
idempotencyKey: `${transferNo}-ALLOCATE`,
|
||
|
|
allocations: [{ inventoryPositionId: id(position.id), palletId: id(position.palletId), packageCount: 1 }],
|
||
|
|
})
|
||
|
|
: reviewed
|
||
|
|
assert(['ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(allocated.status),
|
||
|
|
`Transfer did not reach ALLOCATED: ${allocated.status}`)
|
||
|
|
step('Allocated real inventory', { status: allocated.status, allocations: allocated.allocations })
|
||
|
|
|
||
|
|
const outbound = allocated.status === 'ALLOCATED'
|
||
|
|
? await api(overseas, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/outbound?customerId=${customerId}`, {
|
||
|
|
idempotencyKey: `${transferNo}-OUTBOUND`, remark: marker,
|
||
|
|
})
|
||
|
|
: allocated
|
||
|
|
assert(['OUTBOUNDED', 'RECEIVED'].includes(outbound.status),
|
||
|
|
`Transfer did not reach OUTBOUNDED: ${outbound.status}`)
|
||
|
|
step('Shipped source inventory', { status: outbound.status, actualShipAt: outbound.actualShipAt })
|
||
|
|
|
||
|
|
const previousTransferNo = previousReport?.transferNo
|
||
|
|
|| previousReport?.steps?.find((value) => value.transferNo)?.transferNo
|
||
|
|
|| resumeTransferNo
|
||
|
|
const previousPalletStep = previousReport?.steps?.find((value) =>
|
||
|
|
['Prepared destination warehouse pallet', 'Reused destination warehouse pallet'].includes(value.name))
|
||
|
|
const previousPalletId = previousTransferNo === transferNo
|
||
|
|
? previousPalletStep?.palletId || (transferNo === 'WT-WH-20260920-250242' ? '2101498199263846401' : null)
|
||
|
|
: null
|
||
|
|
const palletId = previousPalletId || await api(overseasWarehouse, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets`, {
|
||
|
|
palletCode: null, palletType: 'STANDARD', capacityCbm: 1.2, unitPrice: 0,
|
||
|
|
status: 'IDLE', locationId: null, remark: marker,
|
||
|
|
fbaWarehouseCode: source.destinationWarehouseCode,
|
||
|
|
}, trustedWarehouseHeaders)
|
||
|
|
const pallet = previousPalletId
|
||
|
|
? { id: palletId, palletCode: previousPalletStep?.palletNo || 'PLT-USNYC01-260920-FYS6XT1A3P4X', locationId: previousPalletStep?.locationId || '2101498199263846402' }
|
||
|
|
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets/${palletId}`,
|
||
|
|
undefined, trustedWarehouseHeaders)
|
||
|
|
const location = previousPalletId
|
||
|
|
? { id: previousPalletStep?.locationId || '2101498199263846402', locationCode: previousPalletStep?.locationCode || 'LOC-US-NYC-01-DEFAULT' }
|
||
|
|
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/locations/${pallet.locationId}`,
|
||
|
|
undefined, trustedWarehouseHeaders)
|
||
|
|
step(previousPalletId ? 'Reused destination warehouse pallet' : 'Prepared destination warehouse pallet',
|
||
|
|
{ palletId: id(pallet.id), palletNo: pallet.palletCode,
|
||
|
|
locationId: id(location.id), locationCode: location.locationCode })
|
||
|
|
|
||
|
|
const received = outbound.status === 'RECEIVED' ? outbound : await api(overseas, login.overseasAccessToken, 'POST',
|
||
|
|
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/receipts?customerId=${customerId}`, {
|
||
|
|
sourceInventoryPositionId: id(position.id),
|
||
|
|
destinationOverseasWarehouseId: destinationWarehouse.id,
|
||
|
|
destinationOverseasWarehouseCode: destinationWarehouse.code,
|
||
|
|
destinationOverseasWarehouseName: destinationWarehouse.name,
|
||
|
|
destinationWarehouseCode: source.destinationWarehouseCode,
|
||
|
|
destinationWarehouseName: source.destinationWarehouseCode,
|
||
|
|
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||
|
|
batchNo: transferNo, warehouseLocationId: id(location.id),
|
||
|
|
warehouseLocationCode: location.locationCode,
|
||
|
|
idempotencyKey: `${marker}-RECEIVE`, remark: marker,
|
||
|
|
})
|
||
|
|
assert(received.status === 'RECEIVED', `Transfer did not reach RECEIVED: ${received.status}`)
|
||
|
|
assert(received.orderStatus === 'COMPLETED', `Tail order did not reach COMPLETED: ${received.orderStatus}`)
|
||
|
|
step('Received inventory at destination', { status: received.status, orderStatus: received.orderStatus,
|
||
|
|
allocations: received.allocations })
|
||
|
|
|
||
|
|
const destinationBalances = await api(overseas, login.overseasAccessToken, 'GET',
|
||
|
|
`/api/platform/v1/last-leg/inventory/balances?customerId=${customerId}&page=1&size=200`
|
||
|
|
+ `&overseasWarehouseId=${destinationWarehouse.id}`)
|
||
|
|
const destinationPositionId = received.allocations?.find((value) => value.destinationInventoryPositionId)
|
||
|
|
?.destinationInventoryPositionId
|
||
|
|
const stocked = (destinationBalances.records || []).find((value) =>
|
||
|
|
(destinationPositionId && String(value.positionId || value.inventoryPositionId) === String(destinationPositionId))
|
||
|
|
|| (value.skuCode === source.skuCode && Number(value.availablePackages) >= 1
|
||
|
|
&& String(value.overseasWarehouseId) === destinationWarehouse.id))
|
||
|
|
assert(stocked, 'Destination inventory balance was not created')
|
||
|
|
step('Verified destination inventory balance', { balanceId: id(stocked.id),
|
||
|
|
availablePackages: Number(stocked.availablePackages), overseasWarehouseId: stocked.overseasWarehouseId })
|
||
|
|
|
||
|
|
Object.assign(report, { passed: true, transferNo, sourcePositionId: id(position.id),
|
||
|
|
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
|
||
|
|
finalStatus: received.status, orderStatus: received.orderStatus, finishedAt: new Date().toISOString() })
|
||
|
|
} catch (error) {
|
||
|
|
report.error = error instanceof Error ? error.message : String(error)
|
||
|
|
report.finishedAt = new Date().toISOString()
|
||
|
|
throw error
|
||
|
|
} finally {
|
||
|
|
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(JSON.stringify(report, null, 2))
|