Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs
T
2026-10-22 11:56:47 +08:00

830 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.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
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 || [])
.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.request('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: RELABEL_CHARGE_AMOUNT, remark: context.marker,
idempotencyKey: key(context, 'RELABEL-CREATE'),
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: nonBlank(source.cartonMark) ? source.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.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
}
}
}