diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 47e687865..5b838da68 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -169,6 +169,13 @@ public class PdaPickSettlementService { int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId); String remainderChildCode = childPackageCount > 0 ? childInboundCode(root.inboundCode(), sequence) : null; + // Consume the current slice before inserting its successor so the single-active-remainder + // constraint is released. A local failure rolls this back; the Order split is idempotent on retry. + requireAffected(splits.markSourceLoaded(parent.tenantId(), parentInboundId, pickedPackageCount, + operatorId, sourceVersion), "来源分片状态"); + if (parentInboundId != rootInboundId) { + requireAffected(splits.touchRootSplit(parent.tenantId(), rootInboundId, operatorId, rootVersion), "根单拆分状态"); + } if (remainderChildId != 0L) { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), @@ -198,11 +205,6 @@ public class PdaPickSettlementService { line.remainingPackageCount()), "子入库测量明细"); lineNo++; } - requireAffected(splits.markSourceLoaded(parent.tenantId(), parentInboundId, pickedPackageCount, - operatorId, sourceVersion), "来源分片状态"); - if (parentInboundId != rootInboundId) { - requireAffected(splits.touchRootSplit(parent.tenantId(), rootInboundId, operatorId, rootVersion), "根单拆分状态"); - } requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效"); for (MeasurementSplitLine line : measurementLines) { requireAffected(splits.updateMeasurementItemForLoaded(parent.tenantId(), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index c88e36722..56925a23e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -13,6 +13,7 @@ import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -27,6 +28,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -243,6 +245,51 @@ class PdaPickSettlementServiceTest { anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString()); } + @Test + void consumesSecondSliceBeforeCreatingSingleNextRemainderFromRootSequence() { + LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 12, 5, "PARTIAL"); + resolvedChild(item, 32L, 42L, 62L, 52L, 12, 2); + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(2); + childWrites(32L, 42L, 62L, 52L, 5, 2); + when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 7))))); + + service.settle(1L, 9L, "operator"); + + InOrder order = inOrder(splits); + order.verify(splits).markSourceLoaded(10001L, 32L, 5, 9L, 2); + order.verify(splits).touchRootSplit(10001L, 31L, 9L, 4); + order.verify(splits).cloneChildWithPurpose(eq(10001L), eq(32L), eq(31L), anyLong(), + eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-2"), eq(7), eq(2), + eq("REMAINDER"), eq(9L), eq("operator")); + verify(splits).insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), eq("PDA-SPLIT-1-32"), + eq(32L), eq("pda-task-1"), eq(4), eq(12), + org.mockito.ArgumentMatchers.isNull(), anyLong(), eq(5), eq(7), eq(701L), eq(801L), + anyString(), eq(9L), eq("operator")); + } + + @Test + void retryAfterRemoteOrderSuccessCanFinishLocalThirdSliceWithoutNestedCode() { + LoadingPickItemEntity item = item(13L, 23L, 33L, 43L, 9, 4, "PARTIAL"); + resolvedChild(item, 33L, 43L, 63L, 53L, 9, 3); + when(splits.nextSplitSequence(10001L, 31L)).thenReturn(3); + childWrites(33L, 43L, 63L, 53L, 4, 3); + when(splits.markSourceLoaded(10001L, 33L, 4, 9L, 3)).thenReturn(0, 1); + when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 5))))); + + assertThatThrownBy(() -> service.settle(1L, 9L, "operator")) + .hasMessageContaining("来源分片状态已被并发修改"); + service.settle(1L, 9L, "operator"); + + verify(orderSplits, times(2)).split(eq("internal-token"), eq(9L), + org.mockito.ArgumentMatchers.argThat(request -> request.idempotencyKey().equals("pda-task-1") + && request.operationId().equals("PDA-SPLIT-1-33"))); + verify(splits).cloneChildWithPurpose(eq(10001L), eq(33L), eq(31L), anyLong(), + eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-3"), eq(5), eq(3), + eq("REMAINDER"), eq(9L), eq("operator")); + } + private void resolved(List items) { when(picks.allTaskItemsResolved(1L)).thenReturn(true); when(picks.items(1L)).thenReturn(items); @@ -255,6 +302,47 @@ class PdaPickSettlementServiceTest { stubOrderSplitSuccess(); } + private void resolvedChild(LoadingPickItemEntity item, long inboundId, long detailId, + long measurementId, long measurementItemId, + int packageCount, int sourceVersion) { + resolved(List.of(item)); + when(splits.lineage(inboundId)).thenReturn(new InboundSplitSettlementMapper.Lineage( + inboundId, 10001L, "CHILD", 31L, "REMAINDER", "NOT_SPLIT")); + when(splits.lockParent(31L)).thenReturn(new ParentRow(31L, 10001L, 901L, "ORDER-001", + "IN-001", "MASTER", 31L, "LOADING", 5, 5, "FULLY_SPLIT", 4)); + when(splits.lockParent(inboundId)).thenReturn(new ParentRow(inboundId, 10001L, 902L, + "ORDER-REMAINDER", "IN-001-" + (sourceVersion - 1), "CHILD", 31L, + "INBOUNDED", packageCount, packageCount, "NOT_SPLIT", sourceVersion)); + when(splits.lockLatestApprovedItemsForInbound(10001L, inboundId)) + .thenReturn(List.of(new MeasurementItemRow(measurementItemId, measurementId, detailId, packageCount))); + when(allocations.lockConfirmed(10001L, inboundId)).thenAnswer(invocation -> + List.of(allocation(1L, 1, 501L, packageCount))); + } + + private void childWrites(long inboundId, long detailId, long measurementId, + long measurementItemId, int pickedPackageCount, int sourceVersion) { + when(splits.cloneChildWithPurpose(eq(10001L), eq(inboundId), eq(31L), anyLong(), anyLong(), + anyString(), anyString(), anyInt(), anyInt(), eq("REMAINDER"), eq(9L), eq("operator"))).thenReturn(1); + when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(inboundId), + anyString(), eq(4), anyInt(), org.mockito.ArgumentMatchers.isNull(), anyLong(), anyInt(), anyInt(), + eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1); + when(splits.cloneMeasurement(eq(10001L), eq(inboundId), eq(measurementId), anyLong(), anyLong(), + anyLong(), anyInt(), eq(9L))).thenReturn(1); + when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(), + eq(detailId), eq(measurementId), eq(measurementItemId), anyInt())).thenReturn(1); + when(splits.cloneMeasurementItem(eq(10001L), eq(measurementId), eq(measurementItemId), anyLong(), + anyLong(), anyLong(), anyInt(), anyInt())).thenReturn(1); + when(splits.markSourceLoaded(10001L, inboundId, pickedPackageCount, 9L, sourceVersion)).thenReturn(1); + when(splits.touchRootSplit(10001L, 31L, 9L, 4)).thenReturn(1); + when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1); + when(splits.updateMeasurementItemForLoaded(eq(10001L), eq(measurementItemId), anyInt())).thenReturn(1); + when(splits.updateMeasurementForLoaded(eq(10001L), eq(inboundId), eq(measurementId), anyInt())).thenReturn(1); + when(splits.normalizeChildMetrics(eq(10001L), anyLong(), anyLong())).thenReturn(1); + when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L), + eq("operator"), anyString())).thenReturn(1); + when(splits.convergePartialPlanItem(anyLong(), eq(9L), eq("operator"))).thenReturn(1); + } + private void stubOrderSplitSuccess() { when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenAnswer(invocation -> { OrderFulfillmentSplitClient.CreateRequest request = invocation.getArgument(2);