Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs
T

800 lines
51 KiB
JavaScript
Raw Normal View History

2026-09-20 19:57:47 +08:00
import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
const MODULE = 'phase4-overseas-pda-operations'
const SCENARIO = 'phase4-overseas-pda-p2-p5'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
function id(value) { return value == null ? '' : String(value) }
2026-09-11 01:18:01 +08:00
function longId(value) { return /^\d+$/.test(String(value || '')) && String(value) !== '0' }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function conflict(response) {
return (response?.http === 409 || (response?.http === 200
&& ['409', 'A0409'].includes(String(response?.body?.code))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
function key(context, action) {
return `${context.marker}-PDAOPS-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120)
}
function query(path, values) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
}
return params.size ? `${path}?${params}` : path
}
function operationPallet(result, palletCode) {
return (result?.pallets || []).find((item) => item?.palletCode === palletCode)
}
function relabelItemForUnit(detail, unitIndex) {
let remaining = unitIndex
for (const item of detail?.items || []) {
if (remaining < Number(item?.expectedPackages || 0)) return item
remaining -= Number(item?.expectedPackages || 0)
}
return null
}
function blocker(context, name, reason, response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true })
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
}
async function request(context, token, method, name, path, body = undefined) {
const response = await context.requestOverseas(method, path, body, {}, token)
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
return { response, data: success(response) ? response.body?.data : null }
}
async function poll(action, predicate, attempts = 60) {
let result = null
for (let index = 0; index < attempts; index += 1) {
result = await action()
if (predicate(result)) return { result, attempts: index + 1 }
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { result, attempts }
}
async function fundRelabelWallet(context, customerId) {
const accountResponse = await context.request('GET',
`/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
const account = success(accountResponse)
? records(accountResponse.body?.data).find((item) => id(item?.customerId) === customerId) : null
context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse,
Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS)
if (!account) return false
const recharge = await context.request('POST',
`/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, {
type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10),
paymentMethod: 'BANK_TRANSFER', referenceNo: `${context.marker}-P5`, remark: context.marker,
expectedAccountVersion: Number(account.accountVersion),
}, context.token, { 'Idempotency-Key': key(context, 'RELABEL-WALLET-FUND') })
context.addStep(MODULE, 'P5为收费换标准备本轮隔离实际余额', recharge,
success(recharge) && Number(recharge.body?.data?.actualBalanceAfter) >= 1,
`actualBalanceAfter=${recharge.body?.data?.actualBalanceAfter ?? 'missing'}; ${trace(recharge)}`, OPTIONS)
return success(recharge)
}
async function expectConflict(context, token, method, name, path, body) {
2026-09-24 01:48:24 +08:00
let response = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await context.requestOverseas(method, path, body, {}, token)
if (response?.http !== 0 || attempt === 3) break
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
}
context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS)
return response
}
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
return request(context, token, 'POST', `${name}(${palletCode})`,
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function resolvePublicLocation(context, fixture) {
const response = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
const location = records(response?.body?.data).find((item) => item?.locationType === 'VIRTUAL'
&& item?.status === 'ACTIVE')
context.addStep(MODULE, '复用当前海外仓公共库位', response,
success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode),
`locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS)
return success(response) && location
? { locationId: id(location.id), locationCode: location.locationCode } : null
}
export function remainingRelabelItem(detail, usedItemIds) {
const used = new Set(usedItemIds.map(id))
return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || null
}
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
for (const candidate of fixture.auxiliaryPallets || []) {
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
const resolved = await resolvePallet(context, token, candidate.palletCode, '解析入仓计划已有空托盘')
if (resolved.data && Array.isArray(resolved.data.inventory) && resolved.data.inventory.length === 0) {
context.assert(MODULE, '辅助托盘只复用本轮入仓计划已有空托盘', resolved.response, true,
`palletCode=${resolved.data.palletCode}; ${trace(resolved.response)}`, OPTIONS)
return resolved.data
}
}
return null
}
async function verifyStocktake(context, token, fixture) {
const path = '/api/pda/v1/last-leg/stocktakes'
const body = { scopeType: 'PALLET', scopeCode: fixture.pallet.palletCode,
idempotencyKey: key(context, 'STOCKTAKE-CREATE') }
const created = await request(context, token, 'POST', 'P2按PALLET创建盘点任务', path, body)
const taskNo = id(created.data?.task?.taskNo)
if (!taskNo || !Array.isArray(created.data?.items) || created.data.items.length === 0) {
blocker(context, 'P2真实库存盘点', '本轮完成托盘没有可盘点库存位置', created.response)
return false
}
const replay = await request(context, token, 'POST', 'P2创建盘点同键重放', path, body)
context.assert(MODULE, 'P2创建重放复用同一任务', replay.response,
id(replay.data?.task?.taskNo) === taskNo, trace(replay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P2创建盘点同键异载荷A0409', path,
{ ...body, scopeCode: `${body.scopeCode}-DIFFERENT` })
for (const [index, item] of created.data.items.entries()) {
const countPath = `${path}/${encodeURIComponent(taskNo)}/counts`
const countBody = { positionId: id(item.positionId), actualPackages: Number(item.bookPackages),
expectedVersion: Number(item.version), idempotencyKey: key(context, `STOCKTAKE-COUNT-${index}`) }
const counted = await request(context, token, 'POST', `P2库存项${index + 1}按原数计数`, countPath, countBody)
context.assert(MODULE, `P2库存项${index + 1}零差异`, counted.response,
Number(counted.data?.actualPackages) === Number(item.bookPackages)
&& Number(counted.data?.differencePackages) === 0,
`positionId=${id(item.positionId)}, book=${item.bookPackages}; ${trace(counted.response)}`, OPTIONS)
const countReplay = await request(context, token, 'POST', `P2库存项${index + 1}计数同键重放`, countPath, countBody)
context.assert(MODULE, `P2库存项${index + 1}重放结果稳定`, countReplay.response,
id(countReplay.data?.positionId) === id(item.positionId)
&& Number(countReplay.data?.actualPackages) === Number(item.bookPackages), trace(countReplay.response), OPTIONS)
await expectConflict(context, token, 'POST', `P2库存项${index + 1}计数同键异载荷A0409`, countPath,
{ ...countBody, actualPackages: Number(item.bookPackages) + 1 })
await expectConflict(context, token, 'POST', `P2库存项${index + 1}旧版本A0409`, countPath,
{ ...countBody, idempotencyKey: key(context, `STOCKTAKE-COUNT-STALE-${index}`) })
}
const detail = await request(context, token, 'GET', 'P2回查全部盘点项已计数',
`${path}/${encodeURIComponent(taskNo)}`)
const taskVersion = Number(detail.data?.task?.version)
context.assert(MODULE, 'P2盘点任务计数守恒', detail.response,
detail.data?.task?.status === 'IN_PROGRESS'
&& Number(detail.data?.task?.countedCount) === detail.data?.items?.length
&& detail.data.items.every((item) => Number(item.actualPackages) === Number(item.bookPackages)),
`counted=${detail.data?.task?.countedCount ?? 'missing'}, items=${detail.data?.items?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
await expectConflict(context, token, 'POST', 'P2提交盘点旧任务版本A0409',
`${path}/${encodeURIComponent(taskNo)}/submit`, {
expectedVersion: Math.max(0, taskVersion - 1), reason: `${context.marker}-stale`,
idempotencyKey: key(context, 'STOCKTAKE-SUBMIT-STALE'),
})
const submitBody = { expectedVersion: taskVersion, reason: `${context.marker}-matched`,
idempotencyKey: key(context, 'STOCKTAKE-SUBMIT') }
const submitted = await request(context, token, 'POST', 'P2提交盘点到SUBMITTED',
`${path}/${encodeURIComponent(taskNo)}/submit`, submitBody)
context.assert(MODULE, 'P2盘点提交状态正确且无差异', submitted.response,
submitted.data?.task?.status === 'SUBMITTED' && Number(submitted.data?.task?.differenceCount) === 0,
trace(submitted.response), OPTIONS)
const submitReplay = await request(context, token, 'POST', 'P2提交盘点同键重放',
`${path}/${encodeURIComponent(taskNo)}/submit`, submitBody)
context.assert(MODULE, 'P2提交重放仍为SUBMITTED', submitReplay.response,
submitReplay.data?.task?.status === 'SUBMITTED', trace(submitReplay.response), OPTIONS)
const page = await request(context, token, 'GET', 'P2分页查询已提交盘点',
`${path}?page=1&size=20&status=SUBMITTED`)
context.assert(MODULE, 'P2列表和详情均包含本轮任务', page.response,
records(page.data).some((item) => id(item?.taskNo) === taskNo),
`taskNo=${taskNo}, records=${records(page.data).length}; ${trace(page.response)}`, OPTIONS)
return success(submitted.response)
}
async function verifyInventoryMove(context, token, fixture) {
const target = await resolvePublicLocation(context, fixture)
if (!target) return false
let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data
if (!pallet || !nonBlank(pallet.locationCode)) {
blocker(context, 'P3整托移库', '本轮完成托盘无法解析当前位置')
return false
}
const originalLocationCode = pallet.locationCode
if (originalLocationCode === target.locationCode) {
const availablePackages = (pallet.inventory || []).reduce((sum, item) =>
sum + Number(item?.availablePackages || 0), 0)
context.assert(MODULE, 'P3托盘已在公共库位不重复移库', null,
availablePackages > 0,
`palletCode=${pallet.palletCode}, locationCode=${target.locationCode}, availablePackages=${availablePackages}`, OPTIONS)
return availablePackages > 0
}
const path = '/api/pda/v1/last-leg/inventory-moves'
const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode,
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'),
reason: `${context.marker}-move-forward` }
const moved = await request(context, token, 'POST', 'P3整托移到目标库位', path, body)
context.assert(MODULE, 'P3整托移库数量和目标位置正确', moved.response,
moved.data?.status === 'COMPLETED' && moved.data?.targetLocationCode === target.locationCode
&& Number(moved.data?.packageCount) > 0, trace(moved.response), OPTIONS)
const replay = await request(context, token, 'POST', 'P3整托移库同键重放', path, body)
context.assert(MODULE, 'P3移库重放标记且结果稳定', replay.response,
replay.data?.replayed === true && replay.data?.moveNo === moved.data?.moveNo, trace(replay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P3移库同键异载荷A0409', path,
{ ...body, reason: `${body.reason}-DIFFERENT` })
await expectConflict(context, token, 'POST', 'P3移库旧托盘版本A0409', path, {
...body, targetLocationCode: originalLocationCode, idempotencyKey: key(context, 'MOVE-STALE'),
})
pallet = (await resolvePallet(context, token, pallet.palletCode, 'P3回查托盘已位于目标库位')).data
context.assert(MODULE, 'P3托盘与库存位置同步到目标库位', moved.response,
pallet?.locationCode === target.locationCode && Number(pallet?.totalPackages) > 0,
`location=${pallet?.locationCode || 'missing'}; ${trace(moved.response)}`, OPTIONS)
const restoreBody = { sourcePalletCode: pallet.palletCode, targetLocationCode: originalLocationCode,
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-RESTORE'),
reason: `${context.marker}-move-restore` }
const restored = await request(context, token, 'POST', 'P3整托移回原库位供后续验证', path, restoreBody)
return success(restored.response) && restored.data?.targetLocationCode === originalLocationCode
}
async function replayOperation(context, token, name, path, body, expectedType) {
const first = await request(context, token, 'POST', name, path, body)
const replay = await request(context, token, 'POST', `${name}同键重放`, path, body)
context.assert(MODULE, `${name}重放结果稳定`, replay.response,
replay.data?.replayed === true && replay.data?.operationId === first.data?.operationId
&& replay.data?.operationType === expectedType,
trace(replay.response), OPTIONS)
return first
}
async function verifyPalletOperations(context, token, fixture) {
const base = '/api/pda/v1/last-leg/pallet-operations'
let main = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P4 resolve主托盘')).data
if (!main || !Array.isArray(main.inventory) || main.inventory.length === 0) {
blocker(context, 'P4托盘操作', '主托盘没有可移动库存行')
return false
}
if (main.inventory.reduce((total, line) => total + Number(line.availablePackages || 0), 0) <= 1) {
blocker(context, 'P4解绑后主托盘可继续验证',
'主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱')
return false
}
let auxiliary = await resolveReusableAuxiliaryPallet(context, token, fixture)
if (!auxiliary) {
blocker(context, 'P4辅助实物托盘', '本轮入仓计划没有可复用的已有空托盘')
return false
}
const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
reason: `${context.marker}-swap` }
const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP')
await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`,
{ ...swapBody, reason: `${swapBody.reason}-DIFFERENT` })
let occupiedSwap = operationPallet(swapped.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4换托后解析目标托盘')).data
let emptyMain = operationPallet(swapped.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data
await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, {
...swapBody, idempotencyKey: key(context, 'PALLET-SWAP-STALE'),
source: { ...swapBody.source }, target: { ...swapBody.target },
})
const swapBackBody = { idempotencyKey: key(context, 'PALLET-SWAP-BACK'),
source: { palletCode: occupiedSwap.palletCode, expectedVersion: Number(occupiedSwap.palletVersion) },
target: { palletCode: emptyMain.palletCode, expectedVersion: Number(emptyMain.palletVersion) },
reason: `${context.marker}-swap-back` }
await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP')
main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托前刷新已有空托盘')).data
const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const onePackage = 1
const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
lines: [{ positionId: id(sourceLine.positionId), packages: onePackage,
expectedPositionVersion: Number(sourceLine.positionVersion) }] }],
reason: `${context.marker}-disassemble` }
const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘',
`${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE')
const splitOccupied = operationPallet(disassembled.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托后解析辅助托盘')).data
const mainAfterSplit = operationPallet(disassembled.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data
const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0)
const assembleBody = { idempotencyKey: key(context, 'PALLET-ASSEMBLE'),
target: { palletCode: mainAfterSplit.palletCode, expectedVersion: Number(mainAfterSplit.palletVersion) },
sources: [{ palletCode: splitOccupied.palletCode, expectedVersion: Number(splitOccupied.palletVersion) }],
lines: [{ positionId: id(splitLine.positionId), packages: Number(splitLine.availablePackages),
expectedPositionVersion: Number(splitLine.positionVersion) }],
reason: `${context.marker}-assemble` }
await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE')
main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4解绑前刷新已有空托盘')).data
const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
lines: [{ positionId: id(unbindSource.positionId), packages: 1,
expectedPositionVersion: Number(unbindSource.positionVersion) }] }],
reason: `${context.marker}-unbind-prepare` }
const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本',
`${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE')
const boundAuxiliary = operationPallet(prepared.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4解析解绑样本托盘')).data
const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'),
source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) },
reason: `${context.marker}-unbind` }
const unbound = await replayOperation(context, token, 'P4解绑辅助托盘库存',
`${base}/unbind`, unbindBody, 'PALLET_UNBIND')
context.assert(MODULE, 'P4主托盘保留可继续验证状态且辅助托盘解绑完成', unbound.response,
unbound.data?.status === 'COMPLETED'
&& operationPallet(unbound.data, boundAuxiliary.palletCode)?.status === 'IDLE',
trace(unbound.response), OPTIONS)
return success(unbound.response)
}
export function selectPhysicalRelabelSources(candidates, preferredBalanceId) {
const ordered = [...candidates].sort((a, b) =>
Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId)))
const sourceLines = []
const seen = new Set()
for (const source of ordered) {
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
for (const position of source.positions || []) {
const positionId = id(position?.positionId)
if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo)
|| position.palletNo.toUpperCase() === 'UNBOUND') continue
seen.add(positionId)
const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages))
if (!Number.isInteger(selected) || selected <= 0) continue
for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 })
if (sourceLines.length === 3) return sourceLines
}
}
return sourceLines
}
async function verifyRelabel(context, token, merchantToken, fixture) {
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|| `PDA-${context.marker}`
const inventory = fixture.inventory.find((item) => nonBlank(item?.skuCode))
if (!customerId || !inventory?.skuCode || !fixture.overseasWarehouseId || !nonBlank(merchantToken)) {
blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU、海外仓或客户令牌事实')
return false
}
if (!await fundRelabelWallet(context, customerId)) return false
// P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets,
// and may combine only the current completed container's same-destination inbounds.
const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || [])
2026-09-24 02:58:40 +08:00
.filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType
&& item.fbaWarehouseCode === fixture.fbaWarehouseCode)
.map((item) => item.inboundCode).filter(nonBlank))]
const inventoryCandidates = []
let inventoryCandidatesResponse = null
for (const inboundCode of inboundCodes) {
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
}), undefined, {}, merchantToken)
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
if (!success(inventoryCandidatesResponse)) return false
inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) =>
item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId)))
}
const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId)
const requiredPackages = 3 - sourceLines.length
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
requiredPackages === 0,
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
if (requiredPackages !== 0) {
blocker(context, 'P5本轮库存换标来源', '客户库存候选未返回可组合三箱的权威余额、位置和来源入库单', inventoryCandidatesResponse)
return false
}
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
const merchantRelabelBase = '/api/merchant/v1/last-leg/relabel-applications'
const pdaBase = '/api/pda/v1/last-leg/relabel-tasks'
const markerSuffix = String(context.marker).slice(-8)
const createBody = {
customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId),
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
idempotencyKey: key(context, 'RELABEL-CREATE'),
2026-09-14 09:58:30 +08:00
deliveryMethod: 'TRUCK', printShippingLabel: false,
items: sourceLines.map(({ source, position, packageCount }, index) => {
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode)
return {
sourceInventoryBalanceId: id(source.inventoryBalanceId),
sourceInventoryPositionId: id(position.positionId), sourceInboundOrderId: id(source.inboundOrderId),
skuCode: source.skuCode, productNameZh: source.productNameZh || inventory.productName || '本轮库存商品',
totalPackageCount: packageCount, notifiedPackageCount: packageCount,
shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128),
newSkuCode,
labelRequirement: context.marker,
cartonMark: `CM-${String(context.marker).slice(-28)}-${index + 1}`,
fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null,
destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null,
operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL',
skuItems: [{ skuCode: source.skuCode, newSkuCode, quantity: packageCount, remark: context.marker }],
}
}),
}
const createdResponse = await context.requestOverseas('POST',
query(relabelBase, { customerId }), createBody)
const created = success(createdResponse) ? createdResponse.body?.data : null
const relabelNo = id(created?.application?.relabelNo)
context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse,
success(createdResponse) && nonBlank(relabelNo)
&& created?.application?.status === 'PENDING_PROCESSING',
`relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS)
if (!relabelNo) {
blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse)
return false
}
const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
(response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN')
context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result,
funded.result?.body?.data?.application?.walletStatus === 'FROZEN',
`pollRequests=${funded.attempts}, walletStatus=${funded.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(funded.result)}`, OPTIONS)
if (funded.result?.body?.data?.application?.walletStatus !== 'FROZEN') return false
const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`)
context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response,
Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo),
`relabelNo=${relabelNo}; ${trace(candidates.response)}`, OPTIONS)
const available = await request(context, token, 'GET', 'P5读取换标任务详情',
`${pdaBase}/${encodeURIComponent(relabelNo)}`)
const claimBody = { idempotencyKey: key(context, 'RELABEL-CLAIM') }
const claimed = await request(context, token, 'POST', 'P5认领换标任务',
`${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody)
const claimReplay = await request(context, token, 'POST', 'P5认领换标同键重放',
`${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody)
context.assert(MODULE, 'P5认领重放保持PROCESSING', claimReplay.response,
claimReplay.data?.taskStatus === 'PROCESSING' && claimed.data?.taskStatus === 'PROCESSING',
trace(claimReplay.response), OPTIONS)
let detail = claimed.data || available.data
const item = relabelItemForUnit(detail, 0)
if (!id(item?.itemId)) {
blocker(context, 'P5换标明细', '认领后的换标任务没有真实明细', claimed.response)
return false
}
const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units`
2026-09-20 19:57:47 +08:00
if (item.operationType === 'CARTON_AND_SKU') {
const carton = await request(context, token, 'POST', 'P5正常换标先确认箱标阶段',
`${pdaBase}/${encodeURIComponent(relabelNo)}/cartons/scan`, {
cartonMark: item.cartonMark, expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-NORMAL-CARTON'),
})
if (!success(carton.response)) return false
detail = carton.data
}
const scanBody = { itemId: id(item.itemId), oldLabel: item.cartonMark,
newLabel: item.fbaCartonMark || item.newSkuCode,
2026-09-20 19:57:47 +08:00
skuVerifications: (item.skuItems || []).map(sku => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-NORMAL') }
const scanned = await request(context, token, 'POST', 'P5正常换标一箱', unitPath, scanBody)
const scanReplay = await request(context, token, 'POST', 'P5正常换标同键重放', unitPath, scanBody)
context.assert(MODULE, 'P5正常换标重放不重复计数', scanReplay.response,
Number(scanReplay.data?.relabelledPackages) === 1, trace(scanReplay.response), OPTIONS)
await expectConflict(context, token, 'POST', 'P5正常换标同键异载荷A0409', unitPath,
{ ...scanBody, oldLabel: `${context.marker}-OLD-DIFFERENT` })
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
detail = scanned.data
if (!detail) return false
const skipItem = Array.isArray(detail?.items)
? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null
if (!skipItem) {
blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response)
return false
}
const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark,
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-SKIP') }
const skipped = await request(context, token, 'POST', 'P5跳过换标一箱', `${unitPath}/skip`, skipBody)
const skipReplay = await request(context, token, 'POST', 'P5跳过换标同键重放', `${unitPath}/skip`, skipBody)
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
detail = skipped.data
if (!detail) return false
const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId])
if (!exceptionItem) {
blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response)
return false
}
const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null,
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
const exception = await request(context, token, 'POST', 'P5登记换标异常一箱',
`${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody)
const exceptionReplay = await request(context, token, 'POST', 'P5换标异常同键重放',
`${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody)
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
detail = exception.data
if (!detail) return false
const photo = await context.requestOverseasMultipart('POST',
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG,
}, token)
const photoId = id(photo?.body?.data?.fileId)
context.addStep(MODULE, 'P5上传换标完成照片', photo,
success(photo) && /^\d+$/.test(photoId) && photoId !== '0',
`fileId=${photoId || 'missing'}; ${trace(photo)}`, OPTIONS)
if (!photoId) return false
const completePath = `${pdaBase}/${encodeURIComponent(relabelNo)}/complete`
const completeBody = { photoFileIds: [photoId], expectedVersion: Number(detail.version),
2026-09-20 19:57:47 +08:00
manualCompletion: true, idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` }
const completed = await request(context, token, 'POST', 'P5异常手动完成换标进入WAIT_REVIEW', completePath, completeBody)
if (!success(completed.response)) return false
const completeReplay = await request(context, token, 'POST', 'P5首次完成同键重放', completePath, completeBody)
context.assert(MODULE, 'P5首次完成重放保持WAIT_REVIEW', completeReplay.response,
completeReplay.data?.taskStatus === 'WAIT_REVIEW', trace(completeReplay.response), OPTIONS)
2026-09-20 19:57:47 +08:00
const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework`
const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version),
idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` }
2026-09-20 19:57:47 +08:00
const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody)
if (!success(reworked.response)) return false
const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response,
2026-09-20 19:57:47 +08:00
reworkReplay.data?.taskStatus === 'PROCESSING' && Number(reworkReplay.data.relabelledPackages) === 0,
trace(reworkReplay.response), OPTIONS)
detail = reworked.data
for (const redoItem of detail.items || []) {
if (redoItem.operationType === 'CARTON_AND_SKU') {
const carton = await request(context, token, 'POST', 'P5返工重新确认箱标阶段',
`${pdaBase}/${encodeURIComponent(relabelNo)}/cartons/scan`, {
cartonMark: redoItem.cartonMark, expectedVersion: Number(detail.version),
idempotencyKey: key(context, `RELABEL-REDO-CARTON-${redoItem.itemId}`),
})
if (!success(carton.response)) return false
detail = carton.data
}
const redo = await request(context, token, 'POST', 'P5返工重新完成SKU阶段', unitPath, {
itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark,
newLabel: redoItem.fbaCartonMark || redoItem.newSkuCode,
2026-09-20 19:57:47 +08:00
skuVerifications: (redoItem.skuItems || []).map(sku => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
expectedVersion: Number(detail.version), idempotencyKey: key(context, `RELABEL-REDO-SKU-${redoItem.itemId}`),
})
if (!success(redo.response)) return false
detail = redo.data
}
const bindingResponse = await context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`)
const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null
if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false
2026-09-14 09:58:30 +08:00
const operationBase = '/api/pda/v1/last-leg/pallet-operations'
const printKeyA = key(context, 'RELABEL-PRINT-A')
const printedA = await request(context, token, 'POST', 'P5打印换标新托盘A',
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
const printedAReplay = await request(context, token, 'POST', 'P5换标新托盘A打印同键重放',
`${operationBase}/relabel-pallet-label`, { relabelNo, idempotencyKey: printKeyA })
context.assert(MODULE, 'P5新托盘标签幂等且返回可打印HTML', printedAReplay.response,
id(printedA.data?.palletId) === id(printedAReplay.data?.palletId)
&& printedA.data?.palletCode === printedAReplay.data?.palletCode
&& String(printedA.data?.printableHtml || '').includes(printedA.data?.palletCode || '__missing__'),
trace(printedAReplay.response), OPTIONS)
const printedB = await request(context, token, 'POST', 'P5打印换标新托盘B',
`${operationBase}/relabel-pallet-label`, {
relabelNo, idempotencyKey: key(context, 'RELABEL-PRINT-B'),
})
if (!printedA.data?.palletCode || !printedB.data?.palletCode) return false
const targets = [printedA.data, printedB.data]
const beforeBySource = new Map()
const movedFacts = []
2026-09-20 19:57:47 +08:00
const sourceCodes = bindingDetail.items.map((itemDetail) => {
2026-09-14 17:45:56 +08:00
const sourcePositionId = id(itemDetail?.sourceInventoryPositionId)
return sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)?.position?.palletNo || ''
})
const sourceFrequency = sourceCodes.reduce((counts, sourceCode) => {
if (sourceCode) counts.set(sourceCode, (counts.get(sourceCode) || 0) + 1)
return counts
}, new Map())
const splitSource = [...sourceFrequency.entries()].find(([, count]) => count >= 2)?.[0] || ''
let splitSourceMoves = 0
2026-09-20 19:57:47 +08:00
for (const [index, itemDetail] of bindingDetail.items.entries()) {
2026-09-14 09:58:30 +08:00
const sourcePositionId = id(itemDetail?.sourceInventoryPositionId)
const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)
const sourcePalletCode = sourceLine?.position?.palletNo
if (!sourcePalletCode || !itemDetail?.cartonMark) {
2026-09-20 19:57:47 +08:00
blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, bindingResponse)
2026-09-14 09:58:30 +08:00
return false
}
const sourceBefore = (await resolvePallet(context, token, sourcePalletCode,
`P5拆托${index + 1}前刷新来源托盘`)).data
if (!sourceBefore) return false
if (!beforeBySource.has(sourcePalletCode)) beforeBySource.set(sourcePalletCode, Number(sourceBefore.totalPackages))
2026-09-14 17:45:56 +08:00
// Split one repeated source across A and B; all other cartons reuse occupied B as the merge target.
const targetSeed = sourcePalletCode === splitSource && splitSourceMoves++ === 0 ? targets[0] : targets[1]
2026-09-14 09:58:30 +08:00
const targetBefore = (await resolvePallet(context, token, targetSeed.palletCode,
`P5拆托${index + 1}前刷新目标托盘`)).data
const reason = index === 0 ? null : `${context.marker}-场景${index + 1}`
const body = {
idempotencyKey: key(context, `RELABEL-DISASSEMBLE-${index + 1}`), relabelNo,
source: { palletCode: sourceBefore.palletCode, expectedVersion: Number(sourceBefore.palletVersion) },
target: { palletCode: targetBefore.palletCode, expectedVersion: Number(targetBefore.palletVersion) },
cartonMarks: [itemDetail.cartonMark], reason,
}
const moved = await request(context, token, 'POST', `P5换标箱${index + 1}拆托`,
`${operationBase}/relabel-disassemble`, body)
const replay = await request(context, token, 'POST', `P5换标箱${index + 1}拆托同键重放`,
`${operationBase}/relabel-disassemble`, body)
const sourceAfter = operationPallet(moved.data, sourceBefore.palletCode)
const targetAfter = operationPallet(moved.data, targetBefore.palletCode)
context.assert(MODULE, `P5换标拆托${index + 1}源减目标增且重放稳定`, replay.response,
moved.data?.operationType === 'RELABEL_PALLET_DISASSEMBLE' && replay.data?.replayed === true
&& Number(sourceAfter?.totalPackages) === Number(sourceBefore.totalPackages) - 1
&& Number(targetAfter?.totalPackages) === Number(targetBefore.totalPackages) + 1,
`source=${sourceBefore.totalPackages}->${sourceAfter?.totalPackages}, target=${targetBefore.totalPackages}->${targetAfter?.totalPackages}; ${trace(replay.response)}`,
OPTIONS)
if (!success(moved.response)) return false
movedFacts.push({ sourcePalletCode, targetPalletId: id(targetAfter?.palletId),
targetPalletCode: targetAfter?.palletCode, cartonMark: itemDetail.cartonMark,
fbaCartonMark: itemDetail.fbaCartonMark, balanceId: id(itemDetail.sourceInventoryBalanceId),
inboundOrderId: id(itemDetail.sourceInboundOrderId), inboundOrderNo: itemDetail.sourceInboundOrderNo,
inboundCode: itemDetail.sourceInboundCode, packages: Number(itemDetail.notifiedPackageCount), reason })
}
for (const [sourcePalletCode, totalBefore] of beforeBySource) {
const sourceAfter = (await resolvePallet(context, token, sourcePalletCode, 'P5拆托后核对来源托盘')).data
const movedPackages = movedFacts.filter((fact) => fact.sourcePalletCode === sourcePalletCode)
.reduce((sum, fact) => sum + fact.packages, 0)
context.assert(MODULE, 'P5来源托盘累计箱数按拆托批次减少', null,
Number(sourceAfter?.totalPackages) === totalBefore - movedPackages,
`pallet=${sourcePalletCode}, before=${totalBefore}, moved=${movedPackages}, after=${sourceAfter?.totalPackages}`, OPTIONS)
}
context.assert(MODULE, 'P5覆盖新托盘、既有托盘合托和同源分批拆托', null,
new Set(movedFacts.map((fact) => fact.targetPalletId)).size === 2
&& movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length >= 2
&& [...beforeBySource.keys()].some((source) => new Set(movedFacts.filter((fact) => fact.sourcePalletCode === source)
.map((fact) => fact.targetPalletId)).size >= 2),
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
for (const fact of movedFacts) {
const positionsResponse = await context.requestOverseas('GET', query(
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
{ customerId }), undefined)
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
? positionsResponse.body.data : []
const targetPosition = positions.find((position) => id(position?.palletId) === fact.targetPalletId)
context.addStep(MODULE, `P5换标箱${fact.cartonMark}保留客户和原入仓归属`, positionsResponse,
id(targetPosition?.customerId) === customerId
&& id(targetPosition?.inboundOrderId) === fact.inboundOrderId
&& targetPosition?.inboundOrderNo === fact.inboundOrderNo
&& targetPosition?.inboundCode === fact.inboundCode,
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
}
const ledgerResponse = await context.requestOverseas('GET', query(
'/api/platform/v1/last-leg/inventory/ledger', {
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
}))
const ledgerRows = success(ledgerResponse) ? records(ledgerResponse.body?.data) : []
const relabelLedgers = ledgerRows.filter((row) => movedFacts.some((fact) =>
row?.palletNo === fact.sourcePalletCode || row?.palletNo === fact.targetPalletCode))
context.addStep(MODULE, 'P5库存流水记录换标拆托原因和正负变动', ledgerResponse,
relabelLedgers.some((row) => Number(row?.deltaPackages) < 0)
&& relabelLedgers.some((row) => Number(row?.deltaPackages) > 0)
&& relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')),
`rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS)
2026-09-20 19:57:47 +08:00
const afterBinding = await request(context, token, 'GET', 'P5组托后刷新任务版本',
`${pdaBase}/${encodeURIComponent(relabelNo)}`)
if (!success(afterBinding.response)) return false
const finalBody = { photoFileIds: [photoId], expectedVersion: Number(afterBinding.data.version),
idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` }
const approved = await request(context, token, 'POST', 'P5返工组托后提交完工等待客户确认', completePath, finalBody)
if (!success(approved.response) || approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false
const finalReplay = await request(context, token, 'POST', 'P5返工完工同键重放', completePath, finalBody)
context.assert(MODULE, 'P5返工完工重放保持待客户确认', finalReplay.response,
finalReplay.data?.taskStatus === 'WAIT_REVIEW' && finalReplay.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING',
trace(finalReplay.response), OPTIONS)
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
remark: `${context.marker}-customer-confirmed` }
const confirmedResponse = await context.requestOverseas('POST',
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
&& confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS)
if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false
2026-09-14 09:58:30 +08:00
context.createdResources.phase4OverseasRelabel = {
marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId),
targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))],
relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank),
sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND',
}
return success(approved.response)
}
export async function settleRelabelFulfillment(context, relabel) {
const relabelNo = relabel.relabelNo
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
2026-09-11 01:18:01 +08:00
const terminal = await context.requestOverseas('PUT',
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
})
context.addStep(MODULE, 'P5待出库换标单完成履约', terminal,
success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS)
const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
(response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED')
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
`pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS)
relabel.status = terminal.body?.data?.application?.status || relabel.status
return success(terminal) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
}
export async function run(context) {
const handoff = context.context?.phase4OverseasPdaOperations
const token = handoff?.primary?.token
const merchantToken = handoff?.merchantToken
try {
if (handoff?.marker !== context.marker || !nonBlank(token)
|| !Array.isArray(handoff.completedFixtures) || handoff.completedFixtures.length === 0) {
blocker(context, 'P2-P5本轮PDA收货交接',
'phase4-overseas-pda未交接本轮primary picker会话与已完成库存事实')
return
}
2026-09-14 17:45:56 +08:00
const fixturesByAuxiliaryCapacity = [...handoff.completedFixtures]
.sort((left, right) => (right?.auxiliaryPallets?.length || 0) - (left?.auxiliaryPallets?.length || 0))
// P4/P5 deliberately consumes and rearranges one multi-carton source. Keep the
// ABE8 producer untouched when the matrix contains both destinations so the
// following dispatch module can still observe RECEIVED candidates for ABE4+ABE8.
const fixture = fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA'
&& item?.fbaWarehouseCode === 'ABE4'
&& (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory)
&& item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3)
|| fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA'
&& (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory)
&& item.inventory.reduce((sum, line) => sum + Number(line?.availablePackages || 0), 0) > 3)
|| fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'OVERSEAS'
2026-09-24 02:58:40 +08:00
&& (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 1))
|| fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA'
2026-09-24 02:04:00 +08:00
&& item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 1))
2026-09-24 02:04:00 +08:00
|| fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA'
&& item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 0))
2026-09-24 01:48:24 +08:00
|| fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 1))
2026-09-14 17:45:56 +08:00
|| fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory)
&& item.inventory.some((line) => Number(line?.availablePackages) > 0))
if (!fixture) {
blocker(context, 'P2-P5可操作库存', '本轮已完成柜没有可移动的真实库存位置')
return
}
fixture.overseasWarehouseId = id(handoff.overseasWarehouseId)
2026-09-20 19:57:47 +08:00
await verifyReceivedOrderHold(context)
await verifyStocktake(context, token, fixture)
await verifyInventoryMove(context, token, fixture)
await verifyPalletOperations(context, token, fixture)
await verifyRelabel(context, token, merchantToken, fixture)
} finally {
try {
if (typeof handoff?.cleanup === 'function') await handoff.cleanup()
} finally {
if (handoff?.primary) delete handoff.primary.token
if (handoff?.secondary) delete handoff.secondary.token
if (handoff) delete handoff.merchantToken
delete context.context.phase4OverseasPdaOperations
}
}
}