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
@@ -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;
}
}