fix(firstleg): retry chained remainder settlement

This commit is contained in:
2026-08-11 11:12:29 +08:00
parent fe0f1665e5
commit 7aa2b507f6
2 changed files with 95 additions and 5 deletions
@@ -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(),
@@ -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<LoadingPickItemEntity> 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);