DEV Community

life
life

Posted on

Splitting one order into two parcels without lying to anyone

A support ticket arrives as a screenshot. The customer bought three items, received two, and the storefront still says the order is pending. The warehouse says both shipments went out on time. Nobody is wrong, and everything is broken, which is the signature of a split order handled at the wrong layer.

Splitting is not an exception path in fulfillment software. It is the normal path for any warehouse holding mixed stock depths, and the systems that treat it as a corner case fail in exactly the ways support teams describe as mysterious.

The model mistake

Most order systems store an order and a shipment as the same object, because at launch every order was one parcel. The moment a split happens, that object has to be two things at once: the commercial promise the customer bought, and the physical thing a carrier label was printed for.

Keep them separate from the start.

type Order = {
  id: string;
  currency: string;
  items: OrderLine[];               // quantity bought, allocated, shipped
  freightChargedOnce: Money;        // what the customer paid, billed a single time
  splits: Shipment[];
};

type Shipment = {
  id: string;
  orderId: string;
  lines: ShipmentLine[];       // subset of order lines with quantities
  trackingNumber: string | null;
  carrier: string;
  entry: CustomsEntry | null;  // one per shipment, never shared
  state: 'draft' | 'picked' | 'packed' | 'tendered' | 'inTransit'
       | 'delivered' | 'failed';
};
Enter fullscreen mode Exit fullscreen mode

The invariant that prevents the screenshot is the first one: the customer is charged for shipping once, on the order, and a split never creates a second charge. Systems get this wrong when the shipping calculation is attached to the shipment record and the split inherits whatever the pricing engine last returned.

create table shipment (
  id            uuid primary key,
  order_id      uuid not null references "order"(id),
  seq           int  not null,
  unique (order_id, seq)
);

create or replace function assert_single_freight_charge() returns trigger language plpgsql as $$
begin
  if exists (select 1 from "order" where id = new.order_id and shipping_charged_count > 1) then
    raise exception 'order % charged freight more than once', new.order_id;
  end if;
  return new;
end $$;
Enter fullscreen mode Exit fullscreen mode

Deciding the split

Allocation runs before split logic, not after. Attempt to satisfy each line from one location and one availability window, then partition.

function planShipments(order: Order, stock: StockIndex): ShipmentPlan[] {
  const groups = new Map<string, ShipmentLine[]>();

  for (const line of order.items) {
    const qty = line.quantity - line.quantityShipped;
    if (qty <= 0) continue;

    for (const chunk of allocate(line.sku, qty, stock)) {
      const key = `${chunk.locationId}|${chunk.availabilityBucket}|${chunk.dangerousGoods}`;
      push(groups, key, { ...line, quantity: chunk.quantity });
    }
  }

  return [...groups].map(([key, lines], i) => ({
    seq: i + 1,
    reason: splitReason(key),   // 'availability' | 'location' | 'hazmat' | 'size'
    lines,
  }));
}
Enter fullscreen mode Exit fullscreen mode

availabilityBucket is the one people leave out. Two lines both in stock but one held for a pending quality check should not ship together, and a split driven only by location will happily put both in the same box and then hold the box. The split key has to contain every dimension on which "together or not" can change, and those dimensions are: physical location, availability status, handling class, size or weight band, and any destination rule that binds only some items.

Store the reason. A split without a recorded reason cannot be audited, and the first question your operations lead asks during a spike is why the split rate doubled this week. If the answer lives only in code, it will be guessed.

Every shipment gets its own downstream objects

Once the partition exists, three things have to be per shipment rather than per order, and each of them is a place where a naive implementation leaks.

Labels and tracking. One label per shipment, one tracking number per shipment, and the storefront shows one delivery per box instead of a single progress bar. A progress bar for a split order is a lie by construction.

Idempotent status ingestion. Carrier webhooks arrive twice and out of order, and with two shipments from one order you now also get interleaving. Key the state machine on shipmentId + status + occurredAt, reject stale transitions, and never derive order status by reading the last event. Order status should be a fold over its shipments.

const orderStatus = (s: Shipment[]) =>
  s.every(x => x.state === 'delivered') ? 'delivered'
  : s.some(x => x.state === 'delivered') ? 'partiallyDelivered'
  : s.some(x => ['tendered','inTransit'].includes(x.state)) ? 'inTransit'
  : 'preparing';
Enter fullscreen mode Exit fullscreen mode

Customs entries. This is the one that stopped being theoretical in 2026. A shipment crossing into the United States is now declared, not waved through, and a declaration belongs to a shipment. Splitting an order therefore splits the declared value across two filings, and each filing needs its own line-level classification and its own value. Two consequences follow that a pure logistics model never sees.

The first is threshold sensitivity. Value bands decide which entry procedure applies, so a split can move a parcel from one procedural bucket to another, and the cost of the split is not just the second box and the second label. If your pricing code assumes freight cost scales with weight only, it will be wrong on the day someone splits an order to save a delivery.

The second is that arbitrary re-splitting to sit under a threshold is not an optimization. It is misrepresentation, because the underlying transaction is a single sale to a single person. Encode that as a hard rule, not a guideline, and let the split planner see the constraint instead of discovering it in a compliance review.

const canSplit = (plan: ShipmentPlan[], rule: EntryRule) => {
  if (plan.length > 1 && sameBuyerSameDay(plan) && rule.aggregatesByBuyerDay) {
    return { ok: false, reason: 'value_aggregation_required' };
  }
  return { ok: true };
};
Enter fullscreen mode Exit fullscreen mode

Tests worth writing before the traffic does it for you

A two-line order with one line out of stock produces two shipments, one freight charge, and an order status of partiallyDelivered once the first is delivered. A webhook replayed after a later status does not regress the shipment. A split where the second shipment is cancelled returns the reserved stock without touching the first shipment's label. A carrier event that arrives with a shipment id belonging to a different order is rejected loudly instead of matched fuzzily. And the fold above returns delivered only when every shipment is delivered, which sounds obvious and is the assertion that catches an implementation where the last event wins.

What the customer should see

Nothing exotic. Two deliveries, each with its own contents, its own tracking link, and its own expected arrival if you can compute one honestly. The order page should make the split visible when it happens, not after the first parcel arrives, because a customer who knows a second box is coming never files the ticket that a confused customer files on day three.

The engineering version of the same advice is smaller. Model the order and the shipment as different objects, charge freight once and enforce it, give every shipment its own declaration, and record why the split happened. The ticket in the screenshot was not a picker error. It was a data model that had never been asked to describe two boxes.


Written from the operations side of FulfillNexa by SBT (fulfillnexa.com), a China-based cross-border fulfillment provider. Stock sits across three sites in mainland China, with the 13,000 square meter Suzhou warehouse handling supplier consolidation, 8,000 square meters at Dongguan for ecommerce and single-unit orders, and 3,000 square meters at Shenzhen for oversized cargo. Charges are quoted per shipment and per order value band rather than published as a rate card.

Top comments (0)