Files
baiyue-firstleg/scripts/relabel-closure-local-acceptance.mjs
T
2026-09-17 17:37:29 +08:00

117 lines
10 KiB
JavaScript

import { mkdir, writeFile, readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
const base = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) throw new Error('Local acceptance only')
const merchant = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN, pda = process.env.PDA_OVERSEAS_ACCESS_TOKEN, admin = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
const warehouse = process.env.OVERSEAS_TEST_WAREHOUSE_ID
if (!merchant || !pda || !admin || !warehouse) throw new Error('Explicit session and warehouse required')
const marker = `CLOSE-${Date.now()}`
const report = { marker, passed: false, scope: 'REAL_HTTP_SYNTHETIC_WAREHOUSE_EVIDENCE_NO_FINANCE', scenarios: [] }
const key = () => `${marker}-${crypto.randomUUID()}`
const root = '/api/merchant/v1/last-leg/relabel-applications'
const work = '/api/pda/v1/last-leg/relabel-tasks'
const pallets = '/api/pda/v1/last-leg/pallet-operations'
const assert = (condition, message) => { if (!condition) throw new Error(message) }
async function raw(token, method, path, body) {
const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined })
const result = await response.json()
return { ...result, http: response.status }
}
async function api(token, method, path, body) {
const result = await raw(token, method, path, body)
assert(result.http === 200 && String(result.code) === '200', `${path}: ${result.code} ${result.message}`)
return result.data
}
async function reject(token, path, body, message) {
const result = await raw(token, 'POST', path, body)
assert(String(result.code) !== '200' && message.test(result.message || ''), `Expected rejection at ${path}, got ${result.code} ${result.message}`)
}
const resolve = code => api(pda, 'POST', `${pallets}/pallets/resolve`, { code })
async function move(no, mark, from, to) {
const source = await resolve(from), target = await resolve(to)
const request = { relabelNo: no, source: { palletCode: from, expectedVersion: source.palletVersion }, target: { palletCode: to, expectedVersion: target.palletVersion }, cartonMarks: [mark], reason: marker, idempotencyKey: key() }
const result = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request)
const replay = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request)
assert(replay.replayed === true, 'Move replay was not idempotent')
const after = await resolve(to)
assert(Number(after.totalPackages) === Number(target.totalPackages) + 1 && after.status === 'OCCUPIED', 'Target stock/status did not update exactly once')
assert(Number((await resolve(from)).totalPackages) === Number(source.totalPackages) - 1, 'Source stock was not reduced once')
return result
}
try {
for (const deliveryMethod of ['TRUCK_PICKUP', 'EXPRESS', 'EXPRESS_PICKUP']) {
const candidates = await api(merchant, 'GET', `${root}/inventory-candidates?operationWarehouseId=${warehouse}`)
const candidate = candidates.find(value => value.destinationFbaWarehouseCode && value.positions?.some(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND'))
assert(candidate, 'No available inventory for another scenario')
const position = candidate.positions.find(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND')
const mark = `${marker}-${deliveryMethod}`
const request = { customerOrderNo: mark, operationWarehouseId: warehouse, labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), contactName: '流程验收', contactPhone: '13800000000', deliveryMethod, printShippingLabel: false, idempotencyKey: key(), items: [{ sourceInventoryBalanceId: candidate.inventoryBalanceId, sourceInventoryPositionId: position.positionId, sourceInboundOrderId: candidate.inboundOrderId, skuCode: candidate.skuCode || 'TEST', productNameZh: '验收测试货物', totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: mark, fbaCartonMark: `FBA-${mark}`, destinationFbaWarehouseCode: candidate.destinationFbaWarehouseCode, operationType: 'CARTON_LABEL', skuItems: [] }] }
request.items[0].shipmentNo = `SHIP-${mark}`
const created = await api(merchant, 'POST', root, request)
const no = created.application.relabelNo
const scenario = { deliveryMethod, relabelNo: no, originalPallet: position.palletNo, checks: [] }
report.scenarios.push(scenario)
await reject(merchant, root, { ...request, idempotencyKey: key() }, /已用于换标单/)
scenario.checks.push('DUPLICATE_APPLICATION_REJECTED')
// Read an actual Print-generated test document from File, then upload it through the merchant API.
const pdfReport = JSON.parse(await readFile('reports/relabel-closure-20260916/pdf.json', 'utf8'))
const doc = pdfReport.files[0]
const pdfResponse = await fetch(`http://127.0.0.1:9206/internal/file/v1/business-files/${process.env.OVERSEAS_TEST_CUSTOMER_ID}/${doc.category}/${pdfReport.businessId}/${doc.fileId}/content`, { headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN } })
const pdf = Buffer.from(await pdfResponse.arrayBuffer())
assert(pdfResponse.ok && pdf.subarray(0, 5).toString() === '%PDF-', 'Test PDF not available')
for (const scope of ['CARTON_TOTAL', 'SKU_TOTAL', 'ITEM']) {
const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), `${marker}.pdf`)
await api(merchant, 'POST', `${root}/${no}/files?scope=${scope}${scope === 'ITEM' ? `&itemId=${created.items[0].id}` : ''}`, form)
}
let task = await api(pda, 'POST', `${work}/${no}/claim`, { idempotencyKey: key() })
const documents = await api(pda, 'GET', `${work}/${no}/documents`)
assert(documents.length >= 3, 'PDA is missing uploaded label documents')
for (const file of documents) {
const response = await fetch(`${base}${work}/${no}/documents/${file.fileId}/download?scope=${file.scope}${file.itemId ? `&itemId=${file.itemId}` : ''}`, { headers: { Authorization: `Bearer ${pda}` } })
const downloaded = Buffer.from(await response.arrayBuffer())
assert(response.ok && createHash('sha256').update(downloaded).digest('hex') === createHash('sha256').update(pdf).digest('hex'), 'PDA document mismatch')
}
scenario.checks.push('MERCHANT_UPLOAD_PDA_DOWNLOAD_THREE_SCOPES')
task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() })
const photoForm = new FormData(); photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic.png')
const photo = await api(pda, 'POST', `${work}/${no}/photos/files`, photoForm)
const complete = () => ({ photoFileIds: [photo.fileId], expectedVersion: task.version, remark: marker, idempotencyKey: key() })
await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/)
scenario.checks.push('UNBOUND_COMPLETION_REJECTED')
const first = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() })
await move(no, mark, position.palletNo, first.palletCode)
const second = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() })
await move(no, mark, first.palletCode, second.palletCode)
scenario.targetPallet = second.palletCode
scenario.checks.push('REBIND_STOCK_CONSERVED_AND_REPLAY_SAFE')
task = await api(pda, 'POST', `${work}/${no}/complete`, complete())
if (deliveryMethod === 'TRUCK_PICKUP') {
task = await api(admin, 'POST', `/api/platform/v1/last-leg/relabel-applications/${no}/rework`, { decision: 'REWORK', expectedVersion: task.version, idempotencyKey: key(), remark: '验收退回:请核对并重新贴标' })
assert(task.reviewRemark?.includes('验收退回'), 'Rework remark missing on PDA')
task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() })
await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/)
await move(no, mark, second.palletCode, first.palletCode)
scenario.targetPallet = first.palletCode
task = await api(pda, 'POST', `${work}/${no}/complete`, complete())
scenario.checks.push('REWORK_RESCAN_REBIND_AND_RESUBMIT')
}
const confirmed = await api(merchant, 'POST', `${root}/${no}/confirm-completion`, { idempotencyKey: key(), remark: marker })
scenario.outboundNo = confirmed.application.outboundOrderNo
assert(scenario.outboundNo, 'Customer confirmation did not create direct outbound')
await reject(pda, `${pallets}/relabel-disassemble`, { relabelNo: no, source: { palletCode: scenario.targetPallet, expectedVersion: 0 }, target: { palletCode: position.palletNo, expectedVersion: 0 }, cartonMarks: [mark], idempotencyKey: key() }, /排车或出库/)
const resolved = await api(pda, 'GET', `/api/pda/v1/last-leg/outbound-tasks/resolve/${scenario.outboundNo}`)
const picked = await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/pallets/${scenario.targetPallet}/pick`, { idempotencyKey: key() })
await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/complete`, { idempotencyKey: key() })
const outbound = await api(admin, 'GET', `/api/platform/v1/last-leg/outbound-orders/${scenario.outboundNo}?page=1&size=20`)
assert(outbound.outbound.status === 'OUTBOUNDED', 'Direct outbound not completed')
scenario.outboundStatus = outbound.outbound.status
scenario.checks.push('DIRECT_OUTBOUND_COMPLETED', 'OUTBOUND_REBIND_REJECTED')
}
report.passed = true
} catch (error) { report.error = error.message; process.exitCode = 1 }
finally {
await mkdir('reports/relabel-closure-20260916', { recursive: true })
await writeFile('reports/relabel-closure-20260916/extra.json', JSON.stringify(report, null, 2))
console.log(JSON.stringify(report, null, 2))
}