From 253c0e747267de2f13989e5435d462f46f63b3d1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 13:02:51 +0800 Subject: [PATCH] feat(phase3): connect PDA settlement to real child orders --- .../client/OrderFulfillmentSplitClient.java | 34 +++++ .../mapper/InboundSplitSettlementMapper.java | 12 +- .../service/PdaPickSettlementService.java | 64 ++++++++++ ...derFulfillmentSplitClientContractTest.java | 25 ++++ .../PdaSplitSettlementSqlContractTest.java | 7 +- .../service/PdaPickSettlementServiceTest.java | 79 ++++++++++-- .../com/baiyue/order/dto/OrderSplitDtos.java | 2 +- .../mapper/OrderFulfillmentSplitMapper.java | 12 ++ .../service/OrderFulfillmentSplitService.java | 43 +++++-- order/src/main/resources/db/order.sql | 2 + .../db/phase3-order-parent-child-schema.sql | 6 + .../src/main/resources/openapi/order-v1.yaml | 57 +++++++++ .../OrderFulfillmentSplitContractTest.java | 43 +++++++ .../OrderFulfillmentSplitServiceTest.java | 116 ++++++++++++++++++ 14 files changed, 475 insertions(+), 27 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClientContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java new file mode 100644 index 000000000..75e8d48c7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +/** Order-domain child-order contract used by the transactional PDA settlement. */ +@FeignClient(name = "order-service", contextId = "firstlegOrderFulfillmentSplitClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/fulfillment-splits") +public interface OrderFulfillmentSplitClient { + @PostMapping + ApiResponse split(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @RequestBody CreateRequest request); + + record CreateRequest(Long tenantId, Long parentOrderId, Long sourceOrderId, + Long loadingPlanId, Long containerBookingId, + Integer sourcePackageCount, Integer loadedPackageCount, + Integer remainingPackageCount, Integer expectedParentVersion, + String idempotencyKey, String operationId, + List lines, String operatorName) {} + record QuantityLine(Long sourceDetailId, Integer packageCount) {} + record Response(Long parentOrderId, String parentOrderNo, Integer parentVersion, + Child loadedChild, Child remainderChild, Integer sourcePackageCount, + Integer loadedPackageCount, Integer remainingPackageCount, + Long loadingPlanId, Long containerBookingId, String idempotencyKey) {} + record Child(Long orderId, String orderNo, Integer splitSequence, String splitRole, + Integer packageCount, Long sourceOrderId, Long loadingPlanId, + Long containerBookingId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index bfa1f9119..bb95ddd06 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -23,7 +23,8 @@ public interface InboundSplitSettlementMapper { @Select("SELECT container_booking_id FROM fl_loading_plan WHERE id=#{loadingPlanId}") Long containerBookingId(@Param("loadingPlanId") long loadingPlanId); @Select(""" - SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole, + SELECT id, tenant_id AS tenantId, order_id AS orderId, order_no AS orderNo, + inbound_code AS inboundCode, inbound_role AS inboundRole, root_inbound_id AS rootInboundId, process_status AS processStatus, total_package_count AS totalPackageCount, package_count AS packageCount, version @@ -44,7 +45,7 @@ public interface InboundSplitSettlementMapper { actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,express_company_name, express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version, inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose) - SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode}, + SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, @@ -58,6 +59,7 @@ public interface InboundSplitSettlementMapper { """) int cloneChildWithPurpose(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId, @Param("rootInboundId") long rootInboundId, @Param("childId") long childId, + @Param("childOrderId") long childOrderId, @Param("childOrderNo") String childOrderNo, @Param("childInboundCode") String childInboundCode, @Param("packageCount") int packageCount, @Param("splitSequence") int splitSequence, @Param("splitPurpose") String splitPurpose, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @@ -264,12 +266,14 @@ public interface InboundSplitSettlementMapper { int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId, @Param("measurementId") long measurementId); - record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, Long rootInboundId, + record ParentRow(Long id, Long tenantId, Long orderId, String orderNo, + String inboundCode, String inboundRole, Long rootInboundId, String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) { public ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) { - this(id, tenantId, inboundCode, inboundRole, id, processStatus, totalPackageCount, packageCount, version); + this(id, tenantId, 901L, "ORDER-001", inboundCode, inboundRole, id, + processStatus, totalPackageCount, packageCount, version); } } 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 9027e533b..554ee2ab4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; 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.mapper.InboundSplitSettlementMapper; @@ -41,6 +42,8 @@ public class PdaPickSettlementService { private InboundPalletAllocationRepository allocations; @Resource private WarehousePalletLocationClient warehouse; + @Resource + private OrderFulfillmentSplitClient orderSplits; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -154,6 +157,9 @@ public class PdaPickSettlementService { } if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在"); + OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines, + pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId, + "pda-task-" + taskId, operatorId, operatorName); long loadedChildId = IdWorker.getId(); long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L; long batchId = IdWorker.getId(); @@ -162,9 +168,11 @@ public class PdaPickSettlementService { int parentVersion = root.version() == null ? 0 : root.version(); int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId); requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId, + orderSplit.loadedChild().orderId(), orderSplit.loadedChild().orderNo(), childInboundCode(parent.inboundCode(), loadedChildId), pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单"); if (remainderChildId != 0L) { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), childCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单"); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, @@ -238,6 +246,62 @@ public class PdaPickSettlementService { return true; } + private OrderFulfillmentSplitClient.Response createOrderSplit( + ParentRow source, ParentRow root, List lines, + int loadedPackageCount, int remainingPackageCount, long loadingPlanId, + long containerBookingId, String operationId, String idempotencyKey, + long operatorId, String operatorName) { + if (root.orderId() == null || source.orderId() == null) { + throw conflict("入仓单缺少真实订单引用"); + } + List quantities = lines.stream() + .map(line -> new OrderFulfillmentSplitClient.QuantityLine( + line.item().inboundDetailId(), line.item().packageCount())) + .toList(); + OrderFulfillmentSplitClient.CreateRequest request = new OrderFulfillmentSplitClient.CreateRequest( + source.tenantId(), root.orderId(), source.orderId(), loadingPlanId, containerBookingId, + loadedPackageCount + remainingPackageCount, loadedPackageCount, remainingPackageCount, + null, idempotencyKey, operationId, quantities, operatorName); + ApiResponse response; + try { + response = orderSplits.split(internalToken, operatorId, request); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务不可用,无法生成真实履约子订单"); + } + if (response == null || !response.successful() || response.data() == null) { + ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); + throw new BizException(code, response == null || response.message() == null + ? "订单服务生成真实履约子订单失败" : response.message()); + } + OrderFulfillmentSplitClient.Response split = response.data(); + boolean remainderMatches = remainingPackageCount == 0 ? split.remainderChild() == null + : validOrderChild(split.remainderChild(), "REMAINDER", source.orderId(), loadingPlanId, + containerBookingId, remainingPackageCount); + if (!Objects.equals(split.parentOrderId(), root.orderId()) + || !Objects.equals(split.sourcePackageCount(), loadedPackageCount + remainingPackageCount) + || !Objects.equals(split.loadedPackageCount(), loadedPackageCount) + || !Objects.equals(split.remainingPackageCount(), remainingPackageCount) + || !Objects.equals(split.loadingPlanId(), loadingPlanId) + || !Objects.equals(split.containerBookingId(), containerBookingId) + || !validOrderChild(split.loadedChild(), "LOADED", source.orderId(), loadingPlanId, + containerBookingId, loadedPackageCount) + || !remainderMatches) { + throw conflict("订单服务返回的父子订单、柜次或数量不一致"); + } + return split; + } + + private static boolean validOrderChild(OrderFulfillmentSplitClient.Child child, String role, + Long sourceOrderId, long loadingPlanId, + long containerBookingId, int packageCount) { + return child != null && child.orderId() != null && child.orderId() > 0 + && child.orderNo() != null && !child.orderNo().isBlank() + && role.equals(child.splitRole()) && Objects.equals(child.packageCount(), packageCount) + && Objects.equals(child.sourceOrderId(), sourceOrderId) + && Objects.equals(child.loadingPlanId(), loadingPlanId) + && Objects.equals(child.containerBookingId(), containerBookingId); + } + private static Map pickedByInbound(List taskItems) { return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus())) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClientContractTest.java new file mode 100644 index 000000000..fb81ff76b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClientContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 3af078a5f..43025ed3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -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"); } } 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 8bf7c6a39..fabb58004 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -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 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); diff --git a/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java b/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java index eb05c1445..d8c9e6de6 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java @@ -21,7 +21,7 @@ public final class OrderSplitDtos { @NotNull @Min(1) Integer sourcePackageCount, @NotNull @Min(1) Integer loadedPackageCount, @NotNull @Min(0) Integer remainingPackageCount, - @NotNull @Min(0) Integer expectedParentVersion, + @Min(0) Integer expectedParentVersion, @NotBlank String idempotencyKey, String operationId, List lines, diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java index 1545a0407..592fd2d06 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java @@ -27,6 +27,18 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper findByParent(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId); + @Select("SELECT * FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} " + + "AND parent_order_id=#{parentOrderId} AND child_order_id=#{sourceOrderId} LIMIT 1 FOR UPDATE") + OrderFulfillmentSplitEntity lockSourceSlice(@Param("tenantId") long tenantId, + @Param("parentOrderId") long parentOrderId, + @Param("sourceOrderId") long sourceOrderId); + + @Select("SELECT COUNT(*) FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} " + + "AND parent_order_id=#{parentOrderId} AND source_order_id=#{sourceOrderId}") + int consumedSourceCount(@Param("tenantId") long tenantId, + @Param("parentOrderId") long parentOrderId, + @Param("sourceOrderId") long sourceOrderId); + @Select("SELECT COALESCE(MAX(split_sequence),0) FROM ord_order WHERE tenant_id=#{tenantId} AND (id=#{parentOrderId} OR parent_order_id=#{parentOrderId}) AND deleted=0") Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index 5c38691b2..3e5a15cfa 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -31,30 +31,33 @@ public class OrderFulfillmentSplitService { String hash = hash(req); List replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); if (!replay.isEmpty()) { - if (replay.stream().anyMatch(x -> x.getRequestHash()!=null && !hash.equals(x.getRequestHash()))) - throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致"); + requireMatchingReplay(replay, hash); return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay); } OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId()); replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); - if (!replay.isEmpty()) return response(req, parent, replay); + if (!replay.isEmpty()) { + requireMatchingReplay(replay, hash); + return response(req, parent, replay); + } if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户"); - if (!req.parentOrderId().equals(req.sourceOrderId()) && !isDirectChild(req.sourceOrderId(), parent)) - throw new BizException(ResultCode.CONFLICT, "来源订单必须是父订单或其直接子订单"); if (!"MASTER".equals(parent.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "子订单不能继续拆分"); - if (parent.getVersion()!=null && !req.expectedParentVersion().equals(parent.getVersion())) + requireUnconsumedSource(req, parent); + if (req.expectedParentVersion()!=null && parent.getVersion()!=null + && !req.expectedParentVersion().equals(parent.getVersion())) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试"); + int parentVersion = parent.getVersion() == null ? 0 : parent.getVersion(); int next = Optional.ofNullable(splits.maxSplitSequence(req.tenantId(), req.parentOrderId())).orElse(0) + 1; List rows = new ArrayList<>(); OrderSplitDtos.Child loaded = createChild(req, parent, next++, "LOADED", req.loadedPackageCount(), hash, operatorId, rows); OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0 ? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null; for (OrderFulfillmentSplitEntity row : rows) splits.insert(row); - parent.setVersion((parent.getVersion()==null?0:parent.getVersion()) + 1); + parent.setVersion(parentVersion + 1); LambdaUpdateWrapper guard = new LambdaUpdateWrapper() .eq(OrderEntity::getId, parent.getId()) .eq(OrderEntity::getTenantId, parent.getTenantId()) - .eq(OrderEntity::getVersion, req.expectedParentVersion()); + .eq(OrderEntity::getVersion, parentVersion); int updated = orders.update(parent, guard); if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试"); return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder, @@ -74,12 +77,28 @@ public class OrderFulfillmentSplitService { return new OrderSplitDtos.Tree(parent.getId(), parent.getOrderNo(), parent.getVersion(), result, allocated, Math.max(0, rows.stream().mapToInt(OrderFulfillmentSplitEntity::getSourcePackageCount).findFirst().orElse(0)-allocated)); } - private boolean isDirectChild(long source, OrderEntity parent) { OrderEntity c=orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(OrderEntity::getId,source).eq(OrderEntity::getTenantId,parent.getTenantId())); return c!=null && "CHILD".equals(c.getOrderRole()) && parent.getId().equals(c.getParentOrderId()); } + private void requireUnconsumedSource(OrderSplitDtos.CreateRequest request, OrderEntity parent) { + if (!request.parentOrderId().equals(request.sourceOrderId())) { + OrderFulfillmentSplitEntity source = splits.lockSourceSlice(request.tenantId(), + request.parentOrderId(), request.sourceOrderId()); + if (source == null || !"REMAINDER".equals(source.getSplitRole())) { + throw new BizException(ResultCode.CONFLICT, "来源订单必须是未消费的余量子订单"); + } + if (!Objects.equals(source.getRemainingPackageCount(), request.sourcePackageCount())) { + throw new BizException(ResultCode.CONFLICT, "余量子订单箱数与本次来源箱数不一致"); + } + } + if (splits.consumedSourceCount(request.tenantId(), parent.getId(), request.sourceOrderId()) != 0) { + throw new BizException(ResultCode.CONFLICT, "来源订单已经完成履约拆分,不能重复消费"); + } + } private OrderSplitDtos.Child createChild(OrderSplitDtos.CreateRequest r, OrderEntity p, int seq, String role, int count, String hash, long op, List rows) { OrderEntity c = new OrderEntity(); c.setId(IdWorker.getId()); c.setTenantId(p.getTenantId()); c.setOrderNo(p.getOrderNo()+"-"+seq); c.setOrderRole("CHILD"); c.setParentOrderId(p.getId()); c.setRootOrderId(p.getId()); c.setSplitSequence(seq); c.setStatus(p.getStatus()); c.setCustomerId(p.getCustomerId()); c.setCustomerName(p.getCustomerName()); c.setTransportMode(p.getTransportMode()); c.setTradeTerm(p.getTradeTerm()); c.setDestinationCountryCode(p.getDestinationCountryCode()); c.setDestinationWarehouseId(p.getDestinationWarehouseId()); c.setDestinationWarehouseName(p.getDestinationWarehouseName()); c.setReceivingWarehouseId(p.getReceivingWarehouseId()); c.setReceivingWarehouseName(p.getReceivingWarehouseName()); c.setChannelCode(p.getChannelCode()); c.setChannelName(p.getChannelName()); c.setDestinationType(p.getDestinationType()); c.setCreatedBy(op); c.setUpdatedBy(op); orders.insert(c); - OrderFulfillmentSplitEntity row=new OrderFulfillmentSplitEntity(); row.setId(IdWorker.getId()); row.setTenantId(r.tenantId()); row.setParentOrderId(p.getId()); row.setChildOrderId(c.getId()); row.setLoadingPlanId(r.loadingPlanId()); row.setContainerBookingId(r.containerBookingId()); row.setSourcePackageCount(r.sourcePackageCount()); row.setLoadedPackageCount(r.loadedPackageCount()); row.setRemainingPackageCount(r.remainingPackageCount()); row.setOperationId(Optional.ofNullable(r.operationId()).orElse("split-"+r.idempotencyKey())); row.setIdempotencyKey(r.idempotencyKey()); row.setRequestHash(hash); row.setSplitRole(role); row.setExpectedParentVersion(r.expectedParentVersion()); row.setCreatedBy(op); row.setCreatedByName(r.operatorName()); row.setCreatedAt(LocalDateTime.now()); rows.add(row); return new OrderSplitDtos.Child(c.getId(), c.getOrderNo(), seq, role, count); + OrderFulfillmentSplitEntity row=new OrderFulfillmentSplitEntity(); row.setId(IdWorker.getId()); row.setTenantId(r.tenantId()); row.setParentOrderId(p.getId()); row.setSourceOrderId(r.sourceOrderId()); row.setChildOrderId(c.getId()); row.setLoadingPlanId(r.loadingPlanId()); row.setContainerBookingId(r.containerBookingId()); row.setSourcePackageCount(r.sourcePackageCount()); row.setLoadedPackageCount(r.loadedPackageCount()); row.setRemainingPackageCount(r.remainingPackageCount()); row.setSourceDetailJson(sourceDetails(r)); row.setOperationId(Optional.ofNullable(r.operationId()).orElse("split-"+r.idempotencyKey())); row.setIdempotencyKey(r.idempotencyKey()); row.setRequestHash(hash); row.setSplitRole(role); row.setExpectedParentVersion(Optional.ofNullable(r.expectedParentVersion()).orElse(p.getVersion()==null?0:p.getVersion())); row.setCreatedBy(op); row.setCreatedByName(r.operatorName()); row.setCreatedAt(LocalDateTime.now()); rows.add(row); return new OrderSplitDtos.Child(c.getId(), c.getOrderNo(), seq, role, count, r.sourceOrderId(), r.loadingPlanId(), r.containerBookingId()); } - private void validate(OrderSplitDtos.CreateRequest r) { if(r==null||r.tenantId()==null||r.parentOrderId()==null||r.sourceOrderId()==null||r.loadingPlanId()==null||r.containerBookingId()==null||r.sourcePackageCount()==null||r.loadedPackageCount()==null||r.remainingPackageCount()==null||r.expectedParentVersion()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank()) throw new BizException(ResultCode.BAD_REQUEST,"拆分请求字段不完整"); if(r.loadedPackageCount()<1||r.remainingPackageCount()<0||r.loadedPackageCount()+r.remainingPackageCount()!=r.sourcePackageCount()) throw new BizException(ResultCode.VALIDATION_FAILED,"装柜数量与剩余数量必须守恒"); } + private void validate(OrderSplitDtos.CreateRequest r) { if(r==null||r.tenantId()==null||r.parentOrderId()==null||r.sourceOrderId()==null||r.loadingPlanId()==null||r.containerBookingId()==null||r.sourcePackageCount()==null||r.loadedPackageCount()==null||r.remainingPackageCount()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank()||r.lines()==null||r.lines().isEmpty()) throw new BizException(ResultCode.BAD_REQUEST,"拆分请求字段不完整"); if(r.loadedPackageCount()<1||r.remainingPackageCount()<0||r.loadedPackageCount()+r.remainingPackageCount()!=r.sourcePackageCount()) throw new BizException(ResultCode.VALIDATION_FAILED,"装柜数量与剩余数量必须守恒"); if(r.lines().stream().anyMatch(line -> line==null||line.sourceDetailId()==null||line.sourceDetailId()<=0||line.packageCount()==null||line.packageCount()<=0)||r.lines().stream().map(OrderSplitDtos.QuantityLine::sourceDetailId).distinct().count()!=r.lines().size()||r.lines().stream().mapToInt(OrderSplitDtos.QuantityLine::packageCount).sum()!=r.sourcePackageCount()) throw new BizException(ResultCode.VALIDATION_FAILED,"来源明细箱数必须唯一且合计等于来源箱数"); } + private void requireMatchingReplay(List rows, String hash) { if(rows.stream().anyMatch(row -> !Objects.equals(hash,row.getRequestHash()))) throw new BizException(ResultCode.CONFLICT,"幂等键对应的请求内容不一致"); } + private String sourceDetails(OrderSplitDtos.CreateRequest request) { try { return json.writeValueAsString(request.lines()); } catch(Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR,"无法保存来源明细快照"); } } private String hash(Object o) { try { byte[] b=json.writeValueAsString(o).getBytes(StandardCharsets.UTF_8); byte[] d=MessageDigest.getInstance("SHA-256").digest(b); StringBuilder s=new StringBuilder(); for(byte x:d)s.append(String.format("%02x",x)); return s.toString(); } catch(Exception e){throw new BizException("无法生成请求指纹");} } - private OrderSplitDtos.Response response(OrderSplitDtos.CreateRequest r, OrderEntity p, List rows) { OrderSplitDtos.Child l=null,m=null; for(var x:rows){OrderEntity c=orders.selectById(x.getChildOrderId()); var z=new OrderSplitDtos.Child(x.getChildOrderId(),c==null?null:c.getOrderNo(),c==null?null:c.getSplitSequence(),x.getSplitRole(),"LOADED".equals(x.getSplitRole())?x.getLoadedPackageCount():x.getRemainingPackageCount()); if("LOADED".equals(x.getSplitRole()))l=z;else m=z;} return new OrderSplitDtos.Response(p.getId(),p.getOrderNo(),p.getVersion(),l,m,r.sourcePackageCount(),r.loadedPackageCount(),r.remainingPackageCount(),r.loadingPlanId(),r.containerBookingId(),r.idempotencyKey(),rows.get(0).getCreatedAt()); } + private OrderSplitDtos.Response response(OrderSplitDtos.CreateRequest r, OrderEntity p, List rows) { OrderSplitDtos.Child l=null,m=null; for(var x:rows){OrderEntity c=orders.selectById(x.getChildOrderId()); var z=new OrderSplitDtos.Child(x.getChildOrderId(),c==null?null:c.getOrderNo(),c==null?null:c.getSplitSequence(),x.getSplitRole(),"LOADED".equals(x.getSplitRole())?x.getLoadedPackageCount():x.getRemainingPackageCount(),x.getSourceOrderId(),x.getLoadingPlanId(),x.getContainerBookingId()); if("LOADED".equals(x.getSplitRole()))l=z;else m=z;} return new OrderSplitDtos.Response(p.getId(),p.getOrderNo(),p.getVersion(),l,m,r.sourcePackageCount(),r.loadedPackageCount(),r.remainingPackageCount(),r.loadingPlanId(),r.containerBookingId(),r.idempotencyKey(),rows.get(0).getCreatedAt()); } } diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 7b1177129..2d9285b6d 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', tenant_id BIGINT UNSIGNED NOT NULL, parent_order_id BIGINT UNSIGNED NOT NULL, + source_order_id BIGINT UNSIGNED NOT NULL COMMENT '本次实际消费的主订单或余量子订单ID', child_order_id BIGINT UNSIGNED NOT NULL, split_role VARCHAR(12) NOT NULL COMMENT '本台账子片:LOADED或REMAINDER', loading_plan_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划逻辑ID', @@ -84,6 +85,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( UNIQUE KEY uk_order_split_idempotency (tenant_id, parent_order_id, idempotency_key, split_role), UNIQUE KEY uk_order_split_child (tenant_id, child_order_id), KEY idx_order_split_parent (tenant_id, parent_order_id, created_at, id), + KEY idx_order_split_source (tenant_id, source_order_id, split_role), KEY idx_order_split_container (tenant_id, container_booking_id, created_at, id), CONSTRAINT fk_order_split_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order(id), CONSTRAINT fk_order_split_child FOREIGN KEY (child_order_id) REFERENCES ord_order(id), diff --git a/order/src/main/resources/db/phase3-order-parent-child-schema.sql b/order/src/main/resources/db/phase3-order-parent-child-schema.sql index 14620c6b4..80fa6c31b 100644 --- a/order/src/main/resources/db/phase3-order-parent-child-schema.sql +++ b/order/src/main/resources/db/phase3-order-parent-child-schema.sql @@ -118,6 +118,12 @@ DELIMITER ; CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'"); CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID'''); CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'"); +-- 存量空来源必须先由发布前数据核对补齐;P0 契约不允许继续写无来源台账。 +ALTER TABLE ord_order_fulfillment_split + MODIFY source_order_id BIGINT UNSIGNED NOT NULL COMMENT '实际被消费的来源订单ID', + MODIFY request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256'; +CALL phase3_order_add_index('ord_order_fulfillment_split','idx_order_split_source', + 'KEY `idx_order_split_source` (`tenant_id`,`source_order_id`,`split_role`)'); DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; DELIMITER $$ diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index db2a27e26..7a72d093e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -274,6 +274,58 @@ components: scheme: bearer bearerFormat: JWT schemas: + FulfillmentSplitQuantityLine: + type: object + required: [sourceDetailId, packageCount] + properties: + sourceDetailId: {type: integer, format: int64, minimum: 1} + packageCount: {type: integer, minimum: 1} + FulfillmentSplitCreateRequest: + type: object + required: [tenantId, parentOrderId, sourceOrderId, loadingPlanId, containerBookingId, + sourcePackageCount, loadedPackageCount, remainingPackageCount, idempotencyKey, lines] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + parentOrderId: {type: integer, format: int64, minimum: 1} + sourceOrderId: {type: integer, format: int64, minimum: 1} + loadingPlanId: {type: integer, format: int64, minimum: 1} + containerBookingId: {type: integer, format: int64, minimum: 1} + sourcePackageCount: {type: integer, minimum: 1} + loadedPackageCount: {type: integer, minimum: 1} + remainingPackageCount: {type: integer, minimum: 0} + expectedParentVersion: {type: integer, minimum: 0, description: 可选;省略时仍在父订单行锁下串行拆分} + idempotencyKey: {type: string, minLength: 1, maxLength: 128} + operationId: {type: string, maxLength: 64} + operatorName: {type: string, maxLength: 64} + lines: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/FulfillmentSplitQuantityLine'} + FulfillmentSplitChild: + type: object + properties: + orderId: {type: integer, format: int64} + orderNo: {type: string} + splitSequence: {type: integer, minimum: 1} + splitRole: {type: string, enum: [LOADED, REMAINDER]} + packageCount: {type: integer, minimum: 1} + sourceOrderId: {type: integer, format: int64} + loadingPlanId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64} + FulfillmentSplitResponse: + type: object + properties: + parentOrderId: {type: integer, format: int64} + parentOrderNo: {type: string} + parentVersion: {type: integer, minimum: 0} + loadedChild: {$ref: '#/components/schemas/FulfillmentSplitChild'} + remainderChild: {$ref: '#/components/schemas/FulfillmentSplitChild', nullable: true} + sourcePackageCount: {type: integer, minimum: 1} + loadedPackageCount: {type: integer, minimum: 1} + remainingPackageCount: {type: integer, minimum: 0} + loadingPlanId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64} + idempotencyKey: {type: string} PricingTaxMode: type: string enum: [TAX_INCLUDED, TAX_EXCLUDED] @@ -334,6 +386,11 @@ components: orderNo: {type: string} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} parameters: + InternalToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string, minLength: 1} UnifiedKeyword: name: keyword in: query diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java new file mode 100644 index 000000000..b6b3220e9 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.order.service; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderFulfillmentSplitContractTest { + @Test + void schemaRequiresRealSourceOrderAndStableCabinetLedger() throws Exception { + String sql = new ClassPathResource("db/phase3-order-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String baseline = new ClassPathResource("db/order.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("source_order_id BIGINT UNSIGNED NOT NULL") + .contains("MODIFY source_order_id BIGINT UNSIGNED NOT NULL") + .contains("MODIFY request_hash CHAR(64) NOT NULL") + .contains("idx_order_split_source"); + assertThat(baseline) + .contains("source_order_id BIGINT UNSIGNED NOT NULL") + .contains("KEY idx_order_split_source (tenant_id, source_order_id, split_role)"); + } + + @Test + void openApiPublishesCompleteInternalSplitContract() throws Exception { + String yaml = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/internal/order/v1/fulfillment-splits:") + .contains("FulfillmentSplitCreateRequest:") + .contains("sourceOrderId:") + .contains("loadingPlanId:") + .contains("containerBookingId:") + .contains("expectedParentVersion:") + .contains("FulfillmentSplitResponse:") + .contains("InternalToken:"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java new file mode 100644 index 000000000..fb39688ba --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentSplitServiceTest.java @@ -0,0 +1,116 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderSplitDtos; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderFulfillmentSplitEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.mapper.OrderFulfillmentSplitMapper; +import com.baiyue.order.mapper.OrderMapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OrderFulfillmentSplitServiceTest { + @Mock private OrderFulfillmentSplitMapper splits; + @Mock private OrderMapper orders; + @InjectMocks private OrderFulfillmentSplitService service; + + @BeforeEach + void objectMapper() throws Exception { + var field = OrderFulfillmentSplitService.class.getDeclaredField("json"); + field.setAccessible(true); + field.set(service, new ObjectMapper()); + } + + @Test + void createsRealChildrenAndPersistsSourceDetailSnapshot() { + OrderEntity parent = parent(); + when(splits.findByIdempotency(10, 100, "key-1")).thenReturn(List.of()); + when(splits.lockOrder(10, 100)).thenReturn(parent); + when(splits.consumedSourceCount(10, 100, 100)).thenReturn(0); + when(splits.maxSplitSequence(10, 100)).thenReturn(0); + when(orders.insert(any(OrderEntity.class))).thenReturn(1); + when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1); + when(orders.update(any(OrderEntity.class), any(Wrapper.class))).thenReturn(1); + + OrderSplitDtos.Response response = service.split(request(100L, 20, 8, 12, "key-1"), 9); + + assertThat(response.loadedChild().sourceOrderId()).isEqualTo(100L); + assertThat(response.loadedChild().loadingPlanId()).isEqualTo(701L); + assertThat(response.remainderChild().containerBookingId()).isEqualTo(801L); + ArgumentCaptor rows = ArgumentCaptor.forClass(OrderFulfillmentSplitEntity.class); + verify(splits, times(2)).insert(rows.capture()); + assertThat(rows.getAllValues()).allSatisfy(row -> { + assertThat(row.getSourceOrderId()).isEqualTo(100L); + assertThat(row.getSourceDetailJson()).contains("sourceDetailId", "packageCount"); + assertThat(row.getRequestHash()).hasSize(64); + }); + } + + @Test + void validatesRequestHashAgainAfterTakingParentLock() { + OrderFulfillmentSplitEntity replay = new OrderFulfillmentSplitEntity(); + replay.setRequestHash("different"); + when(splits.findByIdempotency(10, 100, "key-2")) + .thenReturn(List.of(), List.of(replay)); + when(splits.lockOrder(10, 100)).thenReturn(parent()); + + assertThatThrownBy(() -> service.split(request(100L, 20, 8, 12, "key-2"), 9)) + .isInstanceOf(BizException.class).hasMessageContaining("请求内容不一致"); + verify(orders, never()).insert(any(OrderEntity.class)); + } + + @Test + void rejectsConsumedOrMismatchedRemainderSource() { + OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity(); + source.setSplitRole("REMAINDER"); + source.setRemainingPackageCount(9); + when(splits.findByIdempotency(10, 100, "key-3")).thenReturn(List.of()); + when(splits.lockOrder(10, 100)).thenReturn(parent()); + when(splits.lockSourceSlice(10, 100, 200)).thenReturn(source); + + assertThatThrownBy(() -> service.split(request(200L, 20, 8, 12, "key-3"), 9)) + .isInstanceOf(BizException.class).hasMessageContaining("余量子订单箱数"); + } + + @Test + void rejectsSourceLinesThatDoNotConservePackages() { + OrderSplitDtos.CreateRequest invalid = new OrderSplitDtos.CreateRequest(10L, 100L, 100L, + 701L, 801L, 20, 8, 12, null, "bad-lines", "op", List.of( + new OrderSplitDtos.QuantityLine(501L, 19)), "operator"); + + assertThatThrownBy(() -> service.split(invalid, 9)) + .isInstanceOf(BizException.class).hasMessageContaining("来源明细箱数"); + verifyNoInteractions(splits, orders); + } + + private static OrderSplitDtos.CreateRequest request(Long sourceOrderId, int source, int loaded, + int remaining, String key) { + return new OrderSplitDtos.CreateRequest(10L, 100L, sourceOrderId, 701L, 801L, + source, loaded, remaining, null, key, "op-" + key, List.of( + new OrderSplitDtos.QuantityLine(501L, 10), + new OrderSplitDtos.QuantityLine(502L, 10)), "operator"); + } + + private static OrderEntity parent() { + OrderEntity parent = new OrderEntity(); + parent.setId(100L); parent.setTenantId(10L); parent.setOrderNo("ORDER-001"); + parent.setOrderRole("MASTER"); parent.setVersion(3); parent.setStatus(OrderStatus.PROCESSING); + return parent; + } +}