one order item per row

published
reading
6 min
tags
backend, product, imports

A merchant who ships forty parcels a day doesn't want to fill in a form forty times. Most of them already keep those orders somewhere else, in a spreadsheet or an export from the store they sell on. What they need is a way to bring a day's orders in at once, fix whatever is wrong before anything is created, and never end up with the same order twice.

That's the whole brief for bulk order creation. The interesting part is that the first design decision, what one row means, shapes everything after it.

first plan: row = order items behind a drawer order ref recipient products A-101 Rahim 1 item ▸ A-102 Karim 2 items ▸ products drawer MOUSE-01 ×1 CABLE-1M ×2 no CSV a merchant has looks like this shipped: row = item the shape the CSV already has # order ref recipient product qty 1 A-101 Rahim TSHIRT-L 1 2 A-102 Karim MOUSE-01 1 A-102 CABLE-1M 2 same ref on adjacent rows = one order order cells only on its first row [ order, order ] each one the exact single-order payload POST /orders/bulk one result per order, idempotent
the first plan put one order on a row and hid its items in a drawer. the shipped sheet puts one item on a row, the way the file already does, and sends the orders in one bulk request

the need behind it

Three things matter to the merchant, in this order:

  1. Getting the orders in. Most of them arrive as a CSV, not typed by hand. If the import is fussy, people give up and go back to the one-by-one form.
  2. Fixing problems before anything is created. A wrong city or a missing phone number should be caught on the sheet, not discovered after a parcel is booked.
  3. No duplicates. Creating forty orders is a batch. If the network drops halfway, retrying must not create the first twenty again.

Every decision below is in service of one of these.

first approach: one order per row

My first sketch put one order on each row. Recipient name, phone and address on the left, then a products cell showing a summary like "2 items". Clicking it opened a side drawer with the same product and variant picker the single-order screen uses.

It looked tidy. An order is the thing the merchant is creating, so one row per order felt right.

The problem showed up as soon as I thought about the import. The files merchants already have put one item on each row, with the order reference repeated for a multi-item order. That's how most store and shipping exports work, Shopify's included. A sheet shaped differently from the file turns every import into a translation step, and every error points at a cell that doesn't exist in the merchant's own spreadsheet.

So I dropped it.

second approach: one item per row

The sheet now has the same shape as the file:

  • one row is one line item
  • adjacent rows with the same order reference are one order
  • a row with a blank order reference is its own single-item order
  • the same reference on rows that aren't next to each other is an error, not a silent merge
order ref name phone city sku qty
A-101 Rahim 017… Dhaka TSHIRT-L 1
A-102 Karim 018… Dhaka MOUSE-01 1
A-102 CABLE-1M 2

This also lines up with the backend. Each group of rows becomes exactly the payload the single-order form already sends, so the server doesn't need a second idea of what an order is.

The drawback is readability. Once an order spans rows, it's easy to lose track of where one ends and the next begins, and recipient details repeated on every row look like separate orders.

The fix:

  • Order-level cells (recipient, address, amount to collect) show only on an order's first row. The rows below stay empty, so a total never looks like it's charged twice. The order reference repeats in a muted color as the group marker.
  • A bracket in the row-number column marks each multi-item order, inset at both ends so two neighbouring orders stay visibly separate. Dividers are dashed inside an order and solid between orders.
  • No merged cells. rowSpan looks like the natural tool, but inserting rows, keeping row keys stable and sorting all get harder once cells span rows.

an import that doesn't give up

A strict importer is easy to build and miserable to use: one unexpected column header and the whole file is rejected. Since getting orders in is the first need, the import here never refuses a file. It loads what it can and marks the rest on the sheet.

  • Headers are matched ignoring case, spaces and punctuation, so Phone, phone and Phone (*) all land in the same column. Columns the sheet doesn't know are ignored.
  • Common aliases map to the same field. A product can arrive as sku or as a free-text item name, a pickup point as pickup location or store, so files exported from different tools load without editing.
  • Unknown products become custom line items named after the text. An item that isn't in the catalogue yet is still a real parcel, so it's not a reason to reject the order.
  • Locations are matched against the real list. An exact match is fine. A close match ("banani dohs" for "Banani Old DOHS") is filled in and marked yellow for review. No match is red. When the city or zone is missing, an address parser fills it in from the street address, also yellow.
  • Unknown pickup locations fall back to the merchant's default one, marked yellow until someone picks.

submitting the batch

Once the sheet is clean, the orders go to the server in one request. The obvious shortcut, looping over the single-order endpoint, is slow for big batches and falls apart on exactly the third need: a request that times out after the server already created the order looks like a failure, and a retry creates a duplicate.

So the batch gets its own endpoint, built on the same rules as the single one:

POST /orders/bulk
{
  "orders": [
    { "idempotency_key": "A-101", "order": { ...the single-order payload... } },
    { "idempotency_key": "A-102", "order": { ...the single-order payload... } }
  ]
}
200 OK
{
  "results": [
    { "idempotency_key": "A-101", "status": "created", "order_id": "ord_8f2k" },
    { "idempotency_key": "A-102", "status": "failed",
      "errors": { "pickup_location": "this location is inactive" } }
  ]
}
  • Each element is the exact single-order payload, and the server runs the same create logic for each one. There's one definition of a valid order, not two.
  • Results are per order. One bad order doesn't fail the batch. The response says which ones were created and which failed, and why.
  • Each order carries an idempotency key, here the merchant's order reference. If the same key comes back, the server returns the order it already created instead of making a new one. A retry after a timeout becomes safe.
  • Field errors map back to cells. pickup_location in the response turns that cell red on the sheet, with the server's message.

On the sheet, created orders lock and failed ones stay editable, so the merchant fixes only what failed and submits again. The keys make that second submit harmless for the orders that already went through.

keeping the sheet fast

A spreadsheet that lags while you type is worse than a form. The first version re-rendered every cell on every keystroke: fine with ten orders, sluggish with sixty. Two changes fixed it. Cells type into local state and commit to the sheet on a short pause or on blur, and rows are memoized so an edit only re-renders the row it touched. Validation and the address parser then run per committed value, not per key.

what it costs

  • Order fields live on the first row. To change a recipient you edit the order's first row, not any row. That's the price of matching the file.
  • Big orders get tall. A forty-item wholesale order is forty rows. For a tool aimed at many small orders, that's acceptable.
  • Partial success needs a UI. Per-order results are more honest than all-or-nothing, but the sheet has to show a status on every order and make "fix and resubmit" obvious.

the lesson

I started by designing the sheet around what an order is what the file looks like. When most of the data arrives as a file, the file's shape is the interface. The database, the API and the validation can all adapt to it, and here they barely had to.