Cycle Count — Technical Spec
1. Routes
| Method | Path | Purpose | Permission |
|---|---|---|---|
| GET | /count?sku= | Handheld count screen | any signed-in user (Save gated) |
| POST | /api/count/{sku} | Save/replace a pending count | action count_record |
| GET | /counts | PC review & post screen | action count_post |
| GET | /api/counts/pending | Pending queue as JSON | action count_post |
| POST | /api/counts/{id}/discard | Delete an unposted count | action count_post |
| POST | /api/counts/post | Post selected counts to QuickBooks | action count_post |
| GET | /api/counts/batch/{id} | Batch detail | action count_post |
| GET | /api/counts/batch/{id}/csv | Batch detail as CSV | action 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}:
require_action(user, "count_record").qtycast to float; non-numeric → 400, negative → 400. Zero is valid and meaningful.- SKU must exist in
items(404 otherwise). - 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_errorcleared) and anaudit_logrow is written withfield_name = 'cycle_count',source = 'recount', carrying the replaced quantity. Otherwise a newcycle_countsrow is inserted. - Response:
counted_qty,qb_on_hand,variance, plusreplaced_qty/replaced_bywhen 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:
qbo.item_by_sku(sku)— missing item raises, line markederror.- Capture
prior_qtyfrom QuickBooks'QtyOnHandat post time (not the cached grid value). 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.- Recompute
unit_cost,variance_qty,variance_valuefrom 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:
- posted →
cycle_countsstampedposted_at,batch_id,prior_qty,unit_cost,post_status='posted',synced_at,sync_batch;items.qty_on_handset to the counted quantity;audit_logrow onqty_on_handwithsource = 'cycle count {batch_code}'. - error →
post_status='error'+post_error; the row stays pending and can be retried.
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
cycle_counts—id, sku, counted_qty, counted_by, counted_at, posted_at, batch_id, prior_qty, unit_cost, post_status, post_error, synced_at, sync_batchcount_batches—id, batch_code, posted_by, posted_at, line_count, ok_count, error_count, qty_change, dollar_changeitems— read for name/bin/count group/on hand/cost;qty_on_handwritten on successful postaudit_log— recount, discard and posted-quantity rows
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
- Variance is unbounded — there is no "count is more than X% off, block it" rule. Control is human review before posting.
- No count scheduling yet:
count_groupandabc_codeexist on the item, but which items are due is decided outside the app. - Posting is synchronous, one QuickBooks call per line; large batches take proportionally longer.
- Un-posting is intentionally impossible; corrections are a second count.
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.