This commit is contained in:
2026-08-29 11:59:11 +08:00
parent 0605c32829
commit 81ef43c9fd
2 changed files with 99 additions and 97 deletions
@@ -9,21 +9,21 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'/api/platform/v1/first-leg/coload-groups/batch-query',
'/group-assignments',
'/inbound-assignments',
'/recommend',
'/push-preload',
'创建同键同载荷精确重放',
'创建同键异载荷返回并发冲突',
'组成员不能单独调整箱数',
'组成员不能单独删除',
'整组原子移除',
'智能推荐对整组全取或全不取',
'目标同柜组整体超容时智能推荐明确拒绝',
'整组容量不足时推荐事务零漂移',
'推荐候选仅含当前目标同柜组且成员身份精确',
'ACTIVE同柜组整组取消成功',
'同柜组PDA原子批次记录部分拣货异常',
'同柜组任一成员短拣时完成被拒绝',
'短拣完成拒绝后任务、父子树和组锁零漂移',
'同柜组足量完成命令可靠受理',
'足量完成整组已消费、全部入库完成且不生成子单',
'足量完成整组已消费、全部入库完成且不生成余量子单',
'未成组普通单推送预装不回归',
'未成组普通单仍按既有规则生成唯一余量子单',
'已有父子拆分订单创建同柜组被拒绝',
@@ -79,8 +79,8 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS)
@@ -109,8 +109,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS)
@@ -168,8 +168,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
const draft = oceanBookingDraft(context, suffix, master, {
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
containerRequirements: [{ containerType: '20GP', containerKind: 'FCL', quantity: chains.length,
capacityCbm: 33, maxWeightKg: 28000 }],
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length,
capacityCbm: 76, maxWeightKg: 28000 }],
})
const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: draft }, undefined,
@@ -183,7 +183,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
evidence(releaseResponse), OPTIONS)
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '20GP')
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC')
context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse,
released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length,
`status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS)
@@ -286,7 +286,7 @@ async function uploadCompletionEvidence(context, fixture, label) {
const bindResponse = await context.request('POST',
`/api/platform/v1/inbounds/${fixture.inboundId}/loading/evidence?tenantId=${encodeURIComponent(fixture.tenantId)}`, {
expectedVersion: 0, evidenceType, fileId,
sealNo: evidenceType === 'SEAL' ? `SEAL-${context.marker}`.slice(0, 64) : null,
sealNo: evidenceType === 'SEAL' ? `SEAL-${context.marker}-${label}`.slice(0, 64) : null,
remark: context.marker,
}, undefined, { 'Idempotency-Key': `${context.marker}-${label}-${evidenceType}`.slice(0, 128) })
const bound = context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 柜级证据`, bindResponse,
@@ -466,16 +466,16 @@ export async function run(context) {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '创建本轮组装柜模拟柜', simulationResponse,
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
evidence(simulationResponse), OPTIONS)
const bookingId = id(simulation?.id)
let bookingId = id(simulation?.id)
if (!bookingId) return
const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', {
bookingId, remark: `${context.marker} 同柜不可拆组验收`,
})
let plan = context.apiSuccess(MODULE, '创建同柜组装柜计划', planResponse,
let plan = context.apiSuccess(MODULE, '创建容量拒绝专用空计划', planResponse,
evidence(planResponse, `bookingId=${bookingId}`), OPTIONS)
const planId = id(plan?.id)
let planId = id(plan?.id)
if (!planId) return
const candidateResponse = await context.request('GET',
@@ -488,6 +488,78 @@ export async function run(context) {
&& Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true),
`count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS)
let isolatedCandidates = candidates
const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
.map((item) => id(item?.coLoadGroupId)).filter((value) => value && value !== groupId))]
for (const competingGroupId of competing) {
const competingOrders = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === competingGroupId)
.map((item) => id(item?.orderId)).filter(Boolean)
const competingQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
{ orderIds: competingOrders })
const competingQuery = context.apiSuccess(MODULE, '清理前回读旧验收活动组', competingQueryResponse,
evidence(competingQueryResponse, `groupId=${competingGroupId}`), OPTIONS) || []
const competingGroup = competingQuery.find((item) => id(item?.group?.groupId) === competingGroupId)?.group
if (competingGroup?.status === 'ACTIVE') {
const cleanupResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(competingGroupId)}/cancel`, {
expectedVersion: Number(competingGroup.version), reason: '清理旧HTTP验收活动组以隔离容量候选',
}, undefined, { 'Idempotency-Key': `${context.marker}-cleanup-${competingGroupId}`.slice(0, 128) })
context.apiSuccess(MODULE, '通过公开接口取消旧验收活动组', cleanupResponse,
evidence(cleanupResponse, `groupId=${competingGroupId}`), OPTIONS)
}
}
let isolatedCandidateResponse = candidateResponse
if (competing.length > 0) {
isolatedCandidateResponse = await context.request('GET',
`${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`)
isolatedCandidates = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse,
evidence(isolatedCandidateResponse), OPTIONS) || []
}
const candidateGroupIds = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))]
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.map((item) => id(item?.inboundId)).sort()
context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
const targetCandidateVolume = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
context.assert(MODULE, '目标组自身总体积明确超过20GP容量容差', isolatedCandidateResponse,
targetCandidateVolume > 33 * 1.1,
`targetGroupId=${groupId}, volume=${targetCandidateVolume}, limit=${33 * 1.1}`, OPTIONS)
const beforeRecommend = plan
const recommendResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, {
inboundId: first.inboundId, expectedVersion: Number(plan?.version),
operationId: `${context.marker}-coload-capacity-assign`.slice(0, 128),
})
const capacityRejected = recommendResponse?.http === 200 && code(recommendResponse) === 'A0422'
&& String(recommendResponse?.body?.message || '').includes('同柜不可拆组整体超过货柜')
context.assert(MODULE, '目标同柜组整体超容时智能推荐明确拒绝', recommendResponse,
capacityRejected,
evidence(recommendResponse, `targetGroupId=${groupId}, members=${[first.inboundId, second.inboundId].join('|')}, message=${recommendResponse?.body?.message || 'none'}`), OPTIONS)
const afterRecommend = await readPlan(context, planId, '回读整组容量拒绝后计划')
context.assert(MODULE, '整组容量不足时推荐事务零漂移', afterRecommend.response,
planSignature(beforeRecommend) === planSignature(afterRecommend.data),
`before=${planSignature(beforeRecommend)}, after=${planSignature(afterRecommend.data)}`, OPTIONS)
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
evidence(workingSimulationResponse), OPTIONS)
bookingId = id(workingSimulation?.id)
if (!bookingId) return
const workingPlanResponse = await context.request('POST', `${SMART}/plans`, {
bookingId, remark: `${context.marker} 同柜不可拆组PDA主链`,
})
plan = context.apiSuccess(MODULE, '创建PDA主链同柜装柜计划', workingPlanResponse,
evidence(workingPlanResponse, `bookingId=${bookingId}`), OPTIONS)
planId = id(plan?.id)
if (!planId) return
const beforeIndividual = await readPlan(context, planId, '读取单票绕过前计划快照')
const individualResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/inbound-assignments`, {
@@ -569,84 +641,13 @@ export async function run(context) {
activeQuery.length === 2 && activeQuery.every((item) => item?.group?.status === 'ACTIVE'
&& item?.group?.lockedPlanId == null),
`status=${activeQuery[0]?.group?.status || 'missing'}, lockedPlanId=${id(activeQuery[0]?.group?.lockedPlanId) || 'null'}`, OPTIONS)
let isolatedCandidateResponse = await context.request('GET',
`${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`)
let isolatedCandidates = context.apiSuccess(MODULE, '推荐前隔离读取目标组候选', isolatedCandidateResponse,
evidence(isolatedCandidateResponse), OPTIONS) || []
const competing = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
.map((item) => id(item?.coLoadGroupId)).filter((value) => value && value !== groupId))]
for (const competingGroupId of competing) {
const competingOrders = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === competingGroupId)
.map((item) => id(item?.orderId)).filter(Boolean)
const competingQueryResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
{ orderIds: competingOrders })
const competingQuery = context.apiSuccess(MODULE, '清理前回读旧验收活动组', competingQueryResponse,
evidence(competingQueryResponse, `groupId=${competingGroupId}`), OPTIONS) || []
const competingGroup = competingQuery.find((item) => id(item?.group?.groupId) === competingGroupId)?.group
if (competingGroup?.status === 'ACTIVE') {
const cleanupResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(competingGroupId)}/cancel`, {
expectedVersion: Number(competingGroup.version), reason: '清理旧HTTP验收活动组以隔离容量候选',
}, undefined, { 'Idempotency-Key': `${context.marker}-cleanup-${competingGroupId}`.slice(0, 128) })
context.apiSuccess(MODULE, '通过公开接口取消旧验收活动组', cleanupResponse,
evidence(cleanupResponse, `groupId=${competingGroupId}`), OPTIONS)
}
}
if (competing.length > 0) {
isolatedCandidateResponse = await context.request('GET',
`${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(first.warehouseId)}`)
isolatedCandidates = context.apiSuccess(MODULE, '清理后重新隔离读取目标组候选', isolatedCandidateResponse,
evidence(isolatedCandidateResponse), OPTIONS) || []
}
const candidateGroupIds = [...new Set(isolatedCandidates.filter((item) => item?.locked !== true)
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))]
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.map((item) => id(item?.inboundId)).sort()
context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
const targetCandidateVolume = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
context.assert(MODULE, '目标组自身总体积明确超过20GP容量容差', isolatedCandidateResponse,
targetCandidateVolume > 33 * 1.1,
`targetGroupId=${groupId}, volume=${targetCandidateVolume}, limit=${33 * 1.1}`, OPTIONS)
const beforeRecommend = plan
const recommendResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, {
expectedVersion: Number(plan?.version), operationId: `${context.marker}-coload-recommend`.slice(0, 128),
const manualGroupResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, {
inboundId: first.inboundId, expectedVersion: Number(plan?.version),
operationId: `${context.marker}-coload-reassign`.slice(0, 128),
})
const recommendSucceeded = recommendResponse?.http === 200 && code(recommendResponse) === '200'
const capacityRejected = recommendResponse?.http === 200 && code(recommendResponse) === 'A0422'
&& String(recommendResponse?.body?.message || '').includes('同柜不可拆组整体超过货柜')
context.assert(MODULE, '目标同柜组整体超容时智能推荐明确拒绝', recommendResponse,
capacityRejected,
evidence(recommendResponse, `targetGroupId=${groupId}, members=${[first.inboundId, second.inboundId].join('|')}, message=${recommendResponse?.body?.message || 'none'}`), OPTIONS)
plan = recommendSucceeded ? recommendResponse.body.data : beforeRecommend
const recommendedIds = new Set((plan?.items || []).map((item) => id(item?.inboundId)))
const recommendedMembers = [first.inboundId, second.inboundId].filter((value) => recommendedIds.has(value))
context.assert(MODULE, '智能推荐对整组全取或全不取', recommendResponse,
recommendedMembers.length === 0 || recommendedMembers.length === 2,
`recommendedMembers=${recommendedMembers.join(',') || 'none'}`, OPTIONS)
if (capacityRejected) {
const afterRecommend = await readPlan(context, planId, '回读整组容量拒绝后计划')
context.assert(MODULE, '整组容量不足时推荐事务零漂移', afterRecommend.response,
planSignature(beforeRecommend) === planSignature(afterRecommend.data),
`before=${planSignature(beforeRecommend)}, after=${planSignature(afterRecommend.data)}`, OPTIONS)
plan = afterRecommend.data
}
if (recommendedMembers.length === 0) {
const manualGroupResponse = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/group-assignments`, {
inboundId: first.inboundId, expectedVersion: Number(plan?.version),
operationId: `${context.marker}-coload-reassign`.slice(0, 128),
})
plan = context.apiSuccess(MODULE, '推荐容量未纳入时整组手工回配', manualGroupResponse,
evidence(manualGroupResponse), OPTIONS)
}
plan = context.apiSuccess(MODULE, '整组移除后再次完整回配', manualGroupResponse,
evidence(manualGroupResponse), OPTIONS)
if (!plan) return
const pushKey = `${context.marker}-coload-push`.slice(0, 128)
@@ -720,7 +721,7 @@ export async function run(context) {
const partialBatchItems = (partialPda.task.items || []).map((item) => id(item.id) === id(partialTarget.id)
? { itemId: id(item.id), action: 'PARTIAL', pickedPackageCount: null,
finalPickedPackageCount: Number(item.plannedPackageCount) - 1, releaseMode: 'MANUAL',
locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣异常验收' }
locationIds: [], version: Number(item.version), reason: null }
: { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [],
version: Number(item.version), reason: null })
@@ -807,12 +808,13 @@ export async function run(context) {
evidence(response), OPTIONS)
completedInboundStates.push(inbound?.summary?.processStatus)
}
context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成子单', consumedGroupResponse,
const fullRemainders = fullTreesAfter.flatMap((item) => item?.tree?.children || [])
.filter((item) => item?.splitRole === 'REMAINDER')
context.assert(MODULE, '足量完成整组已消费、全部入库完成且不生成余量子单', consumedGroupResponse,
consumedGroup.length === 2 && consumedGroup.every((item) => item?.group?.status === 'CONSUMED')
&& completedInboundStates.every((status) => status === 'LOADED')
&& fullTreesBefore.every((before, index) => (fullTreesAfter[index]?.tree?.children || []).length === 0
&& before.signature === fullTreesAfter[index]?.signature),
`groupStatus=${consumedGroup[0]?.group?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, children=${fullTreesAfter.map((item) => item.tree?.children?.length ?? -1).join('|')}`, OPTIONS)
&& fullRemainders.length === 0,
`groupStatus=${consumedGroup[0]?.group?.status || 'missing'}, inboundStates=${completedInboundStates.join('|')}, remainderChildren=${fullRemainders.length}, childRoles=${fullTreesAfter.flatMap((item) => item?.tree?.children || []).map((item) => item?.splitRole).join('|') || 'none'}`, OPTIONS)
const normalTreeBefore = await fulfillmentTrees(context, normalChain.fixtures, '普通单完成前')
const normalBatchItems = (normalPda.task?.items || []).map((item) => ({