From 81ef43c9fd5f255b379e3a802a26558add361656 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 11:59:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coload-groups-contract.test.mjs | 6 +- .../admin-test-env-modules/coload-groups.mjs | 190 +++++++++--------- 2 files changed, 99 insertions(+), 97 deletions(-) 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 f70fbbedc..726ddb3b6 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -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原子批次记录部分拣货异常', '同柜组任一成员短拣时完成被拒绝', '短拣完成拒绝后任务、父子树和组锁零漂移', '同柜组足量完成命令可靠受理', - '足量完成整组已消费、全部入库完成且不生成子单', + '足量完成整组已消费、全部入库完成且不生成余量子单', '未成组普通单推送预装不回归', '未成组普通单仍按既有规则生成唯一余量子单', '已有父子拆分订单创建同柜组被拒绝', diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 109780a88..fdc696dfc 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -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) => ({