import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs' const MODULE = 'phase4-overseas-pda-operations' export const RELABEL_CHARGE_AMOUNT = 0.01 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) } 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.request(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 } } export 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 if (context.options?.fixedCustomerId) { const sufficient = account.availableBalance != null && Number.isFinite(Number(account.availableBalance)) && Number(account.availableBalance) >= RELABEL_CHARGE_AMOUNT context.addStep(MODULE, '复用已有可用余额;不足时禁止自动充值', accountResponse, sufficient, `availableBalance=${account.availableBalance ?? 'missing'}`, OPTIONS) return sufficient } 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) { let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { response = await context.request(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, sourceLocationCode) { const response = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`) const locations = records(response?.body?.data) const location = locations.find((item) => item?.locationType === 'SHARED_OVERFLOW' && item?.locationCode === 'A0' && item?.status === 'ACTIVE' && item.locationCode !== sourceLocationCode) || locations.find((item) => ['STORAGE', 'NORMAL'].includes(item?.locationType) && item?.status === 'IDLE' && item?.locationCode !== sourceLocationCode) 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, sourceLocationType: locations.find((item) => item.locationCode === sourceLocationCode)?.locationType } : 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) { 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 const target = await resolvePublicLocation(context, fixture, originalLocationCode) if (!target) return false 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) if (!success(moved.response)) return false 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, expectedPalletVersion: Math.max(0, body.expectedPalletVersion - 1), 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` } if (target.sourceLocationType === 'VIRTUAL') { const rejected = await expectConflict(context, token, 'POST', 'P3禁止回移到虚拟收货位', path, restoreBody) const retained = (await resolvePallet(context, token, pallet.palletCode, 'P3拒绝回移后仍在上架库位')).data context.assert(MODULE, 'P3虚拟位回移失败不改变托盘和库存', rejected, conflict(rejected) && retained?.locationCode === target.locationCode && Number(retained?.palletVersion) === Number(pallet.palletVersion) && Number(retained?.totalPackages) === Number(pallet.totalPackages), trace(rejected), OPTIONS) return conflict(rejected) && retained?.locationCode === target.locationCode } 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') if (!success(swapped.response)) return false 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') if (!success(disassembled.response)) return false 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 const usedByPosition = new Map() for (const carton of source.cartons || []) { const position = (source.positions || []).find(row => id(row.positionId) === id(carton.positionId)) const positionId = id(position?.positionId) const cartonKey = `${id(source.inboundOrderId)}:${carton.cartonMark}` if (!carton.cartonMark || seen.has(cartonKey) || !positionId || !nonBlank(position?.palletNo) || position.palletNo.toUpperCase() === 'UNBOUND') continue const used = usedByPosition.get(positionId) || 0 if (used >= Number(position.availablePackages)) continue seen.add(cartonKey); usedByPosition.set(positionId, used + 1) sourceLines.push({ source, position, carton, 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 const boxed = await readCurrentBoxedInventory(context, merchantToken, fixture.overseasWarehouseId, MODULE) // 同一次收货产生的真实有箱库存;优先选一个具备三箱来源的入仓单,避免借用历史库存。 const candidate = boxed.find(row => selectPhysicalRelabelSources([row]).length === 3) const inventoryCandidates = candidate ? [candidate] : [] const inventoryCandidatesResponse = null 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: RELABEL_CHARGE_AMOUNT, remark: context.marker, idempotencyKey: key(context, 'RELABEL-CREATE'), deliveryMethod: 'TRUCK', printShippingLabel: false, items: sourceLines.map(({ source, position, carton, 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: carton.cartonMark, fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null, destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null, operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL', skuItems: carton.items.map(item => ({ skuCode: item.skuCode, newSkuCode, quantity: Number(item.quantity), remark: context.marker })), } }), } const createdResponse = await context.request('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_REVIEW', `relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS) if (!relabelNo) { blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) return false } const reviewed = await request(context, context.token, 'PUT', 'P5尾程订单审核直接贴标申请', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.application.lastlegOrderId))}/relabel-review`, { customerId }), { status: 'PENDING_PROCESSING', remark: context.marker }) if (!success(reviewed.response) || reviewed.data?.application?.status !== 'PENDING_PROCESSING') return false const funded = await poll(async () => context.request('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` 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, 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.requestMultipart('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), 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) const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework` const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version), idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } const reworked = await request(context, context.token, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody) if (!success(reworked.response)) return false const reworkReplay = await request(context, context.token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) context.assert(MODULE, 'P5 REWORK重放保持AVAILABLE等待重新认领', reworkReplay.response, reworkReplay.data?.taskStatus === 'AVAILABLE' && Number(reworkReplay.data.relabelledPackages) === 0, trace(reworkReplay.response), OPTIONS) const reclaimed = await request(context, token, 'POST', 'P5重新认领返工任务', `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, { idempotencyKey: key(context, 'RELABEL-REWORK-CLAIM') }) if (!success(reclaimed.response) || reclaimed.data?.taskStatus !== 'PROCESSING') return false detail = reclaimed.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, 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.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`) const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false 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 = [] const sourceCodes = bindingDetail.items.map((itemDetail) => { 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 for (const [index, itemDetail] of bindingDetail.items.entries()) { const sourcePositionId = id(itemDetail?.sourceInventoryPositionId) const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId) const sourcePalletCode = sourceLine?.position?.palletNo if (!sourcePalletCode || !itemDetail?.cartonMark) { blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, bindingResponse) 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)) // 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] 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.request('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.request('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) 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.request('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 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' const terminal = await context.request('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.request('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 } 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' && /-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || '')) && (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?.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' && (item?.auxiliaryPallets?.length || 0) > 0 && Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 1)) || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' && item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 1)) || fixturesByAuxiliaryCapacity.find((item) => item?.destinationType === 'FBA' && item?.fbaWarehouseCode === 'ABE4' && Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 0)) || fixturesByAuxiliaryCapacity.find((item) => Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 1)) || 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) await verifyReceivedOrderHold(context) await verifyStocktake(context, token, fixture) await verifyInventoryMove(context, token, fixture) await verifyPalletOperations(context, token, fixture) await verifyRelabel(context, token, merchantToken, fixture) await verifyPdaWarehouseRevocation(context, handoff.primary) } finally { try { if (typeof handoff?.cleanup === 'function') await handoff.cleanup() } finally { for (const picker of [handoff?.primary, handoff?.secondary]) if (picker) { delete picker.token delete picker.refreshToken delete picker.password delete picker.userUpdate } if (handoff) delete handoff.merchantToken delete context.context.phase4OverseasPdaOperations } } }