test(firstleg): extend coload group acceptance

This commit is contained in:
2026-08-30 15:55:48 +08:00
parent bfcb80b326
commit 9a15e78b2e
2 changed files with 142 additions and 35 deletions
@@ -12,6 +12,12 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'/push-preload',
'创建同键同载荷精确重放',
'创建同键异载荷返回并发冲突',
'不同目的仓港仍可创建同柜不可拆组',
'跨国内仓或跨渠道创建整组被拒绝且不落组',
'批查只返回组内订单且长ID保持字符串',
'/recommend',
'智能推荐容量足够时整组全取',
'智能推荐不会只取同柜组部分成员或部分箱数',
'组成员不能单独调整箱数',
'组成员不能单独删除',
'整组原子移除',
@@ -26,6 +32,11 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'足量完成整组已消费、全部入库完成且不生成余量子单',
'未成组普通单推送预装不回归',
'未成组普通单仍按既有规则生成唯一余量子单',
'已有父子拆分订单创建同柜组被拒绝',
'拆分余量子单仍可与同仓同渠道订单创建同柜组',
'拆分身份不影响当前WAIT_SPLIT片段完整纳组',
'拆分余量建组不改写既有父子数量事实',
]) assert.match(source, new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
})
+130 -34
View File
@@ -313,7 +313,7 @@ async function pollCompletedTask(context, taskId, warehouseId, label) {
return { response, task }
}
async function createDifferentRouteFixture(context, base) {
async function createRouteVariantFixture(context, base, variant) {
const orders = await import(new URL('./orders.mjs', import.meta.url))
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
const originalMarker = context.marker
@@ -327,10 +327,10 @@ async function createDifferentRouteFixture(context, base) {
const originalDetailCount = context.context.phase3LoadingIncrementalDetailCount
const originalPackageCount = context.context.phase3LoadingIncrementalPackageCount
try {
for (let attempt = 1; attempt <= 3; attempt += 1) {
context.marker = `${originalMarker}-ROUTE-${attempt}`
for (let attempt = 1; attempt <= 6; attempt += 1) {
context.marker = `${originalMarker}-${variant === 'DESTINATION' ? 'DEST' : 'ROUTE'}-${attempt}`
context.context.orderProfile = {
destinationType: attempt === 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD',
destinationType: variant === 'DESTINATION' && attempt >= 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD',
pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false,
}
context.context.orderDestinationType = context.context.orderProfile.destinationType
@@ -345,6 +345,7 @@ async function createDifferentRouteFixture(context, base) {
context.context.phase3LoadingIncrementalPackageCount = 1
context.context.waitSplitRoute = {
originWarehouseName: base.receivingWarehouseName || '', randomize: true,
channelCode: variant === 'DESTINATION' ? base.channelCode : '',
randomIndex: 50 + attempt * 31, destinationIndex: 80 + attempt * 37, taxModeIndex: attempt,
}
await orders.run(context)
@@ -361,12 +362,15 @@ async function createDifferentRouteFixture(context, base) {
packages: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
processStatus: inbound.processStatus,
}
const differs = candidate.warehouseId !== base.warehouseId
|| candidate.channelCode !== base.channelCode
|| candidate.destinationType !== base.destinationType
const sameLoadingRoute = candidate.warehouseId === base.warehouseId
&& candidate.channelCode === base.channelCode
const differentDestination = candidate.destinationType !== base.destinationType
|| candidate.destinationWarehouseId !== id(base.destinationWarehouseId)
|| candidate.destinationWarehouseCode !== id(base.destinationWarehouseCode)
if (candidate.orderId && candidate.inboundId && candidate.processStatus === 'WAIT_SPLIT' && differs) return candidate
const matched = variant === 'DESTINATION'
? sameLoadingRoute && differentDestination
: !sameLoadingRoute
if (candidate.orderId && candidate.inboundId && candidate.processStatus === 'WAIT_SPLIT' && matched) return candidate
}
return null
} finally {
@@ -392,17 +396,16 @@ export async function run(context) {
}))
const usable = fixtures.filter((item) => item.orderId && item.inboundId && item.tenantId
&& item.warehouseId && item.channelCode && item.packages > 0)
context.assert(MODULE, '取得至少七票本轮 WAIT_SPLIT 新数据', null, usable.length >= 7,
context.assert(MODULE, '取得至少八票本轮 WAIT_SPLIT 新数据', null, usable.length >= 8,
`count=${usable.length}, marker=${context.marker}`, OPTIONS)
if (usable.length < 7) return
if (usable.length < 8) return
const [first, second, third, fourth, fifth, sixth, seventh] = usable
const [first, second, third, fourth, fifth, sixth, seventh, eighth] = usable
const sameRoute = [second, third, fourth, fifth, sixth, seventh].every((item) => item.warehouseId === first.warehouseId
&& item.channelCode === first.channelCode && item.destinationType === first.destinationType
&& id(item.destinationWarehouseId) === id(first.destinationWarehouseId)
&& id(item.destinationWarehouseCode) === id(first.destinationWarehouseCode))
context.assert(MODULE, '正向样本属于同一仓库渠道和目的仓港', null, sameRoute,
`warehouse=${first.warehouseId}, channel=${first.channelCode}, destination=${first.destinationType}/${id(first.destinationWarehouseId) || id(first.destinationWarehouseCode)}`,
&& item.channelCode === first.channelCode)
&& eighth.warehouseId === first.warehouseId && eighth.channelCode === first.channelCode
context.assert(MODULE, '正向样本只要求同一国内仓和渠道', null, sameRoute,
`warehouse=${first.warehouseId}, channel=${first.channelCode}`,
OPTIONS)
if (!sameRoute) return
@@ -434,16 +437,43 @@ export async function run(context) {
{ orderIds: [first.orderId, third.orderId] }, undefined, { 'Idempotency-Key': createKey })
conflict(context, '创建同键异载荷返回并发冲突', changedPayloadResponse)
const differentRoute = await createDifferentRouteFixture(context, first)
context.assert(MODULE, '创建本轮不同仓库渠道或目的仓港反向样本', null, Boolean(differentRoute),
const differentDestination = await createRouteVariantFixture(context, first, 'DESTINATION')
context.assert(MODULE, '创建同国内仓同渠道但不同目的仓港的正向样本', null, Boolean(differentDestination),
differentDestination
? `orderId=${differentDestination.orderId}, warehouse=${differentDestination.warehouseId}, channel=${differentDestination.channelCode}, destination=${differentDestination.destinationType}/${differentDestination.destinationWarehouseId || differentDestination.destinationWarehouseCode}`
: '六次正式下单均未取得跨目的仓样本,未借用历史订单', OPTIONS)
if (differentDestination) {
const crossDestinationResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
{ orderIds: [fifth.orderId, differentDestination.orderId] }, undefined,
{ 'Idempotency-Key': `${context.marker}-coload-cross-destination`.slice(0, 128) })
const crossDestinationGroup = context.apiSuccess(MODULE, '不同目的仓港仍可创建同柜不可拆组',
crossDestinationResponse, evidence(crossDestinationResponse), OPTIONS)
const crossDestinationFacts = memberFacts(crossDestinationGroup)
context.assert(MODULE, '跨目的仓组完整冻结两票当前入仓片段', crossDestinationResponse,
crossDestinationFacts.length === 2
&& [fifth.inboundId, differentDestination.inboundId]
.every((inboundId) => crossDestinationFacts.some((member) => member.inboundId === inboundId)),
`members=${JSON.stringify(crossDestinationFacts)}`, OPTIONS)
if (crossDestinationGroup?.groupId) {
const cleanupResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(crossDestinationGroup.groupId)}/cancel`, {
expectedVersion: Number(crossDestinationGroup.version), reason: '释放跨目的仓正向验收组成员',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cross-destination-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '跨目的仓正向组整组取消并释放成员', cleanupResponse,
evidence(cleanupResponse), OPTIONS)
}
}
const differentRoute = await createRouteVariantFixture(context, first, 'LOADING_ROUTE')
context.assert(MODULE, '创建本轮不同国内仓或渠道反向样本', null, Boolean(differentRoute),
differentRoute
? `orderId=${differentRoute.orderId}, warehouse=${differentRoute.warehouseId}, channel=${differentRoute.channelCode}, destination=${differentRoute.destinationType}/${differentRoute.destinationWarehouseId || differentRoute.destinationWarehouseCode}`
: '三次正式下单均未取得不同线路,未借用历史订单', OPTIONS)
? `orderId=${differentRoute.orderId}, warehouse=${differentRoute.warehouseId}, channel=${differentRoute.channelCode}`
: '六次正式下单均未取得不同国内仓或渠道,未借用历史订单', OPTIONS)
if (differentRoute) {
const routeConflictResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
{ orderIds: [third.orderId, differentRoute.orderId] }, undefined,
{ 'Idempotency-Key': `${context.marker}-coload-route-conflict`.slice(0, 128) })
conflict(context, '跨仓库渠道或目的仓港创建整组被拒绝且不落组', routeConflictResponse,
conflict(context, '跨国内仓或跨渠道创建整组被拒绝且不落组', routeConflictResponse,
`baseOrder=${third.orderId}, mismatchOrder=${differentRoute.orderId}`)
const zeroDriftResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
{ orderIds: [third.orderId, differentRoute.orderId] })
@@ -459,7 +489,8 @@ export async function run(context) {
evidence(queryResponse), OPTIONS) || []
context.assert(MODULE, '批查只返回组内订单且长ID保持字符串', queryResponse,
queried.length === 2 && queried.every((item) => groupOrders.includes(id(item?.orderId))
&& id(item?.group?.groupId) === groupId && typeof item?.group?.groupId === 'string'),
&& id(item?.group?.groupId) === groupId && typeof item?.group?.groupId === 'string'
&& JSON.stringify(memberFacts(item?.group)) === JSON.stringify(frozen)),
`count=${queried.length}, orderIds=${queried.map((item) => id(item?.orderId)).join(',')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
@@ -530,9 +561,9 @@ export async function run(context) {
`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),
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/recommend`, {
expectedVersion: Number(plan?.version),
operationId: `${context.marker}-coload-capacity-recommend`.slice(0, 128),
})
const capacityRejected = recommendResponse?.http === 200 && code(recommendResponse) === 'A0422'
&& String(recommendResponse?.body?.message || '').includes('同柜不可拆组整体超过货柜')
@@ -544,6 +575,44 @@ export async function run(context) {
planSignature(beforeRecommend) === planSignature(afterRecommend.data),
`before=${planSignature(beforeRecommend)}, after=${planSignature(afterRecommend.data)}`, OPTIONS)
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
const recommendBookingId = id(recommendSimulation?.id)
if (!recommendBookingId) return
const recommendPlanResponse = await context.request('POST', `${SMART}/plans`, {
bookingId: recommendBookingId, remark: `${context.marker} 智能推荐整组全取`,
})
let recommendPlan = context.apiSuccess(MODULE, '创建智能推荐整组成功专用计划', recommendPlanResponse,
evidence(recommendPlanResponse), OPTIONS)
const recommendPlanId = id(recommendPlan?.id)
if (!recommendPlanId) return
const recommendSuccessResponse = await context.request('POST',
`${SMART}/plans/${encodeURIComponent(recommendPlanId)}/recommend`, {
expectedVersion: Number(recommendPlan?.version),
operationId: `${context.marker}-coload-success-recommend`.slice(0, 128),
})
recommendPlan = context.apiSuccess(MODULE, '智能推荐容量足够时整组全取', recommendSuccessResponse,
evidence(recommendSuccessResponse, `groupId=${groupId}`), OPTIONS)
const recommendedTotals = new Map()
for (const item of recommendPlan?.items || []) {
recommendedTotals.set(id(item?.inboundId),
(recommendedTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0))
}
context.assert(MODULE, '智能推荐不会只取同柜组部分成员或部分箱数', recommendSuccessResponse,
[first, second].every((fixture) => recommendedTotals.get(fixture.inboundId) === fixture.packages),
`groupId=${groupId}, inboundTotals=${JSON.stringify(Object.fromEntries(recommendedTotals))}`, OPTIONS)
const recommendRemoveResponse = await context.request('DELETE',
`${SMART}/plans/${encodeURIComponent(recommendPlanId)}/group-assignments/${encodeURIComponent(groupId)}`, {
expectedPlanVersion: Number(recommendPlan?.version),
operationId: `${context.marker}-coload-success-recommend-remove`.slice(0, 128),
})
context.apiSuccess(MODULE, '智能推荐整组验收后原子移除并释放组锁', recommendRemoveResponse,
evidence(recommendRemoveResponse), OPTIONS)
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
@@ -847,19 +916,45 @@ export async function run(context) {
&& Number(normalTree?.currentRemainderPackageCount) === Number(seventh.packages) - Number(normalChain.assignedCount),
`children=${normalTree?.children?.length ?? 0}, remainderOrderId=${id(remainder?.orderId) || 'missing'}`, OPTIONS)
if (!remainder?.orderId) return
const splitTreeBeforeReject = treeSignature(normalTree)
const splitTreeBeforeGroup = treeSignature(normalTree)
const splitGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups',
{ orderIds: [seventh.orderId, id(remainder.orderId)] }, undefined,
{ 'Idempotency-Key': `${context.marker}-coload-split-reject`.slice(0, 128) })
conflict(context, '已有父子拆分订单创建同柜组被拒绝', splitGroupResponse,
`rootOrderId=${seventh.orderId}, childOrderId=${id(remainder.orderId)}`)
{ orderIds: [id(remainder.orderId), eighth.orderId] }, undefined,
{ 'Idempotency-Key': `${context.marker}-coload-split-allow`.slice(0, 128) })
const splitGroup = context.apiSuccess(MODULE, '拆分余量子单仍可与同仓同渠道订单创建同柜组',
splitGroupResponse, evidence(splitGroupResponse,
`rootOrderId=${seventh.orderId}, remainderOrderId=${id(remainder.orderId)}`), OPTIONS)
const splitMembers = memberFacts(splitGroup)
context.assert(MODULE, '拆分身份不影响当前WAIT_SPLIT片段完整纳组', splitGroupResponse,
splitMembers.length >= 2
&& splitMembers.some((item) => item.orderId === id(remainder.orderId))
&& splitMembers.some((item) => item.orderId === eighth.orderId)
&& splitMembers.every((item) => item.fullPackageCount > 0),
`members=${JSON.stringify(splitMembers)}`, OPTIONS)
const splitBatchResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups/batch-query',
{ orderIds: [seventh.orderId, id(remainder.orderId), eighth.orderId] })
const splitBatch = context.apiSuccess(MODULE, '拆分组按订单批查返回完整成员', splitBatchResponse,
evidence(splitBatchResponse), OPTIONS) || []
context.assert(MODULE, '批查只关联当前余量子单和同组订单且每行含完整members', splitBatchResponse,
splitBatch.length === 2 && splitBatch.every((item) =>
[id(remainder.orderId), eighth.orderId].includes(id(item?.orderId))
&& id(item?.group?.groupId) === id(splitGroup?.groupId)
&& JSON.stringify(memberFacts(item?.group)) === JSON.stringify(splitMembers)),
`rows=${splitBatch.map((item) => id(item?.orderId)).join('|')}, members=${splitMembers.length}`, OPTIONS)
const splitTreeAfterResponse = await context.request('GET',
`/api/platform/v1/orders/${encodeURIComponent(seventh.orderId)}/fulfillment-tree?tenantId=${encodeURIComponent(seventh.tenantId)}`)
const splitTreeAfter = context.apiSuccess(MODULE, '父子订单建组拒绝后回读履约树', splitTreeAfterResponse,
const splitTreeAfter = context.apiSuccess(MODULE, '拆分余量建组后回读根履约树', splitTreeAfterResponse,
evidence(splitTreeAfterResponse), OPTIONS)
context.assert(MODULE, '父子订单建组拒绝后原普通拆分事实零漂移', splitTreeAfterResponse,
splitTreeBeforeReject === treeSignature(splitTreeAfter),
`treeStable=${splitTreeBeforeReject === treeSignature(splitTreeAfter)}`, OPTIONS)
context.assert(MODULE, '拆分余量建组不改写既有父子数量事实', splitTreeAfterResponse,
splitTreeBeforeGroup === treeSignature(splitTreeAfter),
`treeStable=${splitTreeBeforeGroup === treeSignature(splitTreeAfter)}`, OPTIONS)
if (splitGroup?.groupId) {
const splitCancelResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(splitGroup.groupId)}/cancel`, {
expectedVersion: Number(splitGroup.version), reason: '完成拆分余量正向验收',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-split-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '拆分余量正向组整组取消', splitCancelResponse,
evidence(splitCancelResponse), OPTIONS)
}
context.createdResources.coloadGroupAcceptance = {
marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId,
@@ -867,6 +962,7 @@ export async function run(context) {
fullGroupId: secondGroupId, fullTaskId: fullPda.taskId, fullPlanId: fullChain.planId,
partialTaskId: partialPda.taskId, normalTaskId: normalPda.taskId,
oceanBookingId, cancelledGroupId: cancelGroupId, remainderOrderId: id(remainder.orderId),
splitGroupId: id(splitGroup?.groupId), crossDestinationOrderId: id(differentDestination?.orderId),
publicBypassResult: 'individual assignment/adjust/delete and short-pick completion rejected; full group consumed',
}
}