233 lines
15 KiB
JavaScript
233 lines
15 KiB
JavaScript
import { writeFile } from 'node:fs/promises'
|
|
|
|
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 platformUser = process.env.ADMIN_TEST_USERNAME
|
|
const platformPassword = process.env.ADMIN_TEST_PASSWORD
|
|
const pdaUser = process.env.PDA_TEST_USERNAME
|
|
const pdaPassword = process.env.PDA_TEST_PASSWORD
|
|
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
|
const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID
|
|
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
|
|
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
|
|
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
|
|
const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json'
|
|
const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
|
const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
|
|
if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount')
|
|
|
|
for (const [name, value] of Object.entries({ customerId, warehouseId })) {
|
|
if (!value) throw new Error(`Missing environment variable: ${name}`)
|
|
}
|
|
if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token')
|
|
if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials')
|
|
if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token')
|
|
|
|
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false }
|
|
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
async function api(base, token, method, path, body, headers = {}) {
|
|
const response = await fetch(`${base}${path}`, {
|
|
method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
|
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 poll(action, predicate, attempts = 40) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
const value = await action()
|
|
if (predicate(value)) return value
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
throw new Error('Polling timed out')
|
|
}
|
|
function assert(condition, message) { if (!condition) throw new Error(message) }
|
|
|
|
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
|
|
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
|
|
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
|
|
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
|
|
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
|
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
|
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
|
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
|
assert(platformToken && merchantToken && pdaToken, 'Missing overseas token')
|
|
|
|
const candidates = await api(overseas, merchantToken, 'GET',
|
|
`/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
|
|
const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-')
|
|
&& Number(item.availablePackages) >= 5
|
|
&& item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))
|
|
assert(source, 'No overseas inventory source with at least five available cartons')
|
|
const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5)
|
|
|
|
async function resolvePallet(palletCode) {
|
|
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
|
}
|
|
|
|
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
|
|
const suffix = `${name}-${Date.now()}`
|
|
const items = Array.from({ length: cartonCount }, (_, index) => ({
|
|
sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId),
|
|
sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode,
|
|
productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1,
|
|
shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128),
|
|
labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`,
|
|
fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null,
|
|
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
|
operationType, skuItems: [{ skuCode: source.skuCode,
|
|
newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }],
|
|
}))
|
|
const created = await api(overseas, platformToken, 'POST',
|
|
`/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, {
|
|
customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId,
|
|
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
|
contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}),
|
|
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`),
|
|
deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK',
|
|
printShippingLabel: false, items,
|
|
})
|
|
const relabelNo = created.application.relabelNo
|
|
if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET',
|
|
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
|
|
(detail) => detail.application.walletStatus === 'FROZEN')
|
|
let detail = await api(overseas, pdaToken, 'POST',
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) })
|
|
for (const [index, item] of detail.items.entries()) {
|
|
if (operationType === 'CARTON_AND_SKU') {
|
|
detail = await api(overseas, pdaToken, 'POST',
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
|
|
cartonMark: item.cartonMark, expectedVersion: Number(detail.version),
|
|
idempotencyKey: key(`${name}-CARTON-${index}`),
|
|
})
|
|
const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId))
|
|
assert(Number(stageItem?.cartonProcessedPackages) === 1
|
|
&& Number(stageItem?.skuProcessedPackages) === 0,
|
|
`${relabelNo} carton stage incorrectly locked or completed SKU stage`)
|
|
}
|
|
detail = await api(overseas, pdaToken, '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(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
|
|
})
|
|
}
|
|
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
|
const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore }
|
|
if (prepare) await prepare(scenario)
|
|
const form = new FormData()
|
|
// Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work.
|
|
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`)
|
|
const photo = await api(overseas, pdaToken, 'POST',
|
|
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form)
|
|
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
|
|
photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version),
|
|
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
|
|
})
|
|
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
|
|
const confirmed = await api(overseas, merchantToken, 'POST',
|
|
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
|
|
idempotencyKey: key(`${name}-CONFIRM`), remark: marker,
|
|
})
|
|
const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND'
|
|
assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`)
|
|
assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode),
|
|
`${relabelNo} did not preserve the destination warehouse`)
|
|
return { ...scenario, items: confirmed.items }
|
|
}
|
|
|
|
async function printPallet(relabelNo, suffix) {
|
|
return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
|
|
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
|
|
})
|
|
}
|
|
async function moveCarton(scenario, item, target, index) {
|
|
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
|
|
const targetPallet = await resolvePallet(target.palletCode)
|
|
const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
|
|
idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo,
|
|
source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) },
|
|
target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) },
|
|
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
|
|
})
|
|
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
|
|
return result
|
|
}
|
|
|
|
const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL')
|
|
skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
|
assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets')
|
|
skuOnly.targetPallets = []
|
|
skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
|
|
delete skuOnly.items
|
|
|
|
let sharedTarget
|
|
const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => {
|
|
sharedTarget = await printPallet(scenario.relabelNo, 'SHARED')
|
|
const printReplay = await printPallet(scenario.relabelNo, 'SHARED')
|
|
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
|
|
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
|
scenario.targets = [sharedTarget]
|
|
})
|
|
|
|
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
|
const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => {
|
|
await moveCarton(scenario, scenario.items[0], sharedTarget, 0)
|
|
scenario.targets = [sharedTarget]
|
|
})
|
|
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
|
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
|
|
merged.targets = [sharedTarget]
|
|
|
|
const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => {
|
|
const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A')
|
|
const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B')
|
|
await moveCarton(scenario, scenario.items[0], splitA, 0)
|
|
await moveCarton(scenario, scenario.items[1], splitB, 1)
|
|
scenario.targets = [splitA, splitB]
|
|
})
|
|
|
|
for (const scenario of [fresh, merged, split]) {
|
|
const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
|
|
scenario.sourceAfter = sourceAfter
|
|
scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
|
|
const candidateRows = await api(overseas, platformToken, 'GET',
|
|
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`)
|
|
const targetIds = new Set(scenario.targets.map((target) => id(target.palletId)))
|
|
const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId)))
|
|
const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean))
|
|
assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`)
|
|
assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`)
|
|
assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`)
|
|
const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', {
|
|
planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`,
|
|
relabelNo: scenario.relabelNo,
|
|
})
|
|
scenario.dispatchNo = dispatch.dispatchNo
|
|
scenario.dispatchStatus = dispatch.status
|
|
scenario.relabelCartonMarks = marks
|
|
delete scenario.items
|
|
delete scenario.targets
|
|
}
|
|
|
|
const ledger = await api(overseas, platformToken, 'GET',
|
|
`/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`)
|
|
const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker))
|
|
assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger')
|
|
assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger')
|
|
assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason')
|
|
report.scenarios = [skuOnly, fresh, merged, split]
|
|
report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) }
|
|
report.passed = true
|
|
report.finishedAt = new Date().toISOString()
|
|
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
|
console.log(JSON.stringify(report, null, 2))
|