← All manuals Operator Guide for this screen IM2

Cycle Count — Technical Spec

Cycle Count (handheld) + Count Review & Post (PC)
Technical Spec Ver 3 Updated 2026-09-06 Matches the live screen

1. Routes

MethodPathPurposePermission
GET/count?sku=Handheld count screenany signed-in user (Save gated)
POST/api/count/{sku}Save/replace a pending countaction count_record
GET/countsPC review & post screenaction count_post
GET/api/counts/pendingPending queue as JSONaction count_post
POST/api/counts/{id}/discardDelete an unposted countaction count_post
POST/api/counts/postPost selected counts to QuickBooksaction count_post
GET/api/counts/batch/{id}Batch detailaction count_post
GET/api/counts/batch/{id}/csvBatch detail as CSVaction count_post

Templates: scan.html (mode="count"), counts.html. Handlers in app/im2.py.

2. Handheld capture

GET /count with no sku renders an empty scan box (autofocus, numeric input mode). With sku it reads items by exact SKU (trimmed) plus the last 5 rows of cycle_counts for that SKU, and flags the first unposted one as pending_count. Unknown SKU renders SKU {sku} not found — no item is created. Context flags: can_count (count_record), can_edit_bin / can_edit_secondary (field rights on primary_bin / secondary_bin), so the same screen serves counters and view-only users.

POST /api/count/{sku}:

  1. require_action(user, "count_record").
  2. qty cast to float; non-numeric → 400, negative → 400. Zero is valid and meaningful.
  3. SKU must exist in items (404 otherwise).
  4. One pending count per SKU. If an unposted row exists it is updated in place (counted_qty, counted_by, counted_at = now(), post_status/post_error cleared) and an audit_log row is written with field_name = 'cycle_count', source = 'recount', carrying the replaced quantity. Otherwise a new cycle_counts row is inserted.
  5. Response: counted_qty, qb_on_hand, variance, plus replaced_qty / replaced_by when a count was superseded — the handheld shows the variance without the operator having to compute it.

Nothing in this path touches items.qty_on_hand or QuickBooks.

3. Pending queue (_pending_rows)

cycle_counts c join items i using (sku) where c.posted_at is null order by c.counted_at (oldest first). Derived per line: variance_qty = counted_qty - coalesce(qty_on_hand,0), unit_cost = coalesce(purchase_cost,0), variance_value = round(variance_qty * unit_cost, 2). _totals() returns lines, qty_change, value_up (positive lines only), value_down (negative only), value_net. Cost is QuickBooks' base purchase cost — no tax, no freight.

4. Discard

POST /api/counts/{id}/discard — 404 if unknown; 400 if posted_at is set (posted counts are immutable). Deletes the cycle_counts row and writes audit_log with field_name='cycle_count', new_value='discarded', source='counts'.

5. Posting (POST /api/counts/post)

Body: {"ids": [...]}; empty means the whole pending queue. Empty selection → 400. QBO client construction failure → 503.

Batch code: CC-%Y%m%d-%H%M%S (UTC). Per line, independently:

  1. qbo.item_by_sku(sku) — missing item raises, line marked error.
  2. Capture prior_qty from QuickBooks' QtyOnHand at post time (not the cached grid value).
  3. qbo.set_qty_on_hand(item, counted) — an inventory quantity adjustment dated today, variance to Inventory Shrinkage, no class. The month-end class reclass JE happens outside this app.
  4. Recompute unit_cost, variance_qty, variance_value from QuickBooks' live values.

Any exception is caught per line into post_error (truncated 400 chars) — one bad SKU never stops the batch. After the loop, one count_batches row is written (batch_code, posted_by, line_count, ok_count, error_count, qty_change, dollar_change), then per line:

Response: batch, batch_id, posted, failed, totals, errors[].

6. Batch reporting

GET /counts also lists the newest 25 count_batches with a counted-rows subquery. _batch_lines orders by abs(variance_value) desc, sku — largest dollar impact first. The CSV route returns the same lines for accounting.

7. Tables

8. Permission gates

count_record and count_post are rows in role_action_perms, editable on /perms. Defaults: record = Admin/L1/L2, post = Admin/L1. _count_admin() is a thin wrapper over require_action(user, "count_post") — the legacy COUNT_ADMIN_ROLES constant is no longer the authority and should be removed on the next pass through this module.

8a. Downstream reader: buy-list Pending Count [Ver 2, 2026-09-06]

app/po.py's _refresh_drift() reads cycle_counts directly for any buy-list line in Pending Count: it looks for a row on that SKU with counted_at > po_buy_list.saved_at and uses that row's unposted counted_qty as the fresh on-hand input to calc_rec_qty(). So entering a count clears a buy-list line's Pending Count status — it does not wait for the QuickBooks post. Nothing in that path writes cycle_counts; the count queue is unaffected. The manual CC Complete button (POST /po/buylist/{id}/cc_complete) does not read cycle_counts at all — it re-reads items.qty_on_hand / on-PO instead. See docs/tech/po_buylist.md.

9. Known limits

Batch discard (2026-09-06)

POST /api/counts/discard with {"ids": [...]} deletes every selected pending count plus its audit_log entry in one transaction, returning {"ok": true, "discarded": n}. A count that is already posted, or an id that no longer exists, returns 400 naming it with nothing deleted. Count Review previously looped POST /api/counts/{id}/discard per row, which could half-finish. The per-row route still exists. Posting to QuickBooks stays deliberately per-SKU independent — each SKU is its own QB write and cannot be rolled back, so failures are recorded in cycle_counts.post_error per row instead.