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))