Skip to content

Extracting a Structured Route From a Delivery Manifest

10 min read · updated August 11, 2026

A delivery manifest states its own total package count, which makes it one of the few documents in this cluster you can check for completeness without any external reference. The check is worth running. It is also worth knowing the four reasons it fails on a perfectly good manifest.

Row order is not stop order

The first thing to get right is that a manifest carries a stop sequence in a column, and the order of the printed rows is not necessarily that sequence. Manifests are frequently sorted for printing by postcode, by street name or by the order shipments were scanned onto the vehicle, with the routed sequence shown as a separate number. Reading the rows top to bottom and calling that the route produces a plausible-looking sequence that is not the one the driver was given.

So extract the sequence number as a field and sort by it, and record the row index separately so you can tell later whether the two differed. If a manifest has no sequence column at all — which happens on smaller operations — then row order is the route, and that fact should be recorded explicitly as sequence_source: row_order rather than silently assumed.

Sequence numbers are also frequently non-contiguous by design. Routing systems number stops 10, 20, 30 to leave room for insertions, so gaps are normal and a validator that requires 1..n will reject every manifest from such a system. What you can check is strict monotonicity: sequence numbers must increase, and a repeat or a decrease is a genuine signal — usually of a continuation row, which is the subject of two sections down.

The completeness check and its four false alarms

The check is simple: sum the per-stop package counts and compare to the manifest’s stated total. When it agrees, you have strong evidence that you read every row and every count correctly, which is more than any confidence score gives you. When it disagrees, one of five things is true, and only one of them is an extraction error.

  • A row was missed or a count misread. The actual extraction error, and the one the check exists to catch. Typically a row lost at a page boundary or a handwritten count misread.
  • The total counts shipments and the rows count pieces. A multi-piece shipment is one tracking number covering three cartons. If the header total is a shipment count and the per-stop column is a piece count, the sums cannot agree and neither number is wrong. Check which the column header says.
  • The total includes non-delivery items. Pickups, returns to depot, and undeliverable items being carried back are frequently in the vehicle total but not attached to a delivery stop.
  • A stop spans two rows. A stop with more items than fit on one line continues on the next, repeating the stop number. Sum the rows and the packages are right; count the rows and the stop count is wrong.
  • The manifest was amended. A stop added or removed after the header was printed leaves a stated total that no longer matches, sometimes with a handwritten correction next to it.

The design consequence is that the check produces a diagnosis, not a boolean. Record the stated total, the computed total, the difference and a reason code where you can infer one. A manifest whose difference exactly equals the count on one row is a different situation from one that is off by a large amount, and only the second is likely to be a missing page.

What multi-page manifests do to the sum

Manifests run to several pages and the page structure attacks the sum in three specific ways.

Column headers repeat on every page, and an extractor that treats each page independently will sometimes read the repeated header row as a data row, producing a stop with a package count of zero and a nonsensical address. Filter these by testing whether the row’s values parse as their column types rather than by matching header text, since header wording varies.

Subtotals appear at the foot of each page and a grand total on the last. Summing every numeric line therefore counts every package twice — once in its row and once in the page subtotal — which produces a computed total of roughly double the stated one. That doubling is diagnostic: a computed total near exactly twice the stated total is almost always subtotal contamination rather than duplicated rows.

And the column geometry shifts between pages. A wide address on page three can push the package-count column out of alignment with where it sat on page one, which matters if you are working from coordinates. This is the general problem of table column misalignment across pages, which sits alongside PDF parsing, and the specific lesson for manifests is to detect columns per page rather than once per document.

A stop is not an address

The last modelling decision is what a stop is, and the answer is not “an address”. The same address can be two stops on one route: a morning delivery and an afternoon collection, or two consignments with different time windows, or two units in one building. Deduplicating by address merges them and loses a delivery.

Conversely one stop can have several addresses in the sense that the delivery address, the invoice address and a site access note all appear on the row. Key a stop on the sequence number within the manifest, treat the address as an attribute, and keep any time window as a pair of local times with the zone handled the way the reservation confirmation page describes — a route crossing a zone boundary is not exotic in long-haul work.

Time windows themselves are worth typing carefully. “09:00 - 12:00” is a window; “10:30 APPT” is an appointment, which is a much stronger constraint; and a blank is neither, meaning any time during the service day. A single delivery_time field flattens all three into a value that dispatch cannot act on.

Extracting a route you can dispatch

{
  "manifest_id": "MAN-2026-0518-R14",
  "vehicle": "R14",
  "service_date": "2026-05-18",
  "sequence_source": "sequence_column",
  "stated_package_total": 138,
  "computed_package_total": 138,
  "completeness": { "agrees": true, "difference": 0 },
  "stops": [
    { "sequence": 10, "row_index": 3,
      "address": "22 Example Way, Unit B",
      "packages": 4,
      "tracking_refs": ["1Z...A", "1Z...B"],
      "window": { "type": "window", "from": "09:00", "to": "12:00",
                  "time_zone": "Europe/Amsterdam" },
      "continuation_rows": 0 }
  ]
}

The two fields that make this record auditable are row_index and completeness. The first is what source highlighting in a review queue needs to point a human at the disputed row; the second turns the document’s own arithmetic into a stored fact rather than a transient check. Both are cheap, and both are the difference between a route you can dispatch and a route you can only hope about. Keeping them stable as the prompt evolves is what extraction prompt regression tests are for.