Counting & Recording
Business view
There are four different ways to record a count, because "walk the shelves and count" doesn't look the same for every clinic or every counter:
- Guided, one item at a time (web modal, or the mobile line screen): the system hands you the next uncounted line, you type one number, hit save, and it moves to the next line automatically. Built for someone standing at a shelf with one hand free.
- Directly in the table: every line in a count is also an editable cell in the count's table — click in, type, tab out, save on blur. Better for someone reconciling from a printout at a desk.
- Barcode scan (mobile only): scan an item's barcode and jump straight to its counting screen, skipping the "find it in the list" step entirely.
- Excel upload: for clinics that count on paper first, or want to hand a printed/exported sheet to non-app staff, a spreadsheet with the counted amounts can be uploaded and matched back to the count in bulk.
All four write to the same place and update the same running totals — there's no "web count" vs. "mobile count", just one count that can be worked from whichever device is in someone's hand at the time.
A count amount can be typed in either items or boxes — whichever the counter is holding — and the system stores both, always priced against the value the count snapshot froze when it opened (not today's price, which might have changed since).
Recording a count for a line, then clearing it back out (sending null), returns that line to "not counted" — the system distinguishes "counted and it was zero" from "nobody has looked at this yet."
Technical view
The single-line mutation
Every recording path — modal, inline table cell, mobile screen, Excel upload — ultimately calls editStockCountItems (packages/server/src/resolvers/mutations/actions/stockCounts/editStockCountItems.js), which accepts a batch (input: [{item, amount, amountBox, note}]) so a single request can save one line or many.
For each row:
- Whichever unit was sent (
amountoramountBox), the other is derived using the line's frozenboxsize, so the client never has to send both (editStockCountItems.js:46-51). countedValue/varianceAmount/varianceValueare computed against the frozenprice/expectedAmount/expectedValueon the line, viastockCountAmounts(stockCountUtils.js:42-46) — never against current live pricing.- Sending
amount: null(andamountBox: null) clears the line back to uncounted:countedAmount,countedValue,varianceAmount,countedAt,countedByIdare all reset tonull(editStockCountItems.js:53-67). - After the batch,
recalculateStockCountTotals(stockCountUtils.js:52-75) re-aggregates the parent count'stotalItems/countedItems/countedAmount/countedValue/varianceAmount/varianceValuefrom its lines — this is why a list screen never has to sum lines itself, and why a count's totals survive even if a line is later re-opened. - Only permitted while the count is
OPEN(editStockCountItems.js:28-30) —Stock Count Is Closed[Client Error]otherwise.
Web: the guided modal
StartCountModal (packages/clinic-web/src/components/dashboard/inventories/stockCounts/StartCountModal.js) maintains a small local queue (25 lines, QUEUE_SIZE) fetched with filters: { countedAt: ['null'] } so it only ever hands out uncounted work. Saving a line removes it from the local queue immediately (no need to wait for a refetch) and top up the queue once it runs low (StartCountModal.js:118-124). A debounced search box (useDebounce, 500ms) lets a counter jump to a specific item out of order (StartCountModal.js:80-84).
Web: inline table editing
StockCountLineInput (packages/clinic-web/src/components/dashboard/inventories/stockCounts/StockCountLineInput.js) is the cell renderer used in the count's main table (StockCount.js column countedAt). It saves on blur or Enter — deliberately no explicit save button, "because a counter walking shelves types a number and moves on" (component comment). It's disabled once the count is no longer OPEN, and separately gated on user.permissions.editStockCount.
Mobile: scan-to-line and the single-line screen
StockCountScreen(packages/clinic-mobile/src/components/dashboard/More/inventories/stockCounts/StockCountScreen.js) shows the count's lines as aCommonList, with a camera button that navigates to a sharedBarcodescanner screen and comes back with a scanned code (StockCountScreen.js:42-56).- The scanned code is resolved via the
stockCountItemBySkuquery (packages/server/src/resolvers/queries/actions/stockCounts/stockCountItemBySku.js), which matches against either the line's printedcodeor the variation'ssku— whichever the barcode encodes — scoped to the current count and company. A miss returnsnullrather than throwing, so the UI can say "not found" instead of erroring (function comment,stockCountItemBySku.js:4-6). StockCountItemScreen(packages/clinic-mobile/src/components/dashboard/More/inventories/stockCounts/StockCountItemScreen.js) is the actual counting UI: item name, storage, the frozen expected amount (viaAmountWithBoxText), an item/box toggle, a numeric input, and a note field, saved via the sameeditStockCountItemsmutation as everything else. It reads the item either from navigation params (scan path) or from the Apollo cache by fragment (client.readFragment,StockCountItemScreen.js:22-28) when reached by tapping a row in the list.
The Excel round trip
UploadStockCountSheetButton (packages/clinic-web/src/components/dashboard/inventories/stockCounts/UploadStockCountSheetButton.js) parses the chosen file client-side with xlsx and maps whatever header names the sheet has (Code/code, SKU/sku, Storage/storage, Counted qty/countedAmount, Counted boxes/countedAmountBox, Note/note) into rows, then sends them to the existing generic upload pipeline as uploadType: stockCount (value 6 in uploadTypes, packages/clinic-web/src/components/dashboard/settings/Clinic/Upload/uploadUtils.js).
That upload is picked up by the existing queue worker (packages/server/src/queue/processRows.js, handleParseData.js) and routed to the new handleParseStockCount (packages/server/src/queue/uploadFiles/handleParseStockCount.js), which:
- Confirms the target count exists, belongs to the caller's company, and is still
OPEN(handleParseStockCount.js:30-41). - Matches each row to a
StockCountItembycodefirst, falling back tosku+storage nametogether (handleParseStockCount.js:58-74) — the fallback exists because a code column is easy to lose in a copy-paste, but a barcode rarely is. - Treats a blank counted cell as "not counted", not zero (
handleParseStockCount.js:90) — matching the same semantics as the manual entry paths. - Validates amounts are non-negative numbers, collecting up to 50 row-level errors to report back rather than failing the whole file on one bad cell (
handleParseStockCount.js:76-87,:152). - Applies updates in chunks of 200 inside individual transactions (
handleParseStockCount.js:108-128), then recalculates the count's totals the same way the direct-edit path does.
A documentation gap worth calling out: the upload button's code comment describes this as "the return trip for a count that was done on paper: the sheet the table downloaded" — but this PR does not ship a dedicated "download a blank/pre-filled count sheet with those exact column headers" feature. The count's item table can be exported via the app's existing generic table-export button, but that export writes the on-screen column titles (e.g. "Counted Amount"), not the Code/SKU/Counted qty/Counted boxes headers the parser is built to read. In practice, a clinic wanting to use the paper/Excel workflow today would need to build a sheet with those headers by hand (or from the code values visible in the UI), rather than round-tripping a downloaded file directly.