引言:为什么需要拆单和合单

拆单场景

  • 一个订单的商品分布在不同仓库,需要从多个仓库发货
  • 部分商品有货,部分商品需要预售
  • 订单重量超过物流限制,需要拆成多个包裹

合单场景

  • 同一客户短时间内下了多个订单,可以合并发货节省运费
  • 促销活动中的凑单场景

这些逻辑看似简单,但实现起来非常复杂,涉及库存、物流、财务等多个方面。


一、订单模型设计

1.1 三层订单模型

┌─────────────────────────────────────────────────────┐
│                   原始订单                           │
│              (Source Order)                         │
│         客户下单时的原始订单                          │
└─────────────────────┬───────────────────────────────┘
                      │ 拆分/合并
                      ▼
┌─────────────────────────────────────────────────────┐
│                   履约订单                           │
│            (Fulfillment Order)                      │
│         实际执行发货的订单单元                        │
└─────────────────────┬───────────────────────────────┘
                      │ 下发
                      ▼
┌─────────────────────────────────────────────────────┐
│                   出库单                             │
│             (Outbound Order)                        │
│           WMS执行的出库指令                          │
└─────────────────────────────────────────────────────┘

1.2 数据模型

/**
 * 原始订单
 */
@Data
public class SourceOrder {
    private String sourceOrderId;     // 原始订单号
    private String channelOrderId;    // 渠道订单号
    private String channel;           // 渠道
    private String buyerId;           // 买家ID
    private Address shippingAddress;  // 收货地址
    private List<SourceOrderItem> items; // 订单明细
    private Money totalAmount;        // 订单总额
    private OrderStatus status;       // 状态
}

/**
 * 履约订单
 */
@Data
public class FulfillmentOrder {
    private String fulfillmentOrderId; // 履约订单号
    private String sourceOrderId;      // 关联原始订单号
    private String warehouseId;        // 发货仓库
    private String carrierId;          // 承运商
    private Address shippingAddress;   // 收货地址
    private List<FulfillmentOrderItem> items; // 履约明细
    private Money shippingFee;         // 运费
    private FulfillmentStatus status;  // 状态
}

/**
 * 订单关系表
 */
@Data
public class OrderRelation {
    private String sourceOrderId;       // 原始订单号
    private String fulfillmentOrderId;  // 履约订单号
    private String relationType;        // 关系类型:SPLIT/MERGE
}

1.3 数据库设计

-- 原始订单表
CREATE TABLE t_source_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    source_order_id VARCHAR(32) NOT NULL,
    channel_order_id VARCHAR(64),
    channel VARCHAR(32),
    buyer_id VARCHAR(64),
    total_amount DECIMAL(12,2),
    currency VARCHAR(8),
    status VARCHAR(32),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_source_order_id (source_order_id)
);

-- 履约订单表
CREATE TABLE t_fulfillment_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    fulfillment_order_id VARCHAR(32) NOT NULL,
    source_order_id VARCHAR(32) NOT NULL,
    warehouse_id VARCHAR(32),
    carrier_id VARCHAR(32),
    shipping_fee DECIMAL(12,2),
    status VARCHAR(32),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_fulfillment_order_id (fulfillment_order_id),
    KEY idx_source_order_id (source_order_id)
);

-- 订单关系表
CREATE TABLE t_order_relation (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    source_order_id VARCHAR(32) NOT NULL,
    fulfillment_order_id VARCHAR(32) NOT NULL,
    relation_type VARCHAR(16) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    KEY idx_source_order_id (source_order_id),
    KEY idx_fulfillment_order_id (fulfillment_order_id)
);

二、拆单策略

2.1 拆单场景分类

场景触发条件处理方式
多仓拆单商品分布在不同仓库按仓库拆分
预售拆单部分商品无货有货先发,无货等待
超重拆单包裹超过物流限重按重量拆分
赠品拆单赠品从不同仓库发赠品单独发货
组合商品拆单组合商品需要拆开拆成单品发货

2.2 多仓拆单

场景:订单包含SKU-A和SKU-B,SKU-A在深圳仓,SKU-B在义乌仓

@Service
public class MultiWarehouseSplitStrategy implements SplitStrategy {

    @Autowired
    private InventoryService inventoryService;

    @Override
    public String getStrategyType() {
        return "MULTI_WAREHOUSE";
    }

    @Override
    public List<FulfillmentOrder> split(SourceOrder sourceOrder) {
        // 1. 获取每个SKU的库存分布
        Map<String, List<InventoryInfo>> skuInventoryMap = new HashMap<>();
        for (SourceOrderItem item : sourceOrder.getItems()) {
            List<InventoryInfo> inventories = inventoryService.getInventory(item.getSkuId());
            skuInventoryMap.put(item.getSkuId(), inventories);
        }

        // 2. 为每个SKU选择最优仓库
        Map<String, String> skuWarehouseMap = selectWarehouse(sourceOrder, skuInventoryMap);

        // 3. 按仓库分组
        Map<String, List<SourceOrderItem>> warehouseItemsMap = new HashMap<>();
        for (SourceOrderItem item : sourceOrder.getItems()) {
            String warehouseId = skuWarehouseMap.get(item.getSkuId());
            warehouseItemsMap.computeIfAbsent(warehouseId, k -> new ArrayList<>()).add(item);
        }

        // 4. 生成履约订单
        List<FulfillmentOrder> result = new ArrayList<>();
        int seq = 1;
        for (Map.Entry<String, List<SourceOrderItem>> entry : warehouseItemsMap.entrySet()) {
            FulfillmentOrder fo = new FulfillmentOrder();
            fo.setFulfillmentOrderId(sourceOrder.getSourceOrderId() + "-" + seq++);
            fo.setSourceOrderId(sourceOrder.getSourceOrderId());
            fo.setWarehouseId(entry.getKey());
            fo.setShippingAddress(sourceOrder.getShippingAddress());
            fo.setItems(convertItems(entry.getValue()));
            result.add(fo);
        }

        return result;
    }

    /**
     * 选择仓库策略:就近发货
     */
    private Map<String, String> selectWarehouse(SourceOrder order,
            Map<String, List<InventoryInfo>> skuInventoryMap) {
        Map<String, String> result = new HashMap<>();
        Address address = order.getShippingAddress();

        for (Map.Entry<String, List<InventoryInfo>> entry : skuInventoryMap.entrySet()) {
            String skuId = entry.getKey();
            List<InventoryInfo> inventories = entry.getValue();

            // 过滤有库存的仓库
            List<InventoryInfo> available = inventories.stream()
                .filter(inv -> inv.getAvailableQty() > 0)
                .collect(Collectors.toList());

            if (available.isEmpty()) {
                throw new InsufficientInventoryException(skuId);
            }

            // 选择最近的仓库
            String nearestWarehouse = available.stream()
                .min(Comparator.comparingDouble(inv ->
                    calculateDistance(inv.getWarehouseId(), address)))
                .map(InventoryInfo::getWarehouseId)
                .orElseThrow();

            result.put(skuId, nearestWarehouse);
        }

        return result;
    }
}

2.3 预售拆单

场景:订单包含SKU-A(有货)和SKU-B(无货,预计7天后到货)

@Service
public class PreSaleSplitStrategy implements SplitStrategy {

    @Override
    public String getStrategyType() {
        return "PRE_SALE";
    }

    @Override
    public List<FulfillmentOrder> split(SourceOrder sourceOrder) {
        // 1. 区分有货和无货商品
        List<SourceOrderItem> inStockItems = new ArrayList<>();
        List<SourceOrderItem> outOfStockItems = new ArrayList<>();

        for (SourceOrderItem item : sourceOrder.getItems()) {
            int availableQty = inventoryService.getAvailableQty(item.getSkuId());
            if (availableQty >= item.getQuantity()) {
                inStockItems.add(item);
            } else {
                outOfStockItems.add(item);
            }
        }

        List<FulfillmentOrder> result = new ArrayList<>();

        // 2. 有货商品立即发货
        if (!inStockItems.isEmpty()) {
            FulfillmentOrder fo = createFulfillmentOrder(
                sourceOrder, inStockItems, "IN_STOCK");
            fo.setStatus(FulfillmentStatus.PENDING_FULFILL);
            result.add(fo);
        }

        // 3. 无货商品等待到货
        if (!outOfStockItems.isEmpty()) {
            FulfillmentOrder fo = createFulfillmentOrder(
                sourceOrder, outOfStockItems, "PRE_SALE");
            fo.setStatus(FulfillmentStatus.WAITING_STOCK);
            result.add(fo);
        }

        return result;
    }
}

2.4 超重拆单

场景:订单总重量5kg,物流限重2kg

@Service
public class OverweightSplitStrategy implements SplitStrategy {

    private static final double MAX_WEIGHT = 2000; // 2kg = 2000g

    @Override
    public String getStrategyType() {
        return "OVERWEIGHT";
    }

    @Override
    public List<FulfillmentOrder> split(SourceOrder sourceOrder) {
        // 1. 计算每个商品的重量
        List<ItemWithWeight> itemsWithWeight = sourceOrder.getItems().stream()
            .map(item -> {
                double weight = skuService.getWeight(item.getSkuId()) * item.getQuantity();
                return new ItemWithWeight(item, weight);
            })
            .collect(Collectors.toList());

        // 2. 装箱算法(首次适应递减算法)
        List<List<SourceOrderItem>> packages = packItems(itemsWithWeight);

        // 3. 生成履约订单
        List<FulfillmentOrder> result = new ArrayList<>();
        int seq = 1;
        for (List<SourceOrderItem> packageItems : packages) {
            FulfillmentOrder fo = createFulfillmentOrder(
                sourceOrder, packageItems, "PKG-" + seq++);
            result.add(fo);
        }

        return result;
    }

    /**
     * 装箱算法:首次适应递减
     */
    private List<List<SourceOrderItem>> packItems(List<ItemWithWeight> items) {
        // 按重量降序排序
        items.sort((a, b) -> Double.compare(b.weight, a.weight));

        List<List<SourceOrderItem>> packages = new ArrayList<>();
        List<Double> packageWeights = new ArrayList<>();

        for (ItemWithWeight item : items) {
            boolean packed = false;

            // 尝试放入现有包裹
            for (int i = 0; i < packages.size(); i++) {
                if (packageWeights.get(i) + item.weight <= MAX_WEIGHT) {
                    packages.get(i).add(item.item);
                    packageWeights.set(i, packageWeights.get(i) + item.weight);
                    packed = true;
                    break;
                }
            }

            // 需要新包裹
            if (!packed) {
                List<SourceOrderItem> newPackage = new ArrayList<>();
                newPackage.add(item.item);
                packages.add(newPackage);
                packageWeights.add(item.weight);
            }
        }

        return packages;
    }
}

三、合单策略

3.1 合单场景

场景触发条件收益
同客户合单同一客户短时间内多个订单节省运费
同地址合单不同客户但同一地址节省运费
凑单合单促销活动凑单满足活动条件

3.2 同客户合单

@Service
public class CustomerMergeStrategy implements MergeStrategy {

    // 合单时间窗口:30分钟
    private static final int MERGE_WINDOW_MINUTES = 30;

    @Override
    public String getStrategyType() {
        return "SAME_CUSTOMER";
    }

    @Override
    public List<FulfillmentOrder> merge(List<SourceOrder> orders) {
        // 1. 按客户+地址分组
        Map<String, List<SourceOrder>> groupedOrders = orders.stream()
            .collect(Collectors.groupingBy(this::getMergeKey));

        List<FulfillmentOrder> result = new ArrayList<>();

        for (Map.Entry<String, List<SourceOrder>> entry : groupedOrders.entrySet()) {
            List<SourceOrder> customerOrders = entry.getValue();

            if (customerOrders.size() == 1) {
                // 单个订单,不需要合并
                result.add(createFulfillmentOrder(customerOrders.get(0)));
            } else {
                // 多个订单,检查是否可以合并
                List<SourceOrder> mergeable = filterMergeable(customerOrders);
                if (mergeable.size() > 1) {
                    result.add(mergeOrders(mergeable));
                } else {
                    // 不能合并,分别处理
                    for (SourceOrder order : customerOrders) {
                        result.add(createFulfillmentOrder(order));
                    }
                }
            }
        }

        return result;
    }

    private String getMergeKey(SourceOrder order) {
        // 客户ID + 地址哈希
        return order.getBuyerId() + "_" + hashAddress(order.getShippingAddress());
    }

    private List<SourceOrder> filterMergeable(List<SourceOrder> orders) {
        // 按下单时间排序
        orders.sort(Comparator.comparing(SourceOrder::getOrderTime));

        List<SourceOrder> mergeable = new ArrayList<>();
        LocalDateTime baseTime = orders.get(0).getOrderTime();

        for (SourceOrder order : orders) {
            // 在时间窗口内的订单可以合并
            if (Duration.between(baseTime, order.getOrderTime()).toMinutes() <= MERGE_WINDOW_MINUTES) {
                mergeable.add(order);
            }
        }

        return mergeable;
    }

    private FulfillmentOrder mergeOrders(List<SourceOrder> orders) {
        FulfillmentOrder fo = new FulfillmentOrder();
        fo.setFulfillmentOrderId(generateMergedOrderId(orders));

        // 合并商品明细
        List<FulfillmentOrderItem> allItems = new ArrayList<>();
        for (SourceOrder order : orders) {
            allItems.addAll(convertItems(order.getItems()));
        }
        fo.setItems(mergeItems(allItems)); // 合并相同SKU

        // 使用第一个订单的地址
        fo.setShippingAddress(orders.get(0).getShippingAddress());

        // 记录关系
        for (SourceOrder order : orders) {
            saveOrderRelation(order.getSourceOrderId(), fo.getFulfillmentOrderId(), "MERGE");
        }

        return fo;
    }

    /**
     * 合并相同SKU的明细
     */
    private List<FulfillmentOrderItem> mergeItems(List<FulfillmentOrderItem> items) {
        Map<String, FulfillmentOrderItem> mergedMap = new LinkedHashMap<>();

        for (FulfillmentOrderItem item : items) {
            String skuId = item.getSkuId();
            if (mergedMap.containsKey(skuId)) {
                FulfillmentOrderItem existing = mergedMap.get(skuId);
                existing.setQuantity(existing.getQuantity() + item.getQuantity());
            } else {
                mergedMap.put(skuId, item);
            }
        }

        return new ArrayList<>(mergedMap.values());
    }
}

四、策略引擎设计

4.1 策略引擎架构

/**
 * 订单处理策略引擎
 */
@Service
public class OrderStrategyEngine {

    @Autowired
    private List<SplitStrategy> splitStrategies;

    @Autowired
    private List<MergeStrategy> mergeStrategies;

    @Autowired
    private StrategyConfigService configService;

    /**
     * 处理订单
     */
    public List<FulfillmentOrder> process(List<SourceOrder> orders) {
        // 1. 获取策略配置
        StrategyConfig config = configService.getConfig();

        // 2. 先执行合单策略
        List<SourceOrder> mergedOrders = orders;
        if (config.isMergeEnabled()) {
            mergedOrders = executeMergeStrategies(orders, config);
        }

        // 3. 再执行拆单策略
        List<FulfillmentOrder> result = new ArrayList<>();
        for (SourceOrder order : mergedOrders) {
            List<FulfillmentOrder> splitOrders = executeSplitStrategies(order, config);
            result.addAll(splitOrders);
        }

        return result;
    }

    private List<FulfillmentOrder> executeSplitStrategies(SourceOrder order, StrategyConfig config) {
        List<FulfillmentOrder> current = List.of(createFulfillmentOrder(order));

        // 按优先级执行拆单策略
        for (String strategyType : config.getSplitStrategyOrder()) {
            SplitStrategy strategy = findSplitStrategy(strategyType);
            if (strategy != null && strategy.shouldApply(order)) {
                List<FulfillmentOrder> newOrders = new ArrayList<>();
                for (FulfillmentOrder fo : current) {
                    newOrders.addAll(strategy.split(fo));
                }
                current = newOrders;
            }
        }

        return current;
    }
}

4.2 策略配置

/**
 * 策略配置
 */
@Data
public class StrategyConfig {
    // 是否启用合单
    private boolean mergeEnabled = true;

    // 合单时间窗口(分钟)
    private int mergeWindowMinutes = 30;

    // 拆单策略执行顺序
    private List<String> splitStrategyOrder = List.of(
        "MULTI_WAREHOUSE",  // 先按仓库拆
        "PRE_SALE",         // 再按预售拆
        "OVERWEIGHT"        // 最后按重量拆
    );

    // 最大包裹重量(克)
    private double maxPackageWeight = 2000;

    // 是否允许部分发货
    private boolean partialShipmentAllowed = true;
}

4.3 规则配置化

-- 策略规则表
CREATE TABLE t_strategy_rule (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    rule_name VARCHAR(64) NOT NULL,
    rule_type VARCHAR(32) NOT NULL,
    condition_expr TEXT,
    action_expr TEXT,
    priority INT DEFAULT 0,
    enabled TINYINT DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 示例规则
INSERT INTO t_strategy_rule (rule_name, rule_type, condition_expr, action_expr, priority) VALUES
('多仓拆单', 'SPLIT', 'items.warehouseCount > 1', 'SPLIT_BY_WAREHOUSE', 100),
('预售拆单', 'SPLIT', 'items.hasOutOfStock == true', 'SPLIT_BY_STOCK', 90),
('超重拆单', 'SPLIT', 'totalWeight > 2000', 'SPLIT_BY_WEIGHT', 80),
('同客户合单', 'MERGE', 'sameCustomer && timeDiff < 30', 'MERGE_BY_CUSTOMER', 100);

五、状态管理

5.1 拆单后的状态同步

@Service
public class OrderStatusSyncService {

    /**
     * 履约订单状态变更时,同步原始订单状态
     */
    @Transactional
    public void onFulfillmentStatusChange(String fulfillmentOrderId, FulfillmentStatus newStatus) {
        // 1. 获取关联的原始订单
        List<String> sourceOrderIds = orderRelationRepository
            .findSourceOrderIds(fulfillmentOrderId);

        for (String sourceOrderId : sourceOrderIds) {
            // 2. 获取该原始订单的所有履约订单状态
            List<FulfillmentOrder> fulfillmentOrders = fulfillmentOrderRepository
                .findBySourceOrderId(sourceOrderId);

            // 3. 计算原始订单状态
            OrderStatus newSourceStatus = calculateSourceOrderStatus(fulfillmentOrders);

            // 4. 更新原始订单状态
            sourceOrderRepository.updateStatus(sourceOrderId, newSourceStatus);
        }
    }

    /**
     * 根据履约订单状态计算原始订单状态
     */
    private OrderStatus calculateSourceOrderStatus(List<FulfillmentOrder> fulfillmentOrders) {
        // 全部完成 -> 已完成
        if (fulfillmentOrders.stream().allMatch(fo ->
                fo.getStatus() == FulfillmentStatus.DELIVERED)) {
            return OrderStatus.COMPLETED;
        }

        // 全部发货 -> 已发货
        if (fulfillmentOrders.stream().allMatch(fo ->
                fo.getStatus() == FulfillmentStatus.SHIPPED ||
                fo.getStatus() == FulfillmentStatus.DELIVERED)) {
            return OrderStatus.SHIPPED;
        }

        // 部分发货 -> 部分发货
        if (fulfillmentOrders.stream().anyMatch(fo ->
                fo.getStatus() == FulfillmentStatus.SHIPPED)) {
            return OrderStatus.PARTIAL_SHIPPED;
        }

        // 其他 -> 履约中
        return OrderStatus.FULFILLING;
    }
}

六、总结

6.1 核心要点

  1. 三层订单模型:原始订单 → 履约订单 → 出库单
  2. 拆单场景:多仓、预售、超重、赠品
  3. 合单场景:同客户、同地址
  4. 策略引擎:可配置、可扩展
  5. 状态同步:履约订单状态变更同步到原始订单

6.2 实施建议

  • 先实现多仓拆单(最常见)
  • 再实现预售拆单(业务需要)
  • 合单功能可以后期添加
  • 策略配置化,便于调整

系列文章导航

本文是《跨境电商数字化转型指南》系列的第15篇

  • 01-14. 前序文章
  • 15. 订单拆分与合并策略(本文)
  • 16. 库存预占与释放