Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
This commit is contained in:
2026-09-09 03:00:48 +08:00
20 changed files with 383 additions and 290 deletions
@@ -163,29 +163,42 @@ export async function disablePicker(context, picker, index) {
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
}
async function createPallet(context, inbound, index) {
const warehouseId = id(inbound?.overseasWarehouseId)
if (!longId(warehouseId)) return null
const suffix = markerSuffix(context)
const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64)
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: `PDA-${index + 1}`, locationCode,
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
})
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
const locationId = id(location?.body?.data)
if (!longId(locationId)) return null
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128)
const unitPrice = Number((1.01 + index).toFixed(2))
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice, status: 'IDLE', locationId,
remark: `${context.marker}-receiving-retained`,
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
})
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
const palletId = id(pallet?.body?.data)
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, unitPrice, retained: true } : null
function rememberGeneratedPlans(context, plans) {
if (!Array.isArray(plans)) return
for (const plan of plans) {
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
if (!nonBlank(inboundCode)) continue
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inboundCode] = generated.map((pallet) => ({
warehouseId: id(plan?.overseasWarehouseId),
locationId: id(pallet?.warehouseLocationId),
palletId: id(pallet?.warehousePalletId),
palletCode: id(pallet?.palletNo),
unitPrice: Number(pallet?.unitPrice || 0),
retained: true,
}))
}
}
async function resolveGeneratedPallet(context, inbound) {
const candidates = context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inbound.inboundCode] || []
const pallet = candidates[0]
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
const listed = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
const locations = stored && longId(stored?.locationId)
? await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
: null
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
context.addStep(MODULE, `复用入仓码${inbound.inboundCode}自动计划托盘`, listed, resolved,
`palletCode=${pallet.palletCode}, palletId=${pallet.palletId}, locationId=${id(stored?.locationId) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(listed)}`, OPTIONS)
if (!resolved) return null
return { ...pallet, warehouseId, locationId: id(stored.locationId), locationCode: location.locationCode,
unitPrice: Number(stored.unitPrice || pallet.unitPrice || 0) }
}
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
@@ -203,6 +216,14 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === pallet.locationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
@@ -212,22 +233,6 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
const palletDetail = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(pallet.warehouseId)}/pallets/${encodeURIComponent(pallet.palletId)}`)
context.addStep(MODULE, '完成入库后读取托盘计费单价', palletDetail,
success(palletDetail) && Number(palletDetail.body?.data?.unitPrice) > 0,
`palletId=${pallet.palletId}, unitPrice=${palletDetail.body?.data?.unitPrice ?? 'missing'}; ${trace(palletDetail)}`, OPTIONS)
if (Number(palletDetail.body?.data?.unitPrice) > 0) pallet.unitPrice = Number(palletDetail.body.data.unitPrice)
const locationCode = resolvedPallet.data?.locationCode || pallet.locationCode
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: locationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === locationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史',
'/api/pda/v1/last-leg/operations?page=1&size=50')
const historyRows = records(history.data)
@@ -314,64 +319,59 @@ async function convertReceivingContainers(context, producers) {
convertible,
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
if (!convertible) return false
const previewResponse = await context.requestOverseas('GET',
`/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(summary.sourceContainerBookingId)}`,
const bookingId = id(summary.sourceContainerBookingId)
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
const previewResponse = await context.requestOverseas('GET', previewPath,
undefined, {}, context.overseasToken)
const preview = success(previewResponse) ? previewResponse.body?.data : null
const previewReady = preview?.ready === true
&& Number.isInteger(Number(preview?.snapshotVersion)) && Number(preview.snapshotVersion) >= 0
&& /^[0-9a-f]{64}$/.test(String(preview?.snapshotHash || ''))
&& Number(preview?.pendingOrderCount) === pendingPlans
context.addStep(MODULE, `预览整柜原子转换快照 ${containerNo}`, previewResponse,
previewReady,
`ready=${preview?.ready === true}, version=${preview?.snapshotVersion ?? 'missing'}, hash=${nonBlank(preview?.snapshotHash) ? 'present' : 'missing'}, pending=${preview?.pendingOrderCount ?? 'missing'}; ${trace(previewResponse)}`, OPTIONS)
if (!previewReady) return false
const body = {
sourceContainerBookingId: id(summary.sourceContainerBookingId),
snapshotVersion: Number(preview.snapshotVersion),
snapshotHash: String(preview.snapshotHash),
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
&& Number(preview?.pendingOrderCount) === pendingPlans,
`orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
OPTIONS)
if (preview?.ready !== true) return false
if (pendingPlans > 0) {
const body = { sourceContainerBookingId: bookingId,
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) }
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
const convertedResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
? convertedResponse.body.data : []
const convertedInboundCodes = new Set(plans
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
.map((line) => id(line?.inboundCode)).filter(nonBlank))
context.addStep(MODULE, `整柜转换为逐入仓码独立计划及实体托盘 ${containerNo}`, convertedResponse,
plans.length === inboundCodes
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0
&& Array.isArray(item?.pallets) && item.pallets.length > 0
&& item.pallets.every((pallet) => nonBlank(pallet?.palletNo) && longId(id(pallet?.warehousePalletId))))
&& convertedInboundCodes.size === inboundCodes,
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
if (plans.length !== inboundCodes) return false
rememberGeneratedPlans(context, plans)
const replayResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
trace(replayResponse), OPTIONS)
if (!success(replayResponse)) return false
} else {
const retainedPlans = context.createdResources?.phase4TailOrderControls?.generatedPlans
rememberGeneratedPlans(context, retainedPlans)
context.addStep(MODULE, `复用尾程控制已生成的整柜计划与实体托盘 ${containerNo}`, previewResponse,
summary?.allPlansReady === true && existingPlans === totalOrders
&& Number(preview?.existingPlannedPalletCount) > 0,
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
OPTIONS)
}
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
const convertedResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
? convertedResponse.body.data : []
const convertedInboundCodes = new Set(plans
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
.map((line) => id(line?.inboundCode)).filter(nonBlank))
const reservedPalletsByInbound = {}
for (const plan of plans) {
const reserved = Array.isArray(plan?.pallets) ? plan.pallets.find((item) =>
longId(item?.warehousePalletId) && longId(item?.warehouseLocationId)
&& longId(item?.warehouseResourceLockId)
&& ['RESERVED', 'ASSIGNED', 'LOCKED'].includes(item?.assignmentStatus)) : null
for (const line of Array.isArray(plan?.lines) ? plan.lines : []) {
if (reserved && nonBlank(line?.inboundCode)) reservedPalletsByInbound[id(line.inboundCode)] = {
palletId: id(reserved.warehousePalletId), palletCode: reserved.palletNo,
locationId: id(reserved.warehouseLocationId), retained: true,
}
}
}
context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse,
plans.length === inboundCodes
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0)
&& convertedInboundCodes.size === inboundCodes,
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, reserved=${Object.keys(reservedPalletsByInbound).length}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
if (plans.length !== inboundCodes || convertedInboundCodes.size !== inboundCodes) return false
producer.receivingPalletsByInbound = reservedPalletsByInbound
const replayResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
trace(replayResponse), OPTIONS)
if (!success(replayResponse)) return false
const progressResponse = await context.requestOverseas('GET',
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
undefined, {}, context.overseasToken)
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
context.addStep(MODULE, `整柜转换后计划完整度闭环 ${containerNo}`, progressResponse,
context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
&& Number(progress?.pendingPlanCount) === 0,
`allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS)
@@ -636,19 +636,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
const pallets = new Map()
for (const [index, inbound] of current.data.inbounds.entries()) {
const reservedPallet = preferredProducer?.receivingPalletsByInbound?.[inbound.inboundCode]
const pallet = reservedPallet || await createPallet(context, inbound, index)
if (reservedPallet) context.addStep(MODULE, `${inbound.destinationType}复用整柜转换预留托盘`, null,
longId(reservedPallet.palletId) && nonBlank(reservedPallet.palletCode)
&& longId(reservedPallet.locationId),
`inboundCode=${inbound.inboundCode}, palletCode=${reservedPallet.palletCode}`, OPTIONS)
const pallet = await resolveGeneratedPallet(context, inbound)
if (!pallet) {
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`,
`按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, 'warehouse-resource')
continue
}
pallets.set(inbound.inboundCode, pallet)
if (!reservedPallet) context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
@@ -700,6 +695,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
@@ -712,14 +715,6 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
})
}
}
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
}
}
@@ -729,6 +724,7 @@ export async function run(context) {
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
completedContainerFacts: [],
overseasWarehouseId: null, overseasWarehouseName: null,
generatedPalletsByInbound: {},
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
}
const merchantToken = await loginMerchantForNegativeProbe(context)
@@ -756,7 +752,6 @@ export async function run(context) {
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
overseasWarehouseId: sharedWarehouse.id,
merchantToken,
completedFixtures: [],
cleanup,
}
@@ -773,7 +768,7 @@ export async function run(context) {
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
if (producers.length && !(await convertReceivingContainers(context, producers))) {
blockerGap(context, '实际处理柜整柜转换门禁',
'目标柜未能通过正式平台convert-container逐入仓码生成独立计划')
'目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘')
return
}
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {