fix: align HTTP acceptance fixtures and pallet permissions

This commit is contained in:
2026-09-14 12:40:40 +08:00
parent e55ab73e61
commit a2ea0ba9e6
15 changed files with 205 additions and 50 deletions
@@ -87,7 +87,7 @@ async function expectConflict(context, token, method, name, path, body) {
}
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
return request(context, token, 'POST', name,
return request(context, token, 'POST', `${name}(${palletCode})`,
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
@@ -103,6 +103,11 @@ async function resolvePublicLocation(context, fixture) {
? { 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
@@ -328,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) {
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
@@ -338,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
return false
}
if (!await fundRelabelWallet(context, customerId)) return false
const inventoryCandidatesResponse = await context.requestOverseas('GET', query(
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode,
}), undefined, {}, merchantToken)
const inventoryCandidates = success(inventoryCandidatesResponse)
&& Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : []
const orderedCandidates = [...inventoryCandidates].sort((left, right) => {
const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
return rightPreferred - leftPreferred
})
const sourceLines = []
let requiredPackages = 3
for (const source of orderedCandidates) {
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
for (const position of source?.positions || []) {
const available = Number(position?.availablePackages)
if (!id(position?.positionId) || available <= 0) continue
const selected = Math.min(requiredPackages, available)
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
sourceLines.push({ source, position, packageCount: 1 })
}
requiredPackages -= selected
if (requiredPackages === 0) break
}
if (requiredPackages === 0) break
// 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)
@@ -452,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
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') }
@@ -462,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
detail = skipped.data
const exceptionItem = item
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') }
@@ -473,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
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`, {