feat(phase3): connect PDA settlement to real child orders
This commit is contained in:
@@ -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}")
|
@Select("SELECT container_booking_id FROM fl_loading_plan WHERE id=#{loadingPlanId}")
|
||||||
Long containerBookingId(@Param("loadingPlanId") long loadingPlanId);
|
Long containerBookingId(@Param("loadingPlanId") long loadingPlanId);
|
||||||
@Select("""
|
@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,
|
root_inbound_id AS rootInboundId,
|
||||||
process_status AS processStatus, total_package_count AS totalPackageCount,
|
process_status AS processStatus, total_package_count AS totalPackageCount,
|
||||||
package_count AS packageCount, version
|
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,
|
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,
|
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)
|
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,
|
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,
|
'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,
|
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,
|
int cloneChildWithPurpose(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
|
||||||
@Param("rootInboundId") long rootInboundId, @Param("childId") long childId,
|
@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("childInboundCode") String childInboundCode, @Param("packageCount") int packageCount,
|
||||||
@Param("splitSequence") int splitSequence, @Param("splitPurpose") String splitPurpose,
|
@Param("splitSequence") int splitSequence, @Param("splitPurpose") String splitPurpose,
|
||||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
@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,
|
int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId,
|
||||||
@Param("measurementId") long measurementId);
|
@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,
|
String processStatus, Integer totalPackageCount, Integer packageCount,
|
||||||
Integer version) {
|
Integer version) {
|
||||||
public ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
|
public ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
|
||||||
String processStatus, Integer totalPackageCount, Integer packageCount, Integer version) {
|
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.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||||
|
import com.baiyue.firstleg.client.OrderFulfillmentSplitClient;
|
||||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||||
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
|
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
|
||||||
@@ -41,6 +42,8 @@ public class PdaPickSettlementService {
|
|||||||
private InboundPalletAllocationRepository allocations;
|
private InboundPalletAllocationRepository allocations;
|
||||||
@Resource
|
@Resource
|
||||||
private WarehousePalletLocationClient warehouse;
|
private WarehousePalletLocationClient warehouse;
|
||||||
|
@Resource
|
||||||
|
private OrderFulfillmentSplitClient orderSplits;
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
@@ -154,6 +157,9 @@ public class PdaPickSettlementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
|
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 loadedChildId = IdWorker.getId();
|
||||||
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
|
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
|
||||||
long batchId = IdWorker.getId();
|
long batchId = IdWorker.getId();
|
||||||
@@ -162,9 +168,11 @@ public class PdaPickSettlementService {
|
|||||||
int parentVersion = root.version() == null ? 0 : root.version();
|
int parentVersion = root.version() == null ? 0 : root.version();
|
||||||
int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId);
|
int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId);
|
||||||
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId,
|
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, loadedChildId,
|
||||||
|
orderSplit.loadedChild().orderId(), orderSplit.loadedChild().orderNo(),
|
||||||
childInboundCode(parent.inboundCode(), loadedChildId), pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单");
|
childInboundCode(parent.inboundCode(), loadedChildId), pickedPackageCount, sequence, "LOADED", operatorId, operatorName), "实装子入库单");
|
||||||
if (remainderChildId != 0L) {
|
if (remainderChildId != 0L) {
|
||||||
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId,
|
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), rootInboundId, rootInboundId, remainderChildId,
|
||||||
|
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
|
||||||
childCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单");
|
childCode, childPackageCount, sequence + 1, "REMAINDER", operatorId, operatorName), "余量子入库单");
|
||||||
}
|
}
|
||||||
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
|
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
|
||||||
@@ -238,6 +246,62 @@ public class PdaPickSettlementService {
|
|||||||
return true;
|
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) {
|
private static Map<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
|
||||||
return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|
return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|
||||||
|| "PARTIAL".equals(item.getItemStatus()))
|
|| "PARTIAL".equals(item.getItemStatus()))
|
||||||
|
|||||||
+25
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -68,9 +68,12 @@ class PdaSplitSettlementSqlContractTest {
|
|||||||
.contains("#{containerBookingId}");
|
.contains("#{containerBookingId}");
|
||||||
|
|
||||||
String clone = InboundSplitSettlementMapper.class.getMethod("cloneChildWithPurpose",
|
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)
|
int.class, String.class, long.class, String.class)
|
||||||
.getAnnotation(Insert.class).value()[0];
|
.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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-8
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
|
|||||||
|
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||||
|
import com.baiyue.firstleg.client.OrderFulfillmentSplitClient;
|
||||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||||
@@ -19,12 +20,14 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ class PdaPickSettlementServiceTest {
|
|||||||
@MockBean private InboundSplitSettlementMapper splits;
|
@MockBean private InboundSplitSettlementMapper splits;
|
||||||
@MockBean private InboundPalletAllocationRepository allocations;
|
@MockBean private InboundPalletAllocationRepository allocations;
|
||||||
@MockBean private WarehousePalletLocationClient warehouse;
|
@MockBean private WarehousePalletLocationClient warehouse;
|
||||||
|
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
||||||
@Resource private PdaPickSettlementService service;
|
@Resource private PdaPickSettlementService service;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -48,8 +52,8 @@ class PdaPickSettlementServiceTest {
|
|||||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||||
when(allocations.lockConfirmed(10001L, 31L))
|
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
|
||||||
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
||||||
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
|
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
|
||||||
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
|
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
|
||||||
@@ -57,9 +61,11 @@ class PdaPickSettlementServiceTest {
|
|||||||
|
|
||||||
service.settle(1L, 9L, "operator");
|
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"));
|
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"));
|
anyInt(), eq("REMAINDER"), eq(9L), eq("operator"));
|
||||||
verify(allocations).consume(10001L, 31L, 1L, 8);
|
verify(allocations).consume(10001L, 31L, 1L, 8);
|
||||||
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
|
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.lockParent(31L)).thenReturn(parent(20));
|
||||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||||
when(allocations.lockConfirmed(10001L, 31L))
|
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
|
||||||
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
|
||||||
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
|
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
|
||||||
List.of(transfer(2, 502L, 10)))));
|
List.of(transfer(2, 502L, 10)))));
|
||||||
@@ -126,7 +132,8 @@ class PdaPickSettlementServiceTest {
|
|||||||
|
|
||||||
verify(allocations).consume(10001L, 31L, 1L, 10);
|
verify(allocations).consume(10001L, 31L, 1L, 10);
|
||||||
verify(allocations).consume(10001L, 31L, 2L, 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"));
|
anyInt(), eq("LOADED"), eq(9L), eq("operator"));
|
||||||
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
|
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
|
||||||
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
|
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
|
||||||
@@ -155,6 +162,44 @@ class PdaPickSettlementServiceTest {
|
|||||||
assertThat(resolution.skippedPlanItemIds()).isEmpty();
|
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) {
|
private void resolved(List<LoadingPickItemEntity> items) {
|
||||||
when(picks.allTaskItemsResolved(1L)).thenReturn(true);
|
when(picks.allTaskItemsResolved(1L)).thenReturn(true);
|
||||||
when(picks.items(1L)).thenReturn(items);
|
when(picks.items(1L)).thenReturn(items);
|
||||||
@@ -164,11 +209,29 @@ class PdaPickSettlementServiceTest {
|
|||||||
task.setId(1L); task.setLoadingPlanId(701L);
|
task.setId(1L); task.setLoadingPlanId(701L);
|
||||||
when(picks.task(1L)).thenReturn(task);
|
when(picks.task(1L)).thenReturn(task);
|
||||||
when(splits.containerBookingId(701L)).thenReturn(801L);
|
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) {
|
private void writes(int childCount, int pickedCount) {
|
||||||
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(1);
|
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);
|
anyInt(), anyString(), eq(9L), eq("operator"))).thenReturn(1);
|
||||||
when(splits.insertBatchWithChildren(anyLong(), eq(10001L), eq(31L), anyString(), eq(31L), anyString(), eq(3),
|
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);
|
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 sourcePackageCount,
|
||||||
@NotNull @Min(1) Integer loadedPackageCount,
|
@NotNull @Min(1) Integer loadedPackageCount,
|
||||||
@NotNull @Min(0) Integer remainingPackageCount,
|
@NotNull @Min(0) Integer remainingPackageCount,
|
||||||
@NotNull @Min(0) Integer expectedParentVersion,
|
@Min(0) Integer expectedParentVersion,
|
||||||
@NotBlank String idempotencyKey,
|
@NotBlank String idempotencyKey,
|
||||||
String operationId,
|
String operationId,
|
||||||
List<QuantityLine> lines,
|
List<QuantityLine> lines,
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
|
|||||||
List<OrderFulfillmentSplitEntity> findByParent(@Param("tenantId") long tenantId,
|
List<OrderFulfillmentSplitEntity> findByParent(@Param("tenantId") long tenantId,
|
||||||
@Param("parentOrderId") long parentOrderId);
|
@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")
|
@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);
|
Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,30 +31,33 @@ public class OrderFulfillmentSplitService {
|
|||||||
String hash = hash(req);
|
String hash = hash(req);
|
||||||
List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
|
List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
|
||||||
if (!replay.isEmpty()) {
|
if (!replay.isEmpty()) {
|
||||||
if (replay.stream().anyMatch(x -> x.getRequestHash()!=null && !hash.equals(x.getRequestHash())))
|
requireMatchingReplay(replay, hash);
|
||||||
throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致");
|
|
||||||
return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay);
|
return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay);
|
||||||
}
|
}
|
||||||
OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId());
|
OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId());
|
||||||
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
|
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 (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 (!"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, "父订单版本已变化,请刷新后重试");
|
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;
|
int next = Optional.ofNullable(splits.maxSplitSequence(req.tenantId(), req.parentOrderId())).orElse(0) + 1;
|
||||||
List<OrderFulfillmentSplitEntity> rows = new ArrayList<>();
|
List<OrderFulfillmentSplitEntity> rows = new ArrayList<>();
|
||||||
OrderSplitDtos.Child loaded = createChild(req, parent, next++, "LOADED", req.loadedPackageCount(), hash, operatorId, rows);
|
OrderSplitDtos.Child loaded = createChild(req, parent, next++, "LOADED", req.loadedPackageCount(), hash, operatorId, rows);
|
||||||
OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0
|
OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0
|
||||||
? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null;
|
? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null;
|
||||||
for (OrderFulfillmentSplitEntity row : rows) splits.insert(row);
|
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>()
|
LambdaUpdateWrapper<OrderEntity> guard = new LambdaUpdateWrapper<OrderEntity>()
|
||||||
.eq(OrderEntity::getId, parent.getId())
|
.eq(OrderEntity::getId, parent.getId())
|
||||||
.eq(OrderEntity::getTenantId, parent.getTenantId())
|
.eq(OrderEntity::getTenantId, parent.getTenantId())
|
||||||
.eq(OrderEntity::getVersion, req.expectedParentVersion());
|
.eq(OrderEntity::getVersion, parentVersion);
|
||||||
int updated = orders.update(parent, guard);
|
int updated = orders.update(parent, guard);
|
||||||
if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
|
if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
|
||||||
return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder,
|
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));
|
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) {
|
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);
|
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 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()); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
|
|||||||
id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
parent_order_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,
|
child_order_id BIGINT UNSIGNED NOT NULL,
|
||||||
split_role VARCHAR(12) NOT NULL COMMENT '本台账子片:LOADED或REMAINDER',
|
split_role VARCHAR(12) NOT NULL COMMENT '本台账子片:LOADED或REMAINDER',
|
||||||
loading_plan_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划逻辑ID',
|
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_idempotency (tenant_id, parent_order_id, idempotency_key, split_role),
|
||||||
UNIQUE KEY uk_order_split_child (tenant_id, child_order_id),
|
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_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),
|
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_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order(id),
|
||||||
CONSTRAINT fk_order_split_child FOREIGN KEY (child_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','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','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;存量记录允许为空'");
|
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;
|
DROP PROCEDURE IF EXISTS phase3_order_drop_split_index;
|
||||||
DELIMITER $$
|
DELIMITER $$
|
||||||
|
|||||||
@@ -274,6 +274,58 @@ components:
|
|||||||
scheme: bearer
|
scheme: bearer
|
||||||
bearerFormat: JWT
|
bearerFormat: JWT
|
||||||
schemas:
|
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:
|
PricingTaxMode:
|
||||||
type: string
|
type: string
|
||||||
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
||||||
@@ -334,6 +386,11 @@ components:
|
|||||||
orderNo: {type: string}
|
orderNo: {type: string}
|
||||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||||
parameters:
|
parameters:
|
||||||
|
InternalToken:
|
||||||
|
name: X-Internal-Service-Token
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
schema: {type: string, minLength: 1}
|
||||||
UnifiedKeyword:
|
UnifiedKeyword:
|
||||||
name: keyword
|
name: keyword
|
||||||
in: query
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user