feat(order): adopt single-remainder fulfillment splits
This commit is contained in:
@@ -48,7 +48,14 @@ public final class OrderSplitDtos {
|
|||||||
|
|
||||||
public record Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion,
|
public record Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion,
|
||||||
List<Child> children, Integer allocatedPackageCount,
|
List<Child> children, Integer allocatedPackageCount,
|
||||||
Integer remainingPackageCount) {}
|
Integer remainingPackageCount, Integer originalPackageCount,
|
||||||
|
Integer cumulativeLoadedPackageCount, Integer currentRemainderPackageCount) {
|
||||||
|
public Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion,
|
||||||
|
List<Child> children, Integer allocatedPackageCount, Integer remainingPackageCount) {
|
||||||
|
this(parentOrderId, parentOrderNo, parentVersion, children, allocatedPackageCount, remainingPackageCount,
|
||||||
|
allocatedPackageCount + remainingPackageCount, allocatedPackageCount, remainingPackageCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record ContainerSlice(Long containerBookingId, Long tenantId,
|
public record ContainerSlice(Long containerBookingId, Long tenantId,
|
||||||
Long parentOrderId, String parentOrderNo,
|
Long parentOrderId, String parentOrderNo,
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
|
|||||||
@Param("parentOrderId") long parentOrderId,
|
@Param("parentOrderId") long parentOrderId,
|
||||||
@Param("sourceOrderId") long sourceOrderId);
|
@Param("sourceOrderId") long sourceOrderId);
|
||||||
|
|
||||||
|
@Select("SELECT remainder.* FROM ord_order_fulfillment_split remainder "
|
||||||
|
+ "WHERE remainder.tenant_id=#{tenantId} AND remainder.parent_order_id=#{parentOrderId} "
|
||||||
|
+ "AND remainder.split_role='REMAINDER' AND NOT EXISTS (SELECT 1 FROM ord_order_fulfillment_split consumed "
|
||||||
|
+ "WHERE consumed.tenant_id=remainder.tenant_id AND consumed.parent_order_id=remainder.parent_order_id "
|
||||||
|
+ "AND consumed.source_order_id=remainder.child_order_id) ORDER BY remainder.id DESC LIMIT 1 FOR UPDATE")
|
||||||
|
OrderFulfillmentSplitEntity lockActiveRemainder(@Param("tenantId") long tenantId,
|
||||||
|
@Param("parentOrderId") long parentOrderId);
|
||||||
|
|
||||||
@Select("SELECT COUNT(*) FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} "
|
@Select("SELECT COUNT(*) FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} "
|
||||||
+ "AND parent_order_id=#{parentOrderId} AND source_order_id=#{sourceOrderId}")
|
+ "AND parent_order_id=#{parentOrderId} AND source_order_id=#{sourceOrderId}")
|
||||||
int consumedSourceCount(@Param("tenantId") long tenantId,
|
int consumedSourceCount(@Param("tenantId") long tenantId,
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.order.dto.OrderSplitDtos;
|
import com.baiyue.order.dto.OrderSplitDtos;
|
||||||
import com.baiyue.order.entity.OrderEntity;
|
import com.baiyue.order.entity.OrderEntity;
|
||||||
|
import com.baiyue.order.entity.OrderFulfillmentSplitEntity;
|
||||||
import com.baiyue.order.mapper.OrderFulfillmentSplitMapper;
|
import com.baiyue.order.mapper.OrderFulfillmentSplitMapper;
|
||||||
import com.baiyue.order.mapper.OrderMapper;
|
import com.baiyue.order.mapper.OrderMapper;
|
||||||
import com.baiyue.order.entity.OrderFulfillmentSplitEntity;
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/** 订单域父子单/柜次数量契约。仅生成一层 CHILD,不复制原订单身份。 */
|
/** 订单域父子单/柜次数量契约。新模型只创建余量子单,来源订单承接本次实装。 */
|
||||||
@Service
|
@Service
|
||||||
public class OrderFulfillmentSplitService {
|
public class OrderFulfillmentSplitService {
|
||||||
@Resource private OrderFulfillmentSplitMapper splits;
|
@Resource private OrderFulfillmentSplitMapper splits;
|
||||||
@@ -39,67 +40,165 @@ public class OrderFulfillmentSplitService {
|
|||||||
requireMatchingReplay(replay, hash);
|
requireMatchingReplay(replay, hash);
|
||||||
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 root = splits.lockOrder(req.tenantId(), req.parentOrderId());
|
||||||
|
if (root == null) throw new BizException(ResultCode.NOT_FOUND, "根订单不存在或不属于该客户");
|
||||||
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
|
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
|
||||||
if (!replay.isEmpty()) {
|
if (!replay.isEmpty()) {
|
||||||
requireMatchingReplay(replay, hash);
|
requireMatchingReplay(replay, hash);
|
||||||
return response(req, parent, replay);
|
return response(req, root, replay);
|
||||||
}
|
}
|
||||||
if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户");
|
if (!"MASTER".equals(root.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "根订单层级非法");
|
||||||
if (!"MASTER".equals(parent.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "子订单不能继续拆分");
|
if (req.expectedParentVersion() != null && root.getVersion() != null
|
||||||
requireUnconsumedSource(req, parent);
|
&& !req.expectedParentVersion().equals(root.getVersion()))
|
||||||
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;
|
|
||||||
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);
|
|
||||||
int updated = splits.incrementParentVersion(parent.getTenantId(), parent.getId(), parentVersion, operatorId);
|
|
||||||
if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
|
|
||||||
parent.setVersion(parentVersion + 1);
|
|
||||||
return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder,
|
|
||||||
req.sourcePackageCount(), req.loadedPackageCount(), req.remainingPackageCount(), req.loadingPlanId(), req.containerBookingId(), req.idempotencyKey(), LocalDateTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public OrderSplitDtos.Tree tree(long tenantId, long parentId) {
|
OrderFulfillmentSplitEntity activeRemainder = splits.lockActiveRemainder(req.tenantId(), root.getId());
|
||||||
OrderEntity parent = splits.findOrder(tenantId, parentId);
|
OrderEntity source = root;
|
||||||
if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
|
if (!req.parentOrderId().equals(req.sourceOrderId())) {
|
||||||
if (!"MASTER".equals(parent.getOrderRole())) parentId = Optional.ofNullable(parent.getParentOrderId()).orElse(parentId);
|
source = splits.lockOrder(req.tenantId(), req.sourceOrderId());
|
||||||
parent = splits.findOrder(tenantId, parentId);
|
if (source == null || !"CHILD".equals(source.getOrderRole())
|
||||||
List<OrderFulfillmentSplitEntity> rows = splits.findByParent(tenantId, parentId);
|
|| !Objects.equals(source.getParentOrderId(), root.getId()))
|
||||||
Map<Long,OrderEntity> children = new HashMap<>();
|
throw new BizException(ResultCode.CONFLICT, "来源订单必须是根订单下的余量子订单");
|
||||||
for (OrderFulfillmentSplitEntity row: rows) { OrderEntity child=orders.selectById(row.getChildOrderId()); if(child!=null&&tenantId==child.getTenantId()) children.put(child.getId(),child); }
|
OrderFulfillmentSplitEntity sourceLedger = splits.lockSourceSlice(req.tenantId(), root.getId(), source.getId());
|
||||||
List<OrderSplitDtos.Child> result = rows.stream().map(r -> { OrderEntity c=children.get(r.getChildOrderId()); return new OrderSplitDtos.Child(r.getChildOrderId(), c==null?null:c.getOrderNo(), c==null?null:c.getSplitSequence(), r.getSplitRole(), "LOADED".equals(r.getSplitRole())?r.getLoadedPackageCount():r.getRemainingPackageCount(), r.getSourceOrderId(), r.getLoadingPlanId(), r.getContainerBookingId()); }).toList();
|
if (sourceLedger == null || !"REMAINDER".equals(sourceLedger.getSplitRole())
|
||||||
int allocated = rows.stream().filter(r -> "LOADED".equals(r.getSplitRole())).mapToInt(OrderFulfillmentSplitEntity::getLoadedPackageCount).sum();
|
|| !Objects.equals(sourceLedger.getRemainingPackageCount(), req.sourcePackageCount()))
|
||||||
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 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, "余量子订单箱数与本次来源箱数不一致");
|
throw new BizException(ResultCode.CONFLICT, "余量子订单箱数与本次来源箱数不一致");
|
||||||
}
|
if (activeRemainder == null || !Objects.equals(activeRemainder.getChildOrderId(), source.getId()))
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "来源订单不是当前有效余量子订单");
|
||||||
|
} else if (activeRemainder != null) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "根订单已有有效余量,必须从当前余量子订单继续装柜");
|
||||||
}
|
}
|
||||||
if (splits.consumedSourceCount(request.tenantId(), parent.getId(), request.sourceOrderId()) != 0) {
|
if (splits.consumedSourceCount(req.tenantId(), root.getId(), source.getId()) != 0)
|
||||||
throw new BizException(ResultCode.CONFLICT, "来源订单已经完成履约拆分,不能重复消费");
|
throw new BizException(ResultCode.CONFLICT, "来源订单已经完成履约拆分,不能重复消费");
|
||||||
|
|
||||||
|
int rootVersion = root.getVersion() == null ? 0 : root.getVersion();
|
||||||
|
int sequence = Optional.ofNullable(splits.maxSplitSequence(req.tenantId(), root.getId())).orElse(0) + 1;
|
||||||
|
List<OrderFulfillmentSplitEntity> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
// 来源订单本身承接本次实装;该台账行保留 LOADED 语义以兼容旧消费者。
|
||||||
|
OrderSplitDtos.Child loaded = new OrderSplitDtos.Child(source.getId(), source.getOrderNo(), source.getSplitSequence(),
|
||||||
|
"LOADED", req.loadedPackageCount(), source.getId(), req.loadingPlanId(), req.containerBookingId());
|
||||||
|
rows.add(ledger(req, root, source, source.getId(), "LOADED", hash, operatorId));
|
||||||
|
|
||||||
|
OrderSplitDtos.Child remainder = null;
|
||||||
|
if (req.remainingPackageCount() > 0) {
|
||||||
|
OrderEntity child = cloneRemainder(req, root, sequence, operatorId);
|
||||||
|
remainder = new OrderSplitDtos.Child(child.getId(), child.getOrderNo(), sequence, "REMAINDER",
|
||||||
|
req.remainingPackageCount(), source.getId(), req.loadingPlanId(), req.containerBookingId());
|
||||||
|
rows.add(ledger(req, root, source, child.getId(), "REMAINDER", hash, operatorId));
|
||||||
}
|
}
|
||||||
|
for (OrderFulfillmentSplitEntity row : rows) {
|
||||||
|
if (splits.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "订单拆分台账写入失败");
|
||||||
|
}
|
||||||
|
if (splits.incrementParentVersion(root.getTenantId(), root.getId(), rootVersion, operatorId) != 1)
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试");
|
||||||
|
return new OrderSplitDtos.Response(root.getId(), root.getOrderNo(), rootVersion + 1, loaded, remainder,
|
||||||
|
req.sourcePackageCount(), req.loadedPackageCount(), req.remainingPackageCount(), req.loadingPlanId(),
|
||||||
|
req.containerBookingId(), req.idempotencyKey(), LocalDateTime.now());
|
||||||
}
|
}
|
||||||
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);
|
public OrderSplitDtos.Tree tree(long tenantId, long orderId) {
|
||||||
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());
|
OrderEntity requested = splits.findOrder(tenantId, orderId);
|
||||||
|
if (requested == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
|
||||||
|
long rootId = "MASTER".equals(requested.getOrderRole()) ? requested.getId()
|
||||||
|
: Optional.ofNullable(requested.getRootOrderId()).orElse(requested.getParentOrderId());
|
||||||
|
OrderEntity root = splits.findOrder(tenantId, rootId);
|
||||||
|
List<OrderFulfillmentSplitEntity> rows = splits.findByParent(tenantId, rootId);
|
||||||
|
Map<Long, OrderEntity> ordersById = new HashMap<>();
|
||||||
|
for (OrderFulfillmentSplitEntity row : rows) {
|
||||||
|
OrderEntity child = orders.selectById(row.getChildOrderId());
|
||||||
|
if (child != null && tenantId == child.getTenantId()) ordersById.put(child.getId(), child);
|
||||||
|
}
|
||||||
|
List<OrderSplitDtos.Child> fragments = rows.stream().map(r -> {
|
||||||
|
OrderEntity child = ordersById.get(r.getChildOrderId());
|
||||||
|
int count = "LOADED".equals(r.getSplitRole()) ? r.getLoadedPackageCount() : r.getRemainingPackageCount();
|
||||||
|
return new OrderSplitDtos.Child(r.getChildOrderId(), child == null ? null : child.getOrderNo(),
|
||||||
|
child == null ? null : child.getSplitSequence(), r.getSplitRole(), count,
|
||||||
|
r.getSourceOrderId(), r.getLoadingPlanId(), r.getContainerBookingId());
|
||||||
|
}).toList();
|
||||||
|
int cumulativeLoaded = rows.stream().filter(r -> "LOADED".equals(r.getSplitRole()))
|
||||||
|
.mapToInt(OrderFulfillmentSplitEntity::getLoadedPackageCount).sum();
|
||||||
|
int original = rows.stream().map(OrderFulfillmentSplitEntity::getSourcePackageCount)
|
||||||
|
.filter(Objects::nonNull).max(Integer::compareTo).orElse(0);
|
||||||
|
Set<Long> consumed = new HashSet<>();
|
||||||
|
rows.stream().filter(r -> "LOADED".equals(r.getSplitRole())).map(OrderFulfillmentSplitEntity::getSourceOrderId)
|
||||||
|
.forEach(consumed::add);
|
||||||
|
int currentRemainder = rows.stream().filter(r -> "REMAINDER".equals(r.getSplitRole())
|
||||||
|
&& !consumed.contains(r.getChildOrderId()))
|
||||||
|
.mapToInt(OrderFulfillmentSplitEntity::getRemainingPackageCount).sum();
|
||||||
|
return new OrderSplitDtos.Tree(root.getId(), root.getOrderNo(), root.getVersion(), fragments,
|
||||||
|
cumulativeLoaded, currentRemainder, original, cumulativeLoaded, currentRemainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderEntity cloneRemainder(OrderSplitDtos.CreateRequest r, OrderEntity root, int sequence, long operatorId) {
|
||||||
|
OrderEntity child = new OrderEntity();
|
||||||
|
child.setId(IdWorker.getId()); child.setTenantId(root.getTenantId()); child.setOrderNo(root.getOrderNo() + "-" + sequence);
|
||||||
|
child.setOrderRole("CHILD"); child.setParentOrderId(root.getId()); child.setRootOrderId(root.getId()); child.setSplitSequence(sequence);
|
||||||
|
child.setStatus(root.getStatus()); child.setCustomerId(root.getCustomerId()); child.setCustomerName(root.getCustomerName());
|
||||||
|
child.setTransportMode(root.getTransportMode()); child.setTradeTerm(root.getTradeTerm());
|
||||||
|
child.setDestinationCountryCode(root.getDestinationCountryCode()); child.setDestinationWarehouseId(root.getDestinationWarehouseId());
|
||||||
|
child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId());
|
||||||
|
child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode());
|
||||||
|
child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType());
|
||||||
|
child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId);
|
||||||
|
if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败");
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderFulfillmentSplitEntity ledger(OrderSplitDtos.CreateRequest r, OrderEntity root, OrderEntity source,
|
||||||
|
long childId, String role, String hash, long operatorId) {
|
||||||
|
OrderFulfillmentSplitEntity row = new OrderFulfillmentSplitEntity();
|
||||||
|
row.setId(IdWorker.getId()); row.setTenantId(r.tenantId()); row.setParentOrderId(root.getId()); row.setSourceOrderId(source.getId());
|
||||||
|
row.setChildOrderId(childId); 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(root.getVersion() == null ? 0 : root.getVersion()));
|
||||||
|
row.setCreatedBy(operatorId); row.setCreatedByName(r.operatorName()); row.setCreatedAt(LocalDateTime.now());
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 value) {
|
||||||
|
try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder result = new StringBuilder(); for (byte b : digest) result.append(String.format("%02x", b)); return result.toString();
|
||||||
|
} catch (Exception exception) { throw new BizException("无法生成请求指纹"); }
|
||||||
|
}
|
||||||
|
private OrderSplitDtos.Response response(OrderSplitDtos.CreateRequest r, OrderEntity root, List<OrderFulfillmentSplitEntity> rows) {
|
||||||
|
OrderSplitDtos.Child loaded = null, remainder = null;
|
||||||
|
for (OrderFulfillmentSplitEntity row : rows) {
|
||||||
|
OrderEntity child = orders.selectById(row.getChildOrderId());
|
||||||
|
OrderSplitDtos.Child value = new OrderSplitDtos.Child(row.getChildOrderId(), child == null ? null : child.getOrderNo(),
|
||||||
|
child == null ? null : child.getSplitSequence(), row.getSplitRole(),
|
||||||
|
"LOADED".equals(row.getSplitRole()) ? row.getLoadedPackageCount() : row.getRemainingPackageCount(),
|
||||||
|
row.getSourceOrderId(), row.getLoadingPlanId(), row.getContainerBookingId());
|
||||||
|
if ("LOADED".equals(row.getSplitRole())) loaded = value; else remainder = value;
|
||||||
|
}
|
||||||
|
return new OrderSplitDtos.Response(root.getId(), root.getOrderNo(), root.getVersion(), loaded, remainder,
|
||||||
|
r.sourcePackageCount(), r.loadedPackageCount(), r.remainingPackageCount(), r.loadingPlanId(), r.containerBookingId(),
|
||||||
|
r.idempotencyKey(), rows.get(0).getCreatedAt());
|
||||||
}
|
}
|
||||||
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(),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()); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,13 +243,13 @@ paths:
|
|||||||
|
|
||||||
/internal/order/v1/fulfillment-splits/{tenantId}/{parentOrderId}:
|
/internal/order/v1/fulfillment-splits/{tenantId}/{parentOrderId}:
|
||||||
get:
|
get:
|
||||||
summary: 查询父子订单柜次树(Firstleg 内部)
|
summary: 查询根订单、已完成分片与当前余量(Firstleg 内部)
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/InternalToken'
|
- $ref: '#/components/parameters/InternalToken'
|
||||||
- { name: tenantId, in: path, required: true, schema: { type: integer, format: int64 } }
|
- { name: tenantId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
- { name: parentOrderId, in: path, required: true, schema: { type: integer, format: int64 } }
|
- { name: parentOrderId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||||
responses:
|
responses:
|
||||||
'200': { description: 成功 }
|
'200': { description: 成功;返回原始箱数、累计已装和当前余量 }
|
||||||
'403': { description: 内部凭证无效 }
|
'403': { description: 内部凭证无效 }
|
||||||
|
|
||||||
/api/platform/v1/orders/{orderId}/fulfillment-tree:
|
/api/platform/v1/orders/{orderId}/fulfillment-tree:
|
||||||
@@ -338,14 +338,33 @@ components:
|
|||||||
parentOrderId: {type: integer, format: int64}
|
parentOrderId: {type: integer, format: int64}
|
||||||
parentOrderNo: {type: string}
|
parentOrderNo: {type: string}
|
||||||
parentVersion: {type: integer, minimum: 0}
|
parentVersion: {type: integer, minimum: 0}
|
||||||
loadedChild: {$ref: '#/components/schemas/FulfillmentSplitChild'}
|
loadedChild:
|
||||||
remainderChild: {$ref: '#/components/schemas/FulfillmentSplitChild', nullable: true}
|
allOf: [{$ref: '#/components/schemas/FulfillmentSplitChild'}]
|
||||||
|
description: 新写入时指向承接本次实装的来源订单本身;历史双子单仍返回原已装子订单
|
||||||
|
remainderChild:
|
||||||
|
allOf: [{$ref: '#/components/schemas/FulfillmentSplitChild'}]
|
||||||
|
nullable: true
|
||||||
|
description: 未装数量大于0时唯一创建的余量子订单;全部装完时为空
|
||||||
sourcePackageCount: {type: integer, minimum: 1}
|
sourcePackageCount: {type: integer, minimum: 1}
|
||||||
loadedPackageCount: {type: integer, minimum: 1}
|
loadedPackageCount: {type: integer, minimum: 1}
|
||||||
remainingPackageCount: {type: integer, minimum: 0}
|
remainingPackageCount: {type: integer, minimum: 0}
|
||||||
loadingPlanId: {type: integer, format: int64}
|
loadingPlanId: {type: integer, format: int64}
|
||||||
containerBookingId: {type: integer, format: int64}
|
containerBookingId: {type: integer, format: int64}
|
||||||
idempotencyKey: {type: string}
|
idempotencyKey: {type: string}
|
||||||
|
FulfillmentSplitTree:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
parentOrderId: {type: integer, format: int64}
|
||||||
|
parentOrderNo: {type: string}
|
||||||
|
parentVersion: {type: integer, minimum: 0}
|
||||||
|
children:
|
||||||
|
type: array
|
||||||
|
items: {$ref: '#/components/schemas/FulfillmentSplitChild'}
|
||||||
|
allocatedPackageCount: {type: integer, minimum: 0, deprecated: true}
|
||||||
|
remainingPackageCount: {type: integer, minimum: 0, deprecated: true}
|
||||||
|
originalPackageCount: {type: integer, minimum: 0, description: 根订单最初箱数}
|
||||||
|
cumulativeLoadedPackageCount: {type: integer, minimum: 0, description: 所有已完成来源分片累计实装箱数}
|
||||||
|
currentRemainderPackageCount: {type: integer, minimum: 0, description: 当前唯一有效余量箱数}
|
||||||
PricingTaxMode:
|
PricingTaxMode:
|
||||||
type: string
|
type: string
|
||||||
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baiyue.order.mapper.OrderFulfillmentSplitMapper;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@@ -41,6 +42,19 @@ class OrderFulfillmentSplitContractTest {
|
|||||||
.contains("KEY idx_order_split_source (tenant_id, source_order_id, split_role)");
|
.contains("KEY idx_order_split_source (tenant_id, source_order_id, split_role)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeRemainderIsDerivedUnderTheRootLock() throws Exception {
|
||||||
|
Select select = OrderFulfillmentSplitMapper.class
|
||||||
|
.getMethod("lockActiveRemainder", long.class, long.class)
|
||||||
|
.getAnnotation(Select.class);
|
||||||
|
|
||||||
|
assertThat(String.join(" ", select.value()))
|
||||||
|
.contains("split_role='REMAINDER'")
|
||||||
|
.contains("consumed.source_order_id=remainder.child_order_id")
|
||||||
|
.contains("parent_order_id=#{parentOrderId}")
|
||||||
|
.contains("FOR UPDATE");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiPublishesCompleteInternalSplitContract() throws Exception {
|
void openApiPublishesCompleteInternalSplitContract() throws Exception {
|
||||||
String yaml = new ClassPathResource("openapi/order-v1.yaml")
|
String yaml = new ClassPathResource("openapi/order-v1.yaml")
|
||||||
@@ -54,6 +68,11 @@ class OrderFulfillmentSplitContractTest {
|
|||||||
.contains("containerBookingId:")
|
.contains("containerBookingId:")
|
||||||
.contains("expectedParentVersion:")
|
.contains("expectedParentVersion:")
|
||||||
.contains("FulfillmentSplitResponse:")
|
.contains("FulfillmentSplitResponse:")
|
||||||
|
.contains("新写入时指向承接本次实装的来源订单本身")
|
||||||
|
.contains("FulfillmentSplitTree:")
|
||||||
|
.contains("originalPackageCount:")
|
||||||
|
.contains("cumulativeLoadedPackageCount:")
|
||||||
|
.contains("currentRemainderPackageCount:")
|
||||||
.contains("InternalToken:")
|
.contains("InternalToken:")
|
||||||
.contains("/api/platform/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
|
.contains("/api/platform/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
|
||||||
.contains("/api/merchant/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
|
.contains("/api/merchant/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
|
||||||
|
|||||||
@@ -99,14 +99,69 @@ class OrderFulfillmentSplitServiceTest {
|
|||||||
OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity();
|
OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity();
|
||||||
source.setSplitRole("REMAINDER");
|
source.setSplitRole("REMAINDER");
|
||||||
source.setRemainingPackageCount(9);
|
source.setRemainingPackageCount(9);
|
||||||
|
OrderEntity sourceOrder = new OrderEntity();
|
||||||
|
sourceOrder.setId(200L); sourceOrder.setTenantId(10L); sourceOrder.setOrderRole("CHILD"); sourceOrder.setParentOrderId(100L);
|
||||||
when(splits.findByIdempotency(10, 100, "key-3")).thenReturn(List.of());
|
when(splits.findByIdempotency(10, 100, "key-3")).thenReturn(List.of());
|
||||||
when(splits.lockOrder(10, 100)).thenReturn(parent());
|
when(splits.lockOrder(10, 100)).thenReturn(parent());
|
||||||
|
when(splits.lockOrder(10, 200)).thenReturn(sourceOrder);
|
||||||
when(splits.lockSourceSlice(10, 100, 200)).thenReturn(source);
|
when(splits.lockSourceSlice(10, 100, 200)).thenReturn(source);
|
||||||
|
when(splits.lockActiveRemainder(10, 100)).thenReturn(source);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.split(request(200L, 20, 8, 12, "key-3"), 9))
|
assertThatThrownBy(() -> service.split(request(200L, 20, 8, 12, "key-3"), 9))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("余量子订单箱数");
|
.isInstanceOf(BizException.class).hasMessageContaining("余量子订单箱数");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullLoadCompletesSourceWithoutCreatingAChild() {
|
||||||
|
OrderEntity parent = parent();
|
||||||
|
when(splits.findByIdempotency(10, 100, "key-full")).thenReturn(List.of());
|
||||||
|
when(splits.lockOrder(10, 100)).thenReturn(parent);
|
||||||
|
when(splits.lockActiveRemainder(10, 100)).thenReturn(null);
|
||||||
|
when(splits.consumedSourceCount(10, 100, 100)).thenReturn(0);
|
||||||
|
when(splits.maxSplitSequence(10, 100)).thenReturn(0);
|
||||||
|
when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1);
|
||||||
|
when(splits.incrementParentVersion(10, 100, 3, 9)).thenReturn(1);
|
||||||
|
|
||||||
|
OrderSplitDtos.Response response = service.split(request(100L, 20, 20, 0, "key-full"), 9);
|
||||||
|
|
||||||
|
assertThat(response.loadedChild().orderId()).isEqualTo(100L);
|
||||||
|
assertThat(response.remainderChild()).isNull();
|
||||||
|
verify(orders, never()).insert(any(OrderEntity.class));
|
||||||
|
verify(splits, times(1)).insert(any(OrderFulfillmentSplitEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chainUsesRootBasedSequenceAndOnlyCreatesNextRemainder() {
|
||||||
|
OrderEntity root = parent();
|
||||||
|
OrderEntity sourceOrder = new OrderEntity();
|
||||||
|
sourceOrder.setId(200L); sourceOrder.setTenantId(10L); sourceOrder.setOrderRole("CHILD");
|
||||||
|
sourceOrder.setParentOrderId(100L); sourceOrder.setRootOrderId(100L); sourceOrder.setOrderNo("ORDER-001-1");
|
||||||
|
OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity();
|
||||||
|
source.setChildOrderId(200L); source.setSplitRole("REMAINDER"); source.setRemainingPackageCount(20);
|
||||||
|
when(splits.findByIdempotency(10, 100, "key-chain")).thenReturn(List.of());
|
||||||
|
when(splits.lockOrder(10, 100)).thenReturn(root);
|
||||||
|
when(splits.lockOrder(10, 200)).thenReturn(sourceOrder);
|
||||||
|
when(splits.lockActiveRemainder(10, 100)).thenReturn(source);
|
||||||
|
when(splits.lockSourceSlice(10, 100, 200)).thenReturn(source);
|
||||||
|
when(splits.consumedSourceCount(10, 100, 200)).thenReturn(0);
|
||||||
|
when(splits.maxSplitSequence(10, 100)).thenReturn(1);
|
||||||
|
when(orders.insert(any(OrderEntity.class))).thenAnswer(invocation -> {
|
||||||
|
OrderEntity child = invocation.getArgument(0);
|
||||||
|
assertThat(child.getOrderNo()).isEqualTo("ORDER-001-2");
|
||||||
|
assertThat(child.getParentOrderId()).isEqualTo(100L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
when(splits.insert(any(OrderFulfillmentSplitEntity.class))).thenReturn(1);
|
||||||
|
when(splits.incrementParentVersion(10, 100, 3, 9)).thenReturn(1);
|
||||||
|
|
||||||
|
OrderSplitDtos.Response response = service.split(request(200L, 20, 8, 12, "key-chain"), 9);
|
||||||
|
|
||||||
|
assertThat(response.loadedChild().orderId()).isEqualTo(200L);
|
||||||
|
assertThat(response.remainderChild().orderNo()).isEqualTo("ORDER-001-2");
|
||||||
|
verify(orders, times(1)).insert(any(OrderEntity.class));
|
||||||
|
verify(splits, times(2)).insert(any(OrderFulfillmentSplitEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsSourceLinesThatDoNotConservePackages() {
|
void rejectsSourceLinesThatDoNotConservePackages() {
|
||||||
OrderSplitDtos.CreateRequest invalid = new OrderSplitDtos.CreateRequest(10L, 100L, 100L,
|
OrderSplitDtos.CreateRequest invalid = new OrderSplitDtos.CreateRequest(10L, 100L, 100L,
|
||||||
|
|||||||
Reference in New Issue
Block a user