194 lines
12 KiB
JavaScript
194 lines
12 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 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}`
|
|
|
|
for (const [name, value] of Object.entries({ platformUser, platformPassword, pdaUser, pdaPassword, customerId, warehouseId })) {
|
|
if (!value) throw new Error(`Missing environment variable: ${name}`)
|
|
}
|
|
|
|
const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', 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 = await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword })
|
|
const pdaLogin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword })
|
|
const ticket = await api(domestic, platformLogin.accessToken, 'POST',
|
|
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` })
|
|
const merchantLogin = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
|
const platformToken = platformLogin.overseasAccessToken
|
|
const merchantToken = merchantLogin.overseasAccessToken
|
|
const pdaToken = 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 createAndApprove(name, cartonCount) {
|
|
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: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
|
operationType: 'CARTON_AND_SKU', 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.01,
|
|
remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK',
|
|
printShippingLabel: false, items,
|
|
})
|
|
const relabelNo = created.application.relabelNo
|
|
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()) {
|
|
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
|
|
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark,
|
|
expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
|
|
})
|
|
}
|
|
const form = new FormData()
|
|
form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { 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,
|
|
})
|
|
detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, {
|
|
decision: 'APPROVE', expectedVersion: Number(detail.version),
|
|
idempotencyKey: key(`${name}-APPROVE`), 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,
|
|
})
|
|
assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`)
|
|
return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) }
|
|
}
|
|
|
|
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 fresh = await createAndApprove('NEW-PALLET', 1)
|
|
const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED')
|
|
const printReplay = await printPallet(fresh.relabelNo, 'SHARED')
|
|
assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent')
|
|
await moveCarton(fresh, fresh.items[0], sharedTarget, 0)
|
|
fresh.targets = [sharedTarget]
|
|
|
|
const merged = await createAndApprove('MERGE-EXISTING', 1)
|
|
const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
|
|
await moveCarton(merged, merged.items[0], sharedTarget, 0)
|
|
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 createAndApprove('SPLIT-TWO-TARGETS', 2)
|
|
const splitA = await printPallet(split.relabelNo, 'SPLIT-A')
|
|
const splitB = await printPallet(split.relabelNo, 'SPLIT-B')
|
|
await moveCarton(split, split.items[0], splitA, 0)
|
|
await moveCarton(split, split.items[1], splitB, 1)
|
|
split.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 = [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))
|