diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs index 726ddb3b6..60edfcefb 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -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, /已有父子拆分订单创建同柜组被拒绝/) }) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index fdc696dfc..8078bac41 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -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', } }