487 lines
32 KiB
JavaScript
487 lines
32 KiB
JavaScript
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
|
|
}
|
|
}
|
|
}
|