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,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<Response> 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<QuantityLine> 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) {}
}
@@ -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);
}
}
@@ -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<MeasurementSplitLine> 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<OrderFulfillmentSplitClient.QuantityLine> 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<OrderFulfillmentSplitClient.Response> 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<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|| "PARTIAL".equals(item.getItemStatus()))
@@ -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);
@@ -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<QuantityLine> lines,
@@ -27,6 +27,18 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
List<OrderFulfillmentSplitEntity> 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);
}
@@ -31,30 +31,33 @@ public class OrderFulfillmentSplitService {
String hash = hash(req);
List<OrderFulfillmentSplitEntity> 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<OrderFulfillmentSplitEntity> 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<OrderEntity> guard = new LambdaUpdateWrapper<OrderEntity>()
.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<OrderEntity>().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<OrderFulfillmentSplitEntity> 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<OrderFulfillmentSplitEntity> 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<OrderFulfillmentSplitEntity> 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<OrderFulfillmentSplitEntity> 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()); }
}
+2
View File
@@ -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),
@@ -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 $$
@@ -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
@@ -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:");
}
}
@@ -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<OrderFulfillmentSplitEntity> 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;
}
}