Part 2 of 7 · Stock count reconciler series ~8 min read

How a count gets captured

Before anything can be reconciled, the count has to arrive in a shape the system can read. Staff submit a physical count one of two ways: a scanner export, or a spreadsheet they fill in walking the aisles. Both are messy — duplicate scans, blank rows, an item code typed three different ways, a count entered in cases when the system holds eaches. This post is about the first step: how a submitted count lands, how it gets tied to a count session, and how it becomes a clean list of counted lines — SKU, location, counted quantity — with nothing reconciled yet. Getting this step boring and reliable is what makes every step after it trustworthy.

Key takeaways

  • A count is a session, not a file. It opens, collects lines, and closes — one row in stkr-counts.
  • Two submission lanes: a scanner export and a filled-in sheet. Both land in S3 and fire one count-submitted event.
  • Capture normalises the mess: de-duplicates scans, drops blanks, maps codes to known SKUs, converts cases to eaches.
  • The output is a clean list of counted lines — SKU, location, counted quantity — with nothing reconciled yet.
  • Anything that won’t map cleanly is parked, not guessed. A bad line never silently becomes a variance.

A count is a session, not a file

The unit of work here is a count session, not a spreadsheet. Before anyone scans a thing, the session is opened: a row in stkr-counts recording the location being counted, who’s counting, the moment it opened, and a status of open. Every counted line that comes in is tied to that session. When the count is submitted, the session flips to captured and the reconciler takes over. Modelling it this way means a count is auditable from the first scan — you can always say which session a line belonged to, who ran it, and against which snapshot of the system it was reconciled.

It also makes partial counts honest. A team rarely counts the whole shop in one go; they do the fixings aisle on Saturday and the paint aisle on Sunday. Each is its own session against its own location, so a half-finished count never gets mistaken for a full one, and a SKU that simply wasn’t in scope this weekend never shows up as a phantom variance.

Two lanes in

Staff submit a count one of two ways, and both end in the same place.

The scanner lane is for shops with a handheld or a phone scanning app. The team walks the aisle, scans each item, keys the quantity, and the app exports a file — usually a CSV of barcode and quantity. That file is dropped into the session’s folder in S3.

The sheet lane is for everyone else. The session opens with a pre-filled spreadsheet — one row per SKU expected at that location, a blank column for the counted quantity — and staff fill it in as they walk. When they’re done they upload it to the same S3 folder. A scan of a paper tally counts as this lane too: it’s saved as a sheet with the numbers typed in.

Either way, the upload to S3 is the trigger. S3 fires a count-submitted event onto EventBridge, which lands on an SQS queue in front of the capture Lambda. The queue matters: it means two teams submitting at once can’t trample each other, a capture that fails gets retried, and anything that fails twice goes to a dead-letter queue for a human to look at rather than vanishing.

How a submitted count flows from upload to clean counted lines A left-to-right flow. On the far left, two stacked source boxes: "Scanner export" (a CSV of barcode and quantity) and "Filled-in sheet" (one row per SKU with a counted quantity). Both feed into a single box, "S3 count bucket", which holds the uploaded file under the session's folder. An arrow from S3 labelled "count-submitted event" goes to "EventBridge", then to an "SQS queue" with a small dead-letter queue hanging below it. The queue feeds the central box, "Capture Lambda (stkr-capture)", which sits inside a dotted AWS account container along with the queue and S3. Inside or beside the capture box, four stacked normalisation steps are listed: de-duplicate repeat scans, drop blank rows, map each code to a known SKU, convert cases to eaches. Two arrows leave the capture box. One goes down to "stkr-counts" (the count session, now marked captured, holding the clean counted lines of SKU, location, counted quantity). The other, a short branch labelled "won't map", goes to a small "Parked lines" box for a human to resolve. A note at the bottom reads: capture normalises and ties to a session — it reconciles nothing; a line it can't map is parked, never guessed. Scanner export barcode, quantity Filled-in sheet SKU, counted qty S3 bucket session folder AWS account event EventBridge + SQS queue (DLQ on retry) Capture Lambda stkr-capture de-duplicate scans drop blank rows map code → SKU cases → eaches stkr-counts session captured, clean lines clean lines Parked lines human resolves won’t map Capture normalises and ties to a session — it reconciles nothing. A line it can’t map is parked, never guessed.
Fig 2. A count flows from upload to clean lines. Either lane lands in S3, which fires one event onto a queue. The capture Lambda normalises the file, writes clean counted lines to the session, and parks anything it can’t map cleanly rather than guessing.

Normalising the mess

Raw counts are never clean, and capture’s whole job is to make them so before the engine ever sees them. Four steps, all plain code:

  • De-duplicate scans. A handheld picks up the same barcode three times as someone re-scans a tricky label. Capture collapses repeat scans of the same SKU at the same location into a single counted quantity — summing where the app records one scan per unit, taking the keyed quantity where it records a count.
  • Drop blank rows. A pre-filled sheet has a row for every expected SKU, and the team only fills in what they actually counted. A blank counted-quantity cell means “not counted,” not “counted zero” — an important difference. Blanks are dropped from the count; a true zero has to be typed as 0.
  • Map code to SKU. The same item gets written as a barcode, an internal SKU, or a supplier code depending on who’s counting. Capture maps each entry to the canonical SKU in stkr-inventory using a known-aliases lookup. An entry that matches nothing is not forced — it’s parked.
  • Convert cases to eaches. Someone counts “4 boxes” of an item the system holds in single units. Capture applies the pack size from the inventory record — 4 boxes of 24 becomes 96 eaches — so the count and the system are always in the same unit before anything is subtracted.

Parked, not guessed

The one rule capture never bends: a line it can’t resolve cleanly is parked, not guessed. A barcode that maps to no known SKU, a quantity that reads as text, a pack size the inventory record doesn’t have — each of these goes to a small parked-lines list for a human to fix, and the session notes how many are outstanding. None of them is silently dropped, and none is silently turned into a zero or a guess that would later surface as a phantom variance and waste a manager’s sign-off.

This is the same instinct that runs through the whole system: the reconciler is only as trustworthy as its inputs, so the cheapest place to stop a bad number is at the door, before it ever becomes arithmetic. A parked line is a known unknown. A guessed line is a lie with a timestamp.

Why this shape

  • The session is the unit of work, so a count is auditable from the first scan and a partial count never poses as a full one.
  • Both lanes converge on S3, so the rest of the system has exactly one trigger to care about.
  • A queue with a dead-letter queue sits in front of capture, so concurrent submissions and transient failures are handled, not lost.
  • Normalisation is plain code, run once, before reconciliation — the engine in Part 3 only ever sees clean, single-unit, canonical-SKU lines.
  • Unmappable lines are parked for a human. The system would rather ask than guess.

With a clean, session-tied list of counted lines in hand, the next post does the actual reconciliation: how a variance gets found, walked through one real line with the numbers.

All posts