Files
baiyue-firstleg/scripts/overseas-relabel-real-acceptance.mjs
T

507 lines
38 KiB
JavaScript
Raw Normal View History

2026-10-20 10:35:52 +08:00
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { installRetainedJournal, retainedApi, saveRetainedReport } from './mixed-container-outbound-test-env.mjs'
2026-09-14 09:58:30 +08:00
2026-10-20 10:35:52 +08:00
const MODULE = 'overseas-relabel-producer'
const DISPATCH = '/api/platform/v1/last-leg/dispatches'
const id = value => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision')
return value == null ? '' : String(value)
2026-09-14 09:58:30 +08:00
}
2026-10-20 10:35:52 +08:00
const exactVersion = value => {
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw Error('Authoritative relabel version is missing or imprecise')
return value
}
const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null
const sorted = values => [...new Set(values)].sort()
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const canonical = value => JSON.stringify(normalize(value))
function normalize(value) {
if (Array.isArray(value)) return value.map(normalize)
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value
}
function requireFact(condition, message) { if (!condition) throw Error(message) }
function producerResponsePresent(path, data) {
if (data == null || typeof data !== 'object') return false
if (path === DISPATCH) return data.dispatchNo && data.action === 'CREATE' && data.recordVersion != null && data.result?.dispatchNo === data.dispatchNo
if (path.endsWith('/photos/files')) return /^[1-9]\d*$/.test(id(data.fileId))
if (path.endsWith('/pallets/resolve')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) && data.totalPackages != null
if (path.endsWith('/relabel-pallet-label')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId))
if (path.includes('/relabel-tasks/')) return data.relabelNo && data.version != null && Array.isArray(data.items)
if (path.includes('/relabel-applications')) return data.application?.relabelNo && Array.isArray(data.items)
return true
}
export function installRelabelJournal(context, report, save) {
installRetainedJournal(context, report, save)
const request = context.request.bind(context), upload = context.requestMultipart.bind(context)
const verify = async (method, path, send) => {
if (method !== 'GET' && report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw Error('Unknown producer write outcome: stop with original commands')
const response = await send()
if (method !== 'GET' && response.http === 200 && String(response.body?.code) === '200'
&& !producerResponsePresent(path, response.body?.data)) {
const entry = report.requests.at(-1)
if (entry) entry.status = 'UNKNOWN'
await context.checkpoint(); throw Error('Unknown producer write result: native business response missing')
}
return response
2026-09-14 09:58:30 +08:00
}
2026-10-20 10:35:52 +08:00
context.request = (method, path, body, token, headers) => verify(method, path, () => request(method, path, body, token, headers))
context.requestMultipart = (method, path, file, token, headers) => verify(method, path, () => upload(method, path, file, token, headers))
return context
}
function validateLoadingEvidence(value, marker, dispatchNo, targets) {
const physicalIds = sorted(targets.map(row => id(row.palletId)))
requireFact(value?.environment === 'TEST' && value.marker === marker && typeof value.dispatchNo === 'string' && value.dispatchNo.trim() && value.dispatchNo === dispatchNo && Array.isArray(value.pallets)
&& same(sorted(value.pallets.map(row => id(row.warehousePalletId))), physicalIds)
&& value.pallets.length === physicalIds.length, 'Loading evidence belongs to another marker/dispatch/physical pallet')
for (const row of value.pallets) {
requireFact(Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0,
'Actual pallet spaces and physical stop index are required')
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) {
requireFact(/^(0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number(row[field]) > 0
&& row.evidence?.some(e => e.actual === true && e.field === field && e.sourceType && e.sourceBusinessNo
&& Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(row[field])), 'Actual physical loading field evidence is missing')
}
}
return value
}
export function selectExplicitRelabelSource(candidates, config) {
requireFact(config.sourceBalanceId && config.sourcePositionId, 'Explicit source balance and position are required; no historical inventory fallback')
const matches = candidates.filter(row => id(row.inventoryBalanceId) === id(config.sourceBalanceId)
&& id(row.overseasWarehouseId) === id(config.warehouseId)
&& (!config.inboundCode || row.inboundCode === config.inboundCode)
&& row.positions?.some(position => id(position.positionId) === id(config.sourcePositionId)))
requireFact(matches.length === 1, 'Explicit relabel source is absent or ambiguous in this customer warehouse')
const source = matches[0], positions = source.positions.filter(row => id(row.positionId) === id(config.sourcePositionId))
requireFact(positions.length === 1 && String(source.inboundOrderNo || '').startsWith('DS-') && source.destinationFbaWarehouseCode
&& quantity(source.availablePackages) >= 5 && quantity(positions[0].availablePackages) >= 5, 'Explicit source requires original DS inventory and five real available cartons')
return { source, sourcePosition: positions[0] }
}
function scenarioCargo(scenario, rows, customerId, warehouseId) {
const targets = sorted(scenario.targets.map(target => id(target.palletId)))
const expected = scenario.items.reduce((sum, item) => sum + Number(item.notifiedPackageCount), 0)
requireFact(rows.length === targets.length && same(sorted(rows.map(row => id(row.selection?.warehousePalletId))), targets)
&& rows.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected, 'Native relabel candidates do not exactly cover original targets and carton quantity')
const cargo = rows.map(row => {
const selection = row.selection
requireFact(selection?.sourceType === 'RELABEL' && selection.relabelNo === scenario.relabelNo
&& selection.planPalletId == null && selection.returnReceiptId == null
&& /^[1-9]\d*$/.test(id(selection.warehousePalletId))
&& row.sourceKey === `RELABEL:${scenario.relabelNo}:${id(selection.warehousePalletId)}`
&& /^[a-f\d]{64}$/i.test(row.sourceVersion || '') && row.sourceVersion === selection.sourceVersion
&& id(row.customerId) === id(customerId) && id(row.warehouseId) === id(warehouseId)
&& quantity(row.availablePackages) > 0 && row.destination?.type === 'FBA'
&& row.destination.warehouseCode === scenario.destinationFbaWarehouseCode
&& Array.isArray(row.blockReasons) && !row.blockReasons.length && Array.isArray(row.appointmentBatches) && !row.appointmentBatches.length,
'Incomplete native RELABEL selector, warehouse/customer/destination or source version')
requireFact(!Object.hasOwn(row, 'cartonMarks') && !Object.hasOwn(row, 'relabelCartonMarks'), 'Native candidates must not expose legacy carton mark projections')
return { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: scenario.relabelNo,
warehousePalletId: id(selection.warehousePalletId), sourceVersion: selection.sourceVersion }
})
return cargo
}
/** The complete server group is the selection unit, including the whole application closure. */
export function nativeRelabelCreateBody(marker, scenarios, rows, customerId, warehouseId) {
requireFact(Array.isArray(scenarios) && scenarios.length > 0 && rows.length > 0
&& new Set(scenarios.map(s => s.relabelNo)).size === scenarios.length, 'Owned relabel scenarios are missing or duplicate')
const group = rows[0].selectionGroup
requireFact(typeof group?.groupKey === 'string' && group.groupKey.trim() && typeof group.groupVersion === 'string' && group.groupVersion.trim()
&& group.complete === true && Array.isArray(group.blockReasons) && !group.blockReasons.length
&& Array.isArray(group.sources) && group.sources.length && Array.isArray(group.pallets) && group.pallets.length,
'Authoritative complete selectionGroup and groupVersion are required')
requireFact(rows.every(row => canonical(row.selectionGroup) === canonical(group)), 'Selection group version or membership drift')
requireFact(new Set(rows.map(row => row.sourceKey)).size === rows.length && group.sources.length === rows.length
&& new Set(group.sources.map(source => source.sourceKey)).size === group.sources.length
&& same(sorted(group.sources.map(source => source.sourceKey)), sorted(rows.map(row => row.sourceKey))), 'Selection group logical sources are incomplete or duplicate')
const cargo = scenarios.flatMap(scenario => scenarioCargo(scenario, rows.filter(row => row.selection?.relabelNo === scenario.relabelNo), customerId, warehouseId))
requireFact(cargo.length === rows.length, 'Selection group contains an unknown relabel business member')
for (const source of group.sources) {
const row = rows.find(value => value.sourceKey === source.sourceKey), scenario = scenarios.find(value => value.relabelNo === source.sourceBusinessNo)
requireFact(scenario?.relabelVerified === true && row && source.sourceBusinessType && id(source.customerId) === id(customerId)
&& source.sourceBusinessNo === row.selection.relabelNo && canonical(source.selection) === canonical(row.selection)
&& quantity(source.packages) > 0 && quantity(source.packages) === quantity(row.availablePackages), 'Selection group business, selector version or source quantity drift')
}
requireFact(new Set(group.pallets.map(p => id(p.warehousePalletId))).size === group.pallets.length
&& same(sorted(group.pallets.map(p => id(p.warehousePalletId))), sorted(cargo.map(c => id(c.warehousePalletId)))), 'Selection group physical pallet closure is incomplete or duplicate')
for (const pallet of group.pallets) {
const sources = group.sources.filter(source => id(source.selection.warehousePalletId) === id(pallet.warehousePalletId))
requireFact(/^[1-9]\d*$/.test(id(pallet.warehousePalletId)) && pallet.palletNo && sources.length
&& Array.isArray(pallet.sourceKeys) && pallet.sourceKeys.length === sources.length
&& same(sorted(pallet.sourceKeys), sorted(sources.map(source => source.sourceKey)))
&& quantity(pallet.packageCount) === sources.reduce((sum, source) => sum + quantity(source.packages), 0)
&& rows.filter(row => id(row.selection.warehousePalletId) === id(pallet.warehousePalletId)).every(row => row.palletNo === pallet.palletNo),
'Selection group physical source contributions or carton quantity drift')
}
return { idempotencyKey: createHash('sha256').update(`${marker}\n${group.groupKey}\n${group.groupVersion}\nCREATE`).digest('hex'), warehouseId: id(warehouseId),
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-${scenarios.map(s => s.name).join('+')}`, cargo, batchAllocations: [] }
}
export function nativeRelabelGroups(scenarios, rows, customerId, warehouseId) {
const groups = new Map()
for (const scenario of scenarios) {
requireFact(scenario.relabelVerified === true && scenario.relabelCartonMarks?.length === scenario.expectedCartons
&& new Set(scenario.relabelCartonMarks).size === scenario.relabelCartonMarks.length
&& scenario.relabelCartonMarks.every(mark => typeof mark === 'string' && mark.trim() && !scenario.sourceCartonMarks.includes(mark)), 'Original/new relabel carton mark evidence is incomplete or overlaps')
const candidates = rows.filter(row => row.selection?.sourceType === 'RELABEL' && row.selection.relabelNo === scenario.relabelNo)
scenarioCargo(scenario, candidates, customerId, warehouseId)
requireFact(candidates.every(row => row.selectionGroup?.groupKey) && new Set(candidates.map(row => row.selectionGroup.groupKey)).size === 1,
'Whole relabel application must belong to one authoritative complete selection group')
const key = candidates[0].selectionGroup.groupKey
if (!groups.has(key)) groups.set(key, { scenarios: [], selectionGroup: candidates[0].selectionGroup })
groups.get(key).scenarios.push(scenario)
scenario.dispatchCandidates = candidates
}
const physicalOwners = new Map(), result = []
for (const entry of groups.values()) {
const keys = entry.selectionGroup.sources?.map(source => source.sourceKey) || []
entry.candidates = rows.filter(row => keys.includes(row.sourceKey))
entry.createBody = nativeRelabelCreateBody('PREFLIGHT', entry.scenarios, entry.candidates, customerId, warehouseId)
for (const pallet of entry.selectionGroup.pallets) {
const key = id(pallet.warehousePalletId)
requireFact(!physicalOwners.has(key), 'One physical pallet cannot be split across selection groups')
physicalOwners.set(key, entry.selectionGroup.groupKey)
}
result.push(entry)
}
return result
}
// Compare physical values once, retaining per-scenario raw receipts without adding whole-pallet weights.
export function relabelGroupMeasurements(scenarios, group) {
const values = new Map(), fields = ['palletCode', 'packages', 'stockVersion', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']
for (const scenario of scenarios) for (const measured of scenario.physicalMeasurements || []) {
const palletId = id(measured.palletId), pallet = group.pallets.find(p => id(p.warehousePalletId) === palletId)
if (!pallet) continue
requireFact(measured.palletCode === pallet.palletNo && quantity(measured.packages) === quantity(pallet.packageCount), 'Physical measurement does not cover the complete shared pallet')
const old = values.get(palletId)
if (old) requireFact(fields.every(field => canonical(old[field]) === canonical(measured[field])), 'Shared physical measurement versions or values conflict')
else values.set(palletId, structuredClone(measured))
}
return [...values.values()]
}
function groupLoadingEvidence(config, report, scenarios, group, dispatchNo) {
const targets = group.pallets.map(p => ({ palletId: id(p.warehousePalletId) })), inputs = scenarios.map(s => config.loadingEvidenceByScenario?.[s.name]).filter(Boolean)
if (!inputs.length) return undefined
const validated = inputs.map(value => validateLoadingEvidence(value, report.marker, dispatchNo || value.dispatchNo, targets))
const physical = value => [...value.pallets].sort((a, b) => id(a.warehousePalletId).localeCompare(id(b.warehousePalletId))).map(p => ({ warehousePalletId: id(p.warehousePalletId),
palletSpaces: p.palletSpaces, weightKg: String(Number(p.weightKg)), volumeCbm: String(Number(p.volumeCbm)), deliveryIndex: p.deliveryIndex }))
requireFact(validated.every(value => value.dispatchNo === validated[0].dispatchNo && canonical(physical(value)) === canonical(physical(validated[0]))), 'Shared scenario loading receipts must name the same dispatch and identical physical measurements')
return structuredClone(validated[0])
}
/** Read the complete candidate universe and validate every owned group before the first CREATE. */
export async function publishNativeRelabelDispatches(context, report, config) {
const scenarios = report.scenarios.filter(row => row.operationType === 'CARTON_AND_SKU')
report.blockers ||= []
const block = (scenario, code, evidence) => {
scenario.dispatchStatus = 'BLOCKED'; scenario.nativeDispatchBlocker = { code, ...evidence }
report.blockers.push({ scenario: scenario.name, code, ...evidence })
context.addCoverageGap?.(MODULE, scenario.name, 'Native relabel dispatch', code, 'physical-source-blocked', { deploymentBlocker: true })
context.addStep?.(MODULE, `${scenario.name} native dispatch blocked`, null, false, code, { deploymentBlocker: true })
}
const page = await retainedApi(context, 'GET', `${DISPATCH}/candidates?${new URLSearchParams({ warehouseId: id(config.warehouseId), sourceStage: 'RELABEL' })}`)
requireFact(page?.complete === true && Array.isArray(page.candidates), 'Native candidate list is incomplete')
for (const scenario of scenarios) {
scenario.sourceAfter = Number((await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: report.sourcePosition.palletNo }, config.pdaToken)).totalPackages)
scenario.targetPallets = scenario.targets.map(target => ({ palletId: id(target.palletId), palletCode: target.palletCode }))
scenario.physicalMeasurements = await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements', { relabelNo: scenario.relabelNo }, config.pdaToken)
await context.checkpoint()
}
let groups
try {
groups = nativeRelabelGroups(scenarios, page.candidates, config.customerId, config.warehouseId)
for (const group of groups) {
group.physicalMeasurements = relabelGroupMeasurements(group.scenarios, group.selectionGroup)
groupLoadingEvidence(config, report, group.scenarios, group.selectionGroup)
}
} catch (error) {
report.nativeSelectionFailure = { message: error.message, candidates: page.candidates }
for (const scenario of scenarios) block(scenario, 'NATIVE_SELECTION_GROUP_NOT_VERIFIED', { reason: error.message })
await context.checkpoint(); throw error
}
for (const group of groups) {
const members = group.scenarios
try {
const createBody = nativeRelabelCreateBody(report.marker, members, group.candidates, config.customerId, config.warehouseId)
const native = { marker: report.marker, customerId: id(config.customerId), warehouseId: id(config.warehouseId), createBody, physicalMeasurements: group.physicalMeasurements }
for (const scenario of members) {
scenario.nativeDispatch = native; scenario.nativeCreateBody = createBody; scenario.selectionGroup = group.selectionGroup
scenario.sourceContributions = group.selectionGroup.sources.filter(source => source.sourceBusinessNo === scenario.relabelNo)
}
await context.checkpoint()
const createResult = await retainedApi(context, 'POST', DISPATCH, createBody)
Object.assign(native, { createResult, dispatchNo: createResult?.dispatchNo }); await context.checkpoint()
requireFact(createResult?.action === 'CREATE' && createResult.dispatchNo && createResult.status === 'ACTIVE'
&& createResult.stage === 'BEFORE_LOADING', 'Native CREATE did not return original active vehicle identity')
exactVersion(createResult.recordVersion)
const replay = await retainedApi(context, 'POST', DISPATCH, createBody)
native.createReplay = replay; await context.checkpoint()
requireFact(replay.replayed === true && replay.dispatchNo === createResult.dispatchNo
&& String(replay.recordVersion) === String(createResult.recordVersion) && same(replay.result, createResult.result), 'Native CREATE replay changed original result')
const detail = await retainedApi(context, 'GET', `${DISPATCH}/${encodeURIComponent(createResult.dispatchNo)}`)
const sourceKeys = group.candidates.map(row => row.sourceKey)
requireFact(detail?.dispatchNo === createResult.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING'
&& quantity(detail.counts?.packageCount) === group.selectionGroup.sources.reduce((sum, source) => sum + quantity(source.packages), 0)
&& quantity(detail.counts?.palletCount) === group.selectionGroup.pallets.length
&& id(exactVersion(detail.recordVersion)) === id(createResult.recordVersion)
&& detail.orderDetails?.length >= sourceKeys.length
&& same(sorted(detail.orderDetails?.map(row => row.sourceKey) || []), sorted(sourceKeys))
&& detail.orderDetails.every(row => group.selectionGroup.sources.some(source => source.sourceKey === row.sourceKey && id(source.customerId) === id(row.customerId)
&& source.sourceBusinessNo === row.sourceBusinessNo && source.sourceBusinessType === row.sourceBusinessType)), 'Native Detail source/business/customer/carton identity drift')
native.detail = detail
const measurements = groupLoadingEvidence(config, report, members, group.selectionGroup, createResult.dispatchNo)
if (measurements) native.loadingMeasurements = measurements
for (const scenario of members) {
scenario.dispatchNo = createResult.dispatchNo; scenario.dispatchStatus = detail.status
if (!measurements) block(scenario, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED', { fields: ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'],
dispatchNo: createResult.dispatchNo, evidenceKind: 'PHYSICAL_MEASUREMENTS_NOT_FABRICATED' })
}
await context.checkpoint()
} catch (error) {
if (report.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw error
for (const scenario of members) block(scenario, 'NATIVE_DISPATCH_NOT_VERIFIED', { candidates: scenario.dispatchCandidates, sourceBeforePackages: scenario.sourceBefore,
sourceAfterPackages: scenario.sourceAfter, reason: error.message, evidenceKind: 'RETAINED_ORIGINAL_REQUEST_AND_RESPONSE' })
await context.checkpoint()
}
}
return scenarios
2026-09-14 09:58:30 +08:00
}
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) }
2026-10-20 10:35:52 +08:00
export async function runRelabelProducer(context, config, report) {
const { customerId, warehouseId, merchantToken, pdaToken, chargeAmount } = config
const platformToken = context.token, marker = report.marker
const key = value => `${marker}-${value}`.slice(0, 200)
const api = (token, method, path, body) => retainedApi(context, method, path, body, token)
const candidates = await api(merchantToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`)
const { source, sourcePosition } = selectExplicitRelabelSource(candidates, config)
report.source = source; report.sourcePosition = sourcePosition
await context.checkpoint()
async function resolvePallet(palletCode) {
return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
2026-09-14 09:58:30 +08:00
2026-10-20 10:35:52 +08:00
async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) {
const suffix = `${name}-${marker}`
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 scenario = { name, operationType, expectedCartons: cartonCount, items: [], targets: [], requestItems: items,
sourceCartonMarks: items.map(item => item.cartonMark), relabelVerified: false }
report.scenarios.push(scenario)
const created = await api(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
scenario.relabelNo = relabelNo; scenario.createResult = created
assert(created.application.overseasWarehouseId === warehouseId, 'Created relabel belongs to a different warehouse')
await context.checkpoint()
if (chargeAmount > 0) await poll(() => api(platformToken, 'GET',
`/api/platform/v1/last-leg/relabel-applications/${relabelNo}`),
(detail) => detail.application.walletStatus === 'FROZEN')
let detail = await api(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') {
const cartonBody = { cartonMark: item.cartonMark, expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-CARTON-${index}`) }
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody)
const cartonReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody)
assert(String(cartonReplay.version) === String(detail.version) && same(cartonReplay.items, detail.items), 'Original carton scan replay changed quantities/version')
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`)
}
const scanBody = {
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: exactVersion(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`),
}
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody)
const scanReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody)
assert(String(scanReplay.version) === String(detail.version) && same(scanReplay.items, detail.items), 'Original SKU verification replay changed quantities/version')
2026-09-17 17:37:29 +08:00
}
2026-10-20 10:35:52 +08:00
const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
Object.assign(scenario, { items: detail.items, sourceBefore, sourceCartonMarks: items.map(item => item.cartonMark), requestItems: items })
if (prepare) await prepare(scenario)
// Valid synthetic PNG for API validation; no physical warehouse photograph is claimed.
const photoResponse = await context.requestMultipart('POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, {
name: `${marker}-${name}.png`, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
}, pdaToken)
const photo = context.apiSuccess('overseas-relabel-producer', 'Upload synthetic relabel PNG', photoResponse)
assert(photoResponse.http === 200 && String(photoResponse.body?.code) === '200' && photo?.fileId, 'Synthetic photo upload failed')
scenario.photo = { fileId: id(photo.fileId), evidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK' }
detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
photoFileIds: [id(photo.fileId)], expectedVersion: exactVersion(detail.version),
idempotencyKey: key(`${name}-COMPLETE`), remark: marker,
})
assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`)
const confirmed = await api(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`)
Object.assign(scenario, { items: confirmed.items, confirmedResult: confirmed, relabelVerified: true,
relabelCartonMarks: operationType === 'CARTON_AND_SKU' ? confirmed.items.map(item => item.fbaCartonMark) : [] })
scenario.sourceAfterRelabel = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages)
await context.checkpoint()
return scenario
}
async function printPallet(relabelNo, suffix) {
return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
relabelNo, idempotencyKey: key(`PRINT-${suffix}`),
2026-09-14 09:58:30 +08:00
})
}
2026-10-20 10:35:52 +08:00
async function moveCarton(scenario, item, target, index) {
const sourcePallet = await resolvePallet(sourcePosition.palletNo)
const targetPallet = await resolvePallet(target.palletCode)
const result = await api(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: exactVersion(sourcePallet.palletVersion) },
target: { palletCode: targetPallet.palletCode, expectedVersion: exactVersion(targetPallet.palletVersion) },
cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`,
2026-09-14 09:58:30 +08:00
})
2026-10-20 10:35:52 +08:00
assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type')
return result
}
2026-09-14 09:58:30 +08:00
2026-10-20 10:35:52 +08:00
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
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]
2026-09-14 09:58:30 +08:00
})
2026-10-20 10:35:52 +08:00
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]
2026-09-14 09:58:30 +08:00
})
2026-10-20 10:35:52 +08:00
const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages)
assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase')
merged.targets = [sharedTarget]
report.sharedMerge = { palletId: id(sharedTarget.palletId), palletCode: sharedTarget.palletCode, beforePackages: sharedBefore, afterPackages: sharedAfter,
sourceScenarios: [{ name: fresh.name, cartons: fresh.expectedCartons }, { name: merged.name, cartons: merged.expectedCartons }] }
2026-09-14 09:58:30 +08:00
2026-10-20 10:35:52 +08:00
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]
2026-09-14 09:58:30 +08:00
})
2026-10-20 10:35:52 +08:00
for (const scenario of [fresh, merged, split]) scenario.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode
const ledger = await api(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.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map(row => Number(row.deltaPackages)), records: ledgerRows }
report.relabelScenariosVerified = report.scenarios.length === 4 && report.scenarios.every(scenario => scenario.relabelVerified)
await publishNativeRelabelDispatches(context, report, config)
report.passed = report.relabelScenariosVerified && !report.blockers.length
report.finalStatus = report.passed ? 'RELABEL_AND_NATIVE_DISPATCH_VERIFIED' : 'BLOCKED_NATIVE_DISPATCH_OR_PHYSICAL_LOADING_EVIDENCE'
await context.checkpoint(); return report
2026-09-14 09:58:30 +08:00
}
2026-10-20 10:35:52 +08:00
export async function mainRelabelProducer(env = process.env) {
const options = parseArguments([]); options.modules = []
const output = resolve(env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json')
options.output = output; validateExecutionOptions(options)
requireFact(options.environment === 'test', 'Relabel producer only supports explicit TEST')
const customerId = id(env.OVERSEAS_TEST_CUSTOMER_ID), warehouseId = id(env.OVERSEAS_TEST_WAREHOUSE_ID)
const sourceBalanceId = id(env.OVERSEAS_RELABEL_SOURCE_BALANCE_ID), sourcePositionId = id(env.OVERSEAS_RELABEL_SOURCE_POSITION_ID)
requireFact(/^[1-9]\d*$/.test(customerId) && /^[1-9]\d*$/.test(warehouseId) && /^[1-9]\d*$/.test(sourceBalanceId) && /^[1-9]\d*$/.test(sourcePositionId),
'Explicit customer, warehouse, original source balance and position IDs are required before HTTP')
requireFact(!existsSync(output), 'Existing producer checkpoint must be reconciled with its original commands; do not overwrite it or regenerate a marker')
const chargeAmount = Number(env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01')
requireFact(Number.isFinite(chargeAmount) && chargeAmount >= 0, 'Invalid charge amount')
const context = new AcceptanceRun(options), marker = env.OVERSEAS_RELABEL_MARKER || context.marker
requireFact(/^[A-Za-z0-9_-]+$/.test(marker), 'Invalid explicit producer marker')
const report = { marker, environment: 'TEST', startedAt: new Date().toISOString(), scope: 'RELABEL_PRODUCER_AND_NATIVE_DISPATCH_ONLY',
chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], blockers: [], passed: false, requests: [] }
const raw = async (method, path, body, token) => {
const response = await context.request(method, path, body, token)
requireFact(response.http === 200 && String(response.body?.code) === '200', 'Unified authentication failed')
return response.body.data
}
try {
// Session tickets and tokens remain in memory and never enter the business journal.
const platform = await raw('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
context.token = platform.accessToken
requireFact(context.token && options.pdaUsername && options.pdaPassword, 'Unified platform and explicit PDA credentials required')
const pda = await raw('POST', '/api/auth/login', { loginId: options.pdaUsername, password: options.pdaPassword }, '')
let merchant
if (options.merchantUsername && options.merchantPassword) merchant = await raw('POST', '/api/auth/login', { loginId: options.merchantUsername, password: options.merchantPassword }, '')
else {
const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} TEST relabel producer` }, context.token)
merchant = await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.code }, '')
}
requireFact(pda.accessToken && merchant.accessToken, 'Unified PDA/customer accessToken required')
const profile = await raw('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant.accessToken)
requireFact(id(profile.id) === customerId, 'Merchant session does not own the explicit customer')
installRelabelJournal(context, report, () => saveRetainedReport(output, report)); context.marker = marker
const loadingEvidenceByScenario = env.OVERSEAS_RELABEL_LOADING_EVIDENCE ? JSON.parse(readFileSync(resolve(env.OVERSEAS_RELABEL_LOADING_EVIDENCE), 'utf8')) : undefined
await runRelabelProducer(context, { customerId, warehouseId, sourceBalanceId, sourcePositionId, inboundCode: env.OVERSEAS_RELABEL_SOURCE_INBOUND_CODE,
merchantToken: merchant.accessToken, pdaToken: pda.accessToken, chargeAmount, loadingEvidenceByScenario }, report)
} catch (error) {
report.passed = false; report.finalStatus = report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)) ? 'UNKNOWN_WRITE_STOPPED' : 'BLOCKED'
report.failedStage = report.scenarios.at(-1)?.name || 'CUSTOMER_OR_EXPLICIT_SOURCE'; process.exitCode = 1
} finally {
report.checks = context.steps; report.finishedAt = new Date().toISOString()
if (!report.passed || context.steps.some(step => step.passed === false)) process.exitCode = 1
saveRetainedReport(output, report)
console.log(JSON.stringify({ marker, finalStatus: report.finalStatus, passed: report.passed,
relabelScenariosVerified: report.relabelScenariosVerified, scenarios: report.scenarios.map(scenario => ({ name: scenario.name,
relabelNo: scenario.relabelNo, dispatchNo: scenario.dispatchNo, dispatchStatus: scenario.dispatchStatus })), report: output }))
}
return report
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabelProducer()