feat(phase3): connect PDA settlement to real child orders

This commit is contained in:
2026-08-10 13:02:51 +08:00
parent 20996b050b
commit 253c0e7472
14 changed files with 475 additions and 27 deletions
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import static org.assertj.core.api.Assertions.assertThat;
class OrderFulfillmentSplitClientContractTest {
@Test
void usesAuthenticatedOrderInternalSplitContract() throws Exception {
FeignClient client = OrderFulfillmentSplitClient.class.getAnnotation(FeignClient.class);
var method = OrderFulfillmentSplitClient.class.getMethod("split", String.class, long.class,
OrderFulfillmentSplitClient.CreateRequest.class);
assertThat(client.path()).isEqualTo("/internal/order/v1/fulfillment-splits");
assertThat(method.getAnnotation(PostMapping.class)).isNotNull();
assertThat(method.getParameterAnnotations()[0][0]).isInstanceOf(RequestHeader.class);
assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(((RequestHeader) method.getParameterAnnotations()[1][0]).value())
.isEqualTo("X-Operator-Id");
}
}
@@ -68,9 +68,12 @@ class PdaSplitSettlementSqlContractTest {
.contains("#{containerBookingId}");
String clone = InboundSplitSettlementMapper.class.getMethod("cloneChildWithPurpose",
long.class, long.class, long.class, long.class, String.class, int.class,
long.class, long.class, long.class, long.class, long.class, String.class,
String.class, int.class,
int.class, String.class, long.class, String.class)
.getAnnotation(Insert.class).value()[0];
assertThat(clone).contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END");
assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END");
}
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.client.OrderFulfillmentSplitClient;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
@@ -19,12 +20,14 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -35,6 +38,7 @@ class PdaPickSettlementServiceTest {
@MockBean private InboundSplitSettlementMapper splits;
@MockBean private InboundPalletAllocationRepository allocations;
@MockBean private WarehousePalletLocationClient warehouse;
@MockBean private OrderFulfillmentSplitClient orderSplits;
@Resource private PdaPickSettlementService service;
@Test
@@ -48,8 +52,8 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
@@ -57,9 +61,11 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(8),
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(8),
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(12),
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1002L), eq("ORDER-REMAINDER"), anyString(), eq(12),
anyInt(), eq("REMAINDER"), eq(9L), eq("operator"));
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
@@ -93,8 +99,8 @@ class PdaPickSettlementServiceTest {
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
List.of(transfer(2, 502L, 10)))));
@@ -126,7 +132,8 @@ class PdaPickSettlementServiceTest {
verify(allocations).consume(10001L, 31L, 1L, 10);
verify(allocations).consume(10001L, 31L, 2L, 10);
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), eq(20),
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(20),
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
@@ -155,6 +162,44 @@ class PdaPickSettlementServiceTest {
assertThat(resolution.skippedPlanItemIds()).isEmpty();
}
@Test
void orderSplitFailureStopsLocalChildCreationAndCanBeRetriedWithSameKey() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenReturn(
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE,
"order unavailable", null, null));
assertThatThrownBy(() -> service.settle(1L, 9L, "operator"))
.hasMessageContaining("order unavailable");
verify(orderSplits).split(eq("internal-token"), eq(9L),
org.mockito.ArgumentMatchers.argThat(request ->
request.idempotencyKey().equals("pda-task-1")
&& request.operationId().equals("PDA-SPLIT-1-31")));
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
stubOrderSplitSuccess();
writes(0, 20);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
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")));
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1001L), eq("ORDER-LOADED"), anyString(), eq(20), anyInt(), eq("LOADED"),
eq(9L), eq("operator"));
}
private void resolved(List<LoadingPickItemEntity> items) {
when(picks.allTaskItemsResolved(1L)).thenReturn(true);
when(picks.items(1L)).thenReturn(items);
@@ -164,11 +209,29 @@ class PdaPickSettlementServiceTest {
task.setId(1L); task.setLoadingPlanId(701L);
when(picks.task(1L)).thenReturn(task);
when(splits.containerBookingId(701L)).thenReturn(801L);
stubOrderSplitSuccess();
}
private void stubOrderSplitSuccess() {
when(orderSplits.split(eq("internal-token"), eq(9L), any())).thenAnswer(invocation -> {
OrderFulfillmentSplitClient.CreateRequest request = invocation.getArgument(2);
OrderFulfillmentSplitClient.Child loaded = new OrderFulfillmentSplitClient.Child(
1001L, "ORDER-LOADED", 1, "LOADED", request.loadedPackageCount(),
request.sourceOrderId(), request.loadingPlanId(), request.containerBookingId());
OrderFulfillmentSplitClient.Child remainder = request.remainingPackageCount() == 0 ? null
: new OrderFulfillmentSplitClient.Child(1002L, "ORDER-REMAINDER", 2, "REMAINDER",
request.remainingPackageCount(), request.sourceOrderId(), request.loadingPlanId(),
request.containerBookingId());
return ApiResponse.success(new OrderFulfillmentSplitClient.Response(
request.parentOrderId(), "ORDER-001", 2, loaded, remainder,
request.sourcePackageCount(), request.loadedPackageCount(), request.remainingPackageCount(),
request.loadingPlanId(), request.containerBookingId(), request.idempotencyKey()));
});
}
private void writes(int childCount, int pickedCount) {
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(1);
when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyString(), anyInt(),
when(splits.cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), anyLong(), anyString(), anyString(), anyInt(),
anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3),
anyInt(), anyLong(), any(), anyInt(), anyInt(), eq(701L), eq(801L), anyString(), eq(9L), eq("operator"))).thenReturn(1);