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