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 fa19df95c..e838c2dc6 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -21,11 +21,11 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '组成员不能单独调整箱数', '组成员不能单独删除', '整组原子移除', - '目标同柜组整体超容时智能推荐跳过整组', - '整组容量不足时智能推荐全不取且不产生部分成员', + '容量竞争时智能推荐不因整组容量失败', + '容量竞争时同柜组只能整组全取或全不取', '推荐候选仅含当前目标同柜组且成员身份精确', 'ACTIVE同柜组整组取消成功', - '同柜组PDA原子批次记录部分拣货异常', + '同柜组PDA原子批次记录短拣异常', '同柜组任一成员短拣时完成被拒绝', '短拣完成拒绝后任务、父子树和组锁零漂移', '同柜组足量完成命令可靠受理', @@ -35,6 +35,8 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '拆分余量子单仍可与同仓同渠道订单创建同柜组', '拆分身份不影响当前WAIT_SPLIT片段完整纳组', '拆分余量建组不改写既有父子数量事实', + '为Admin页面保留ACTIVE未分配跨目的仓组', + '页面验收保留组仍为ACTIVE且未锁定', ]) assert.match(source, new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 83dad88cb..589ea8a54 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -516,8 +516,7 @@ export async function run(context) { const groupedCandidates = candidates.filter((item) => id(item?.coLoadGroupId) === groupId) context.assert(MODULE, '每个组成员候选携带相同整组事实', candidateResponse, groupedCandidates.length === 2 && groupedCandidates.every((item) => Number(item?.memberCount) === 2 - && Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true - && String(item?.blockReason || '').includes('同柜不可拆组整体超过当前柜剩余容量')), + && Number(item?.groupTotalBoxes) === first.packages + second.packages && item?.locked !== true), `count=${groupedCandidates.length}, groupTotal=${first.packages + second.packages}`, OPTIONS) let isolatedCandidates = candidates @@ -555,23 +554,30 @@ export async function run(context) { 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) + const allCandidateVolume = isolatedCandidates .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) + context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse, + allCandidateVolume > 33 * 1.1, + `targetGroupId=${groupId}, candidateVolume=${allCandidateVolume}, 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-capacity-recommend`.slice(0, 128), }) - const capacityRecommendation = context.apiSuccess(MODULE, '目标同柜组整体超容时智能推荐跳过整组', + const capacityRecommendation = context.apiSuccess(MODULE, '容量竞争时智能推荐不因整组容量失败', recommendResponse, evidence(recommendResponse, `targetGroupId=${groupId}`), OPTIONS) const capacityGroupItems = (capacityRecommendation?.items || []) .filter((item) => [first.inboundId, second.inboundId].includes(id(item?.inboundId))) - context.assert(MODULE, '整组容量不足时智能推荐全不取且不产生部分成员', recommendResponse, - capacityGroupItems.length === 0, + const capacityTotals = new Map() + for (const item of capacityGroupItems) { + capacityTotals.set(id(item?.inboundId), + (capacityTotals.get(id(item?.inboundId)) || 0) + Number(item?.packageCount || 0)) + } + const capacityTookNone = capacityGroupItems.length === 0 + const capacityTookWhole = [first, second].every((fixture) => capacityTotals.get(fixture.inboundId) === fixture.packages) + context.assert(MODULE, '容量竞争时同柜组只能整组全取或全不取', recommendResponse, + capacityTookNone || capacityTookWhole, `targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS) const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { @@ -782,14 +788,14 @@ export async function run(context) { if (!partialPda || !fullPda || !normalPda) return const partialTreesBefore = await fulfillmentTrees(context, partialChain.fixtures, '短拣前') - const partialTarget = (partialPda.task?.items || []).find((item) => Number(item?.plannedPackageCount) > 1) - context.assert(MODULE, '短拣链存在可记录PARTIAL的多箱明细', null, Boolean(partialTarget?.id), + const partialTarget = (partialPda.task?.items || []).find((item) => Number(item?.plannedPackageCount) > 0) + context.assert(MODULE, '短拣链存在可记录SKIP的组成员明细', null, Boolean(partialTarget?.id), `planned=${partialTarget?.plannedPackageCount ?? 'missing'}, taskItems=${partialPda.task?.items?.length ?? 0}`, OPTIONS) if (!partialTarget?.id) return 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: null } + ? { itemId: id(item.id), action: 'SKIP', pickedPackageCount: null, + finalPickedPackageCount: null, releaseMode: 'MANUAL', + locationIds: [], version: Number(item.version), reason: '同柜不可拆组短拣验收' } : { itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null }) @@ -798,13 +804,12 @@ export async function run(context) { containerBookingId: partialChain.containerBookingId, expectedVersion: Number(partialPda.task.version), inventorySnapshotToken: partialPda.inventory.snapshotToken, items: partialBatchItems, }, undefined, { 'Idempotency-Key': `${context.marker}-coload-partial-batch`.slice(0, 128) }) - const partialBatch = context.apiSuccess(MODULE, '同柜组PDA原子批次记录部分拣货异常', partialBatchResponse, + const partialBatch = context.apiSuccess(MODULE, '同柜组PDA原子批次记录短拣异常', partialBatchResponse, evidence(partialBatchResponse), OPTIONS) - context.assert(MODULE, '短拣批次只进入待确认且保留PARTIAL事实', partialBatchResponse, + context.assert(MODULE, '短拣批次只进入待确认且保留SKIP事实', partialBatchResponse, partialBatch?.taskStatus === 'LOADING' && partialBatch?.workflowStatus === 'AWAITING_REVIEW' && partialBatch?.items?.some((item) => id(item?.id) === id(partialTarget.id) - && item?.itemStatus === 'PARTIAL' - && Number(item?.pickedPackageCount) === Number(partialTarget.plannedPackageCount) - 1), + && item?.itemStatus === 'SKIPPED' && Number(item?.pickedPackageCount) === 0), `taskStatus=${partialBatch?.taskStatus || 'missing'}, workflow=${partialBatch?.workflowStatus || 'missing'}`, OPTIONS) const partialTaskBeforeComplete = taskSignature(partialBatch) const partialCompleteResponse = await context.request('POST', `${PDA}/tasks/${partialPda.taskId}/complete`, { @@ -955,6 +960,38 @@ export async function run(context) { evidence(splitCancelResponse), OPTIONS) } + let uiAcceptance = null + if (differentDestination) { + const uiGroupResponse = await context.request('POST', '/api/platform/v1/first-leg/coload-groups', + { orderIds: [fifth.orderId, differentDestination.orderId] }, undefined, + { 'Idempotency-Key': `${context.marker}-coload-ui-preserve`.slice(0, 128) }) + const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse, + evidence(uiGroupResponse), OPTIONS) + const uiSimulationResponse = await context.request('POST', CONTAINERS, { + channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC', + capacityCbm: 76, maxWeightKg: 28000, + }) + const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, + evidence(uiSimulationResponse), OPTIONS) + const uiBookingId = id(uiSimulation?.id) + const uiPlanResponse = uiBookingId ? await context.request('POST', `${SMART}/plans`, { + bookingId: uiBookingId, remark: `${context.marker} Admin整组候选人工验收保留`, + }) : null + const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留未分配空计划', uiPlanResponse, + evidence(uiPlanResponse, `bookingId=${uiBookingId}`), OPTIONS) : null + const uiMembers = memberFacts(uiGroup) + context.assert(MODULE, '页面验收保留组仍为ACTIVE且未锁定', uiGroupResponse, + uiGroup?.status === 'ACTIVE' && !uiGroup?.lockedPlanId && uiMembers.length === 2 + && Boolean(uiBookingId) && Boolean(uiPlan?.id), + `groupId=${id(uiGroup?.groupId)}, groupNo=${uiGroup?.groupNo || 'missing'}, bookingId=${uiBookingId}, planId=${id(uiPlan?.id)}, members=${JSON.stringify(uiMembers)}`, OPTIONS) + uiAcceptance = { + groupId: id(uiGroup?.groupId), groupNo: uiGroup?.groupNo || null, + orderIds: [fifth.orderId, differentDestination.orderId], + inboundIds: [fifth.inboundId, differentDestination.inboundId], + bookingId: uiBookingId, planId: id(uiPlan?.id), + } + } + context.createdResources.coloadGroupAcceptance = { marker: context.marker, groupId, groupNo: group?.groupNo || null, planId, bookingId, orderIds: groupOrders, inboundIds: [first.inboundId, second.inboundId], @@ -962,6 +999,7 @@ export async function run(context) { partialTaskId: partialPda.taskId, normalTaskId: normalPda.taskId, oceanBookingId, cancelledGroupId: cancelGroupId, remainderOrderId: id(remainder.orderId), splitGroupId: id(splitGroup?.groupId), crossDestinationOrderId: id(differentDestination?.orderId), + uiAcceptance, publicBypassResult: 'individual assignment/adjust/delete and short-pick completion rejected; full group consumed', } }