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) } 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 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) { const response = await context.requestOverseas(method, path, body, {}, token) 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, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } async function createLocation(context, warehouseId, suffix) { const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64) const response = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`, }) const locationId = id(response?.body?.data) context.addStep(MODULE, `创建${suffix}目标库位`, response, success(response) && /^\d+$/.test(locationId) && locationId !== '0', `locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS) return success(response) ? { locationId, locationCode } : null } async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) { const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120) const response = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId: fixture.location.locationId, fbaWarehouseCode: destinationWarehouseCode || null, remark: `${context.marker}-pda-operations-${suffix}`, }) context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS) if (!success(response)) return null return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data } 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 createLocation(context, fixture.overseasWarehouseId, 'MOVE') 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 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 } const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode) const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode) const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode) if (!swapTarget || !splitTarget || !unbindTarget) { blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘') return false } const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'), source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.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, swapTarget.palletCode) || (await resolvePallet(context, token, swapTarget.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 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: splitTarget.palletCode, expectedVersion: Number(splitTarget.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, splitTarget.palletCode) || (await resolvePallet(context, token, splitTarget.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 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: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.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, unbindTarget.palletCode) || (await resolvePallet(context, token, unbindTarget.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) } async function verifyRelabel(context, token, 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) { blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实') return false } if (!await fundRelabelWallet(context, customerId)) return false const relabelBase = '/api/platform/v1/last-leg/relabel-applications' const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128) const createBody = { customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: 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'), items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), newSkuCode: newSku, labelRequirement: context.marker, cartonMark: `CM-${String(context.marker).slice(-32)}`, fbaCartonMark: `FBA-${String(context.marker).slice(-32)}` }], } 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 = detail?.items?.[0] if (!id(item?.itemId)) { blocker(context, 'P5换标明细', '认领后的换标任务没有真实明细', claimed.response) return false } const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units` const scanBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-1`, newLabel: newSku, 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 const skipBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-2`, 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 const exceptionBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-3`, 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 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), idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` } const completed = await request(context, token, 'POST', 'P5首次完成换标进入WAIT_REVIEW', completePath, completeBody) 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 = `${pdaBase}/${encodeURIComponent(relabelNo)}/review` const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data?.version), idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } const reworked = await request(context, token, 'POST', 'P5复核退回REWORK', reviewPath, reworkBody) const reworkReplay = await request(context, token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response, reworkReplay.data?.taskStatus === 'PROCESSING', trace(reworkReplay.response), OPTIONS) const secondCompleteBody = { photoFileIds: [photoId], expectedVersion: Number(reworked.data?.version), idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` } const secondCompleted = await request(context, token, 'POST', 'P5返工后再次完成WAIT_REVIEW', completePath, secondCompleteBody) const secondReplay = await request(context, token, 'POST', 'P5再次完成同键重放', completePath, secondCompleteBody) context.assert(MODULE, 'P5再次完成重放保持WAIT_REVIEW', secondReplay.response, secondReplay.data?.taskStatus === 'WAIT_REVIEW', trace(secondReplay.response), OPTIONS) const approveBody = { decision: 'APPROVE', expectedVersion: Number(secondCompleted.data?.version), idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` } const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody) const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody) context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response, approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), 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) return success(approved.response) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED' } export async function run(context) { const handoff = context.context?.phase4OverseasPdaOperations const token = handoff?.primary?.token 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 fixture = handoff.completedFixtures.find((item) => Array.isArray(item?.inventory) && item.inventory.some((line) => Number(line?.availablePackages) > 1)) || handoff.completedFixtures.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 verifyStocktake(context, token, fixture) await verifyInventoryMove(context, token, fixture) await verifyPalletOperations(context, token, fixture) await verifyRelabel(context, token, 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 delete context.context.phase4OverseasPdaOperations } } }