Skip to main content

SOP: Cycle Count Campaigns

Document ID: WMS-INV-002 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Counters running cycle counts in the field; managers reviewing and approving submissions


1. Purpose

This procedure governs how warehouse staff create cycle-count tasks, run the count session at a physical location, submit results for manager review, and how managers approve or reject those submissions. Cycle counts are the recurring inventory verification mechanism — they catch shrinkage, stale stock, mislocated units, and the slow drift between system and reality that single-unit adjustments per WMS-INV-001 §4.2 can't address at scale.

Approval of a cycle-count session automatically creates InventoryAdjustment rows (per WMS-INV-007 §4.1 Path A) for every line where variance ≠ 0, applies the inventory changes in the same transaction, enqueues backorder checks for variants where the count went up, and triggers a Shopify inventory sync for each variant whose sourceStore is on a Shopify storefront. It is the highest-leverage manager action in the inventory system today, and the only routine path that produces formal inventory_adjustments rows.

2. Scope

In scope:

  • Creating a CycleCountTask (LOCATION / ZONE / SKU / ABC / AD_HOC types)
  • Starting a CycleCountSession for a specific location
  • The two count modes: standard (system quantity is shown) and blind (system quantity is hidden)
  • Counting via barcode scan, single-line +/−, batch updates
  • Adding unexpected items — units found at the location that the system didn't expect
  • The lock + heartbeat mechanism (5-min timeout, 60s interval) — same model as receiving (WMS-REC-001 §4.4)
  • Submitting the session
  • Manager review: approve creates adjustments and applies inventory changes; reject sends back to counter for recount
  • Reopening a rejected session

Out of scope:

  • Floor count & new-SKU location discovery — see WMS-INV-003 (different table, different flow)
  • Per-unit ad-hoc moves and adjustments — see WMS-INV-001
  • Reading or querying the inventory_adjustments audit table — see WMS-INV-007
  • Receiving counts — see WMS-REC-001 (separate session model, separate code)
  • Shrinkage investigation across many cycle counts — see WMS-AUD-002

3. Roles & permissions

API enforcement: all cycle-count endpoints (/cycle-count/tasks, /cycle-count/sessions/start, /scan, /count, /batch, /unexpected, /heartbeat, /submit, /approve, /reject, /reopen) check authentication only — no inline role gate. The only role-aware code path is the bell-notification target list at submit time, which queries users with role ADMIN, MANAGER, or SUPER_ADMIN.

RoleCreate taskCountSubmitApprove / rejectReopen rejected
READONLY
STAFF✓ (technically allowed by API; see expectation below)
MANAGER
ADMIN
SUPER_ADMIN

Operational expectations (manager review, not technically blocked):

  • The counter who submits a session does not approve their own count. Approval transactions are signed with approvedById = userId; if it equals countedById, the audit trail shows self-approval — a flag for any external auditor.
  • High-variance sessions (where varianceCount > 5 or any line has |variance| > 25) are reviewed by a manager who walks the location physically before approving.
  • Blind-count submissions get extra scrutiny by definition — see §5.2 — and a manager reviews them in person before approval.

Honest about discipline. Today, a STAFF user can technically approve their own cycle-count session. Don't. The audit log shows the same user as countedById and approvedById and that's the kind of pattern external auditors flag without explanation. Have a manager approve, even if they aren't physically at the location — they can still verify the variance numbers against the audit trail.

4. Procedures

4.1 Creating a cycle-count task (manager-side)

Use when: Setting up a planned count — daily location rotation, weekly ABC class, follow-up on a flagged variance from a prior count, or a one-off triggered by a customer dispute.

Steps:

  1. From /cycle-count, tap the Tasks tab. Tap Create Task (or whatever the page calls the create CTA).
  2. Choose the type:
    • LOCATION — count specific bin(s). Pick one or more locations from the list.
    • ZONE — count an entire zone. Pick the zone name.
    • SKU — count all units of a specific SKU across all locations. Enter the SKU filter.
    • ABC — count all SKUs in an ABC class. Pick A, B, or C.
    • AD_HOC — none of the above; counter picks location at start time.
  3. Configure:
    • Blind count (blindCount: boolean) — if checked, system quantities are hidden from the counter (see §5.2).
    • Include zero-quantity (includeZeroQty: boolean, default true) — count items the system shows as zero, in case there's actually stock there.
    • Priority (integer, default 0) — higher = higher in the queue.
    • Scheduled date / due date — both optional; for queue ordering and overdue tracking.
    • Assigned to (optional) — pick a specific counter, or leave open for self-assignment.
  4. Submit. The task is created with status: PENDING, taskNumber: CC-{YYYY}-{NNNN} (4-digit, year-scoped).

What this writes:

  • One CycleCountTask row.
  • No CycleCountAudit row at task creation; audit logs are session-scoped, not task-scoped.

⚠ Tasks are optional. A counter can also start a session directly without a task (per §4.2). The task model is for planned, recurring, manager-driven counts. Ad-hoc counts triggered by a discrepancy on the floor go straight to §4.2.

4.2 Starting a session

Use when: You're at a physical location and ready to count.

Prerequisites:

  • Either a CycleCountTask you can pick up, or you're starting an ad-hoc session.
  • The location exists in the system and you can scan its barcode (or pick from the list).

Steps:

  1. From /cycle-count/start:
    • If you have a task — the page shows the task's locations. Pick one. The task ID is passed forward.
    • Ad-hoc — the page lists all locations with their inventory unit counts. Search, scroll, or scan a location barcode. The page also supports barcode scan from the TC22 — scanning a location barcode auto-starts the session at that location.
  2. (Optional) Toggle Blind count on the start screen if you want the system quantities hidden during count. If you started from a task with blindCount: true, the toggle is forced on.
  3. Tap Start.
  4. The page redirects to /cycle-count/session/:sessionId.

What this does (transaction):

  • Checks for an existing active session at the same location (status IN ('IN_PROGRESS', 'SUBMITTED')). If one exists, you're routed to it via the lock-acquire path (§4.7).
  • Loads all InventoryUnit rows at the location.
  • Creates a CycleCountSession with status: IN_PROGRESS, version: 1, lockedBy: <you>, lockedAt: <now>, countedById: <you>, totalExpected: SUM(quantity).
  • Creates one CycleCountLine per inventory unit, with systemQty: unit.quantity, countedQty: null, variance: null, status: PENDING.
  • If taskId was provided, updates the task to status: IN_PROGRESS.
  • Writes a CycleCountAudit row with action: "SESSION_STARTED", the line item count, and the total expected.

⚠ One active session per location. If someone else is mid-count at the same location, you get routed to their session in read-only mode (locked-by-other, see §4.7). Two people cannot count the same location at the same time.

4.3 Counting via barcode scan

Use when: You're scanning units one by one — the cleanest, most reliable count method.

Steps:

  1. The session page auto-focuses the Scan item barcode input.
  2. Trigger the scan from the TC22. The barcode normalizes (trim + uppercase) and is matched in this priority:
    1. line.sku === barcode
    2. variant.upc === barcode
    3. variant.barcode === barcode
    4. line.sku.toUpperCase() === barcode (defensive duplicate)
  3. On success: the matching line's status flips to VERIFIED. The UI shows a green flash with the SKU. Phone vibrates. The line is now ready for you to enter the counted quantity (not auto-incremented — see callout below).
  4. On UNEXPECTED_ITEM: the barcode matched a known variant but that variant isn't on this session's line items. The UI prompts you to add it (per §4.5). Don't skip this — every unexpected item is a real discrepancy.
  5. On UNKNOWN_BARCODE: not in the system at all. Could be a damaged barcode, vendor's internal code, or genuinely new SKU. Document on paper, escalate to a manager — see §5.4.

⚠ Scan ≠ +1 in cycle counts (different from receiving). In WMS-REC-001 §4.2, every scan increments quantityCounted by 1. In a cycle count, scanning marks the line VERIFIED but does not populate countedQty — you still have to enter the count manually. The reasoning: receiving counts every individual unit; cycle counts often happen with stock in pre-counted cases, where the floor process is "scan a unit barcode to verify the SKU is present, then enter the case math." If you want every cycle-count scan to increment by 1, that's a code change request, not an SOP workaround.

Every scan (success and failure) writes a CycleCountAudit row with the scanId, the barcode, the matched line (or null), and the result type.

4.4 Counting manually (per-line, batch)

Use when: You've scanned to verify the SKU is present, and now you're entering the case count, the recount, or correcting an earlier mistake.

Steps:

  1. Tap the line. The line detail panel shows:
    • System quantity (hidden if blind count — see §5.2)
    • Counted quantity input (+ / − for single-step or Set Exact for the total)
    • Lot number if the line is lot-tracked
  2. Enter the counted quantity. Three paths:
    • Tap + or — single-step adjustment, calls POST /sessions/:sessionId/count with { lineId, quantity }.
    • Set Exact — opens a modal, enter the new total, submits.
    • Multi-line batch — debounced UI flushes pending edits via POST /sessions/:sessionId/batch with { updates: [{ lineId, quantity }, ...], expectedVersion }.
  3. The line's status flips from PENDING to COUNTED. variance = countedQty - systemQty is computed automatically.
  4. Repeat for every line.

Common errors:

HTTPAPI messageWhat it means
400"Session not found"Stale session ID. Refresh.
400"Session is not in progress"Was submitted/approved/rejected from another tab. Refresh.
400"Line item not found"Line was deleted or never existed. Refresh.
409"Version conflict"Two devices edited concurrently. UI auto-refreshes; verify counts after. Same semantics as WMS-REC-001 §4.3.

4.5 Adding unexpected items

Use when: During count, you find units of a SKU that the session didn't expect at this location. Either the system thought there were zero (and includeZeroQty: false was set when the task was created), or the system has the units assigned to a different location and you found them here, or a brand-new SKU showed up.

Steps:

  1. From the line list, tap Add Unexpected Item (or scan an unknown-but-known barcode — see §4.3 step 4).
  2. Pick the variant (SKU search, or scan barcode).
  3. Enter the counted quantity.
  4. Optionally enter a lot number.
  5. Submit.

What this does:

  • Creates a CycleCountLine with productVariantId, sku, productName, inventoryUnitId: null (no existing unit to link), systemQty: 0, countedQty: <your number>, variance: countedQty, status: COUNTED, isUnexpected: true, lotNumber: <optional>.
  • The unique constraint (sessionId, productVariantId, lotNumber) will reject "Item already exists in this count" (HTTP 400) if you try to add the same variant + lot twice. Use the standard count flow (§4.4) on the existing line instead.

At approval time (§4.6):

  • An unexpected item with countedQty > 0 triggers creation of a new InventoryUnit at the session's location with status: AVAILABLE, the lot number, and the counted quantity.
  • An unexpected item with countedQty: 0 does nothing on approval — it's the audit record that "we checked and there are zero of these here."

4.6 Submitting for review

Use when: Every line has been counted and you're ready to hand off to a manager.

Prerequisites:

  • Session status is IN_PROGRESS.
  • Every line has countedQty IS NOT NULL. Lines still at null are blocking — the API rejects with "{n} items not counted" (HTTP 400). The submit button is disabled until all lines are counted.
  • You hold the session lock.

Steps:

  1. Tap Submit for Review at the bottom.
  2. The Submit for Review? modal appears with:
    • Items Counted (lines completed)
    • Total Counted (sum of countedQty)
    • Total Expected (sum of systemQty)
    • Variance Count (lines where variance ≠ 0)
  3. Review. Tap Submit.

What this does (transaction):

  • Updates session: status: SUBMITTED, submittedAt: now, clears lockedBy and lockedAt.
  • Writes a CycleCountAudit row with action: "SUBMITTED" and the totals.
  • Creates Notification rows with type: "cycle_count:submitted" for every active user with role ADMIN, MANAGER, or SUPER_ADMIN. The notification links to /cycle-count/review/:sessionId.
  • Emits a CYCLE_COUNT_SUBMITTED event over the event bus.

Result:

  • Session is read-only.
  • Every manager gets a bell notification.
  • No inventory has changed yet. That happens at approval.

4.7 The lock and heartbeat (same as WMS-REC-001 §4.4 in spirit)

Cycle-count sessions use the same lock pattern as receiving: 5-minute timeout, 60-second heartbeat, atomic release on submit, take-over of stale locks.

ParameterValueWhere
Lock timeout5 minutes of silence after last lockedAt updateacquireLockAndReturn in cycle-count.service.ts
Heartbeat interval60 seconds while session page is opensetInterval in pages/cycle-count/session.tsx line 244
Lock takeoverAutomatic when stale (>5 min) on next session-start or session-loadacquireLockAndReturn
Lock release on submitAtomic with status changesubmitForReview clears in same transaction

The four UI states are identical to receiving — see WMS-REC-001 §4.4 for the table. The differences:

  • The error string the API throws is "Session is not in progress" (HTTP 400), not the receiving form.
  • Heartbeat endpoint is POST /cycle-count/sessions/:sessionId/heartbeat, not the receiving endpoint.

If you're seeing a "locked by other" banner, WMS-REC-001 §4.4 step-by-step applies verbatim — coordinate verbally, wait for the 5-minute stale timeout, or have the holder navigate away from the page.

4.8 Manager review: approve

Use when: A counter has submitted and you're reviewing.

Prerequisites:

  • Your role is MANAGER, ADMIN, or SUPER_ADMIN (see §3 — operational expectation, not technically enforced).
  • Session status is SUBMITTED.

Steps:

  1. Open /cycle-count/review/:sessionId from the bell notification or the Pending Review list.
  2. The page shows:
    • Header with location, counter name, time submitted
    • Summary: Total Expected, Total Counted, Variance Count
    • Per-line table: SKU, expected, counted, variance (color-coded — green for zero, red for negative, yellow for positive)
  3. For sessions with material variance (your judgment, but anything varianceCount > 0 deserves looking at), walk to the location and physically verify the variance items before approving.
  4. Tap Approve (or Reject — see §4.9 — if you don't trust the count).

What approval does (one big transaction):

For each line where variance ≠ 0:

  • Generates the next adjustment number: ADJ-{YYYY}-{NNNNN} (5-digit, year-scoped).
  • Creates an InventoryAdjustment row with reason: "CYCLE_COUNT", sourceType: "CYCLE_COUNT", sourceId: <sessionId>, previousQty: line.systemQty, adjustedQty: line.countedQty, changeQty: line.variance, status: "APPROVED", createdById: <you>, approvedById: <you>, approvedAt: <now>. (See WMS-INV-007 §4.1 Path A for the full details on this row.)
  • Applies the inventory change:
    • Existing unit (line had inventoryUnitId): updates InventoryUnit.quantity = countedQty.
    • Unexpected line with countedQty > 0: creates a new InventoryUnit at the session's location with status: AVAILABLE, the lot number, the counted quantity.
    • Unexpected line with countedQty: 0: no-op (the adjustment row alone is the audit).
  • Writes a CycleCountAudit row with action: "APPROVED".

After the transaction commits, two best-effort post-processing steps run (failures are logged, not raised):

  • Backorder check: for every distinct variant where variance > 0 (more found than expected), enqueues a checkBackorders job per WMS-INV-006.
  • Shopify inventory sync: for every distinct variant where productVariant.sourceStore is a Shopify store (not MANUAL, WPULLS, or STOREFRONT), enqueues a shopifyInventorySync job. The synced quantity is the warehouse-wide aggregate (SUM(InventoryUnit.quantity) WHERE status='AVAILABLE'), not just the location's count — so Shopify sees the correct sellable total.

Result:

  • Session status: APPROVED. reviewedById, reviewedAt set.
  • Inventory is now reconciled to physical truth.
  • Adjustments are formally recorded in the inventory_adjustments audit table.
  • Backorders for any newly-found stock are checked.
  • Shopify (if applicable) gets the new totals within seconds.

Common errors:

HTTPAPI messageWhat it means
400"Session not found"Stale ID.
400"Session is not submitted"Status is IN_PROGRESS (counter hasn't submitted) or already APPROVED or REJECTED. Refresh.

4.9 Manager review: reject

Use when: The submitted count looks wrong — large unexplained variances, suspect counter discipline, the counter said "I'll recount tomorrow" but submitted anyway.

Steps:

  1. From the review page, tap Reject.
  2. The Reject Count modal opens. The text reads: Please provide a reason for rejection. The counter will be notified.
  3. Enter a reason (textarea, required — empty submits are blocked client-side; the server returns 400 if posted empty).
  4. Tap Reject.

What rejection does:

  • Updates session: status: REJECTED, reviewedById: <you>, reviewedAt: <now>, reviewNotes: <reason>.
  • Writes a CycleCountAudit row with action: "REJECTED" and the reason.
  • Creates one Notification for session.countedById with type: "cycle_count:recount_required", title Recount Required: {locationName}, message set to the reason, link to the session.
  • Emits a CYCLE_COUNT_REJECTED event.
  • No inventory changes. Nothing happens until the counter reopens.

⚠ The reason is permanent and visible to the counter. Be specific — "3 lines show variance > 25 with no explanation. Recount and check that you scanned the right SKU on the THC vape line; we had 100 expected and you wrote 50." Not "wrong". See WMS-REC-003 §4.3 for the same principle on receiving.

4.10 Reopening a rejected session (counter-side)

Use when: You're the counter, you got the Recount Required notification, and you've read the reason.

Prerequisites:

  • Session status is REJECTED.
  • You can read the rejection reason (visible on the session page).

Steps:

  1. Open the session via the bell notification link.
  2. The page renders read-only with the rejection reason banner. Tap Reopen (or whatever the button is labeled — same endpoint).
  3. The session resets:
    • status: IN_PROGRESS
    • lockedBy: <you>, lockedAt: <now>
    • All lines reset to status: PENDING. This is a meaningful difference from receiving — receiving reopen preserves counts (per WMS-REC-003 §4.4), cycle-count reopen marks everything pending again.
    • reviewedById, reviewedAt, reviewNotes cleared.
  4. Recount according to the rejection reason. Submit again per §4.6.

What this does (transaction):

  • Resets all CycleCountLine.status = "PENDING".
  • Updates session per the bullets above.
  • Writes a CycleCountAudit row with action: "REOPENED".

countedQty is preserved through reopen, but status is not. A line you previously counted at 12 still has countedQty: 12 after reopen, but status: PENDING — meaning the submit button is disabled until you re-touch every line (scan or count). This is a deliberate friction to force a real recount, not a copy-paste. If the count is genuinely correct on a line, just enter the same number again to re-mark it counted.

5. Reference

5.1 Cycle-count session status

StatusMeaningWhat's allowed
IN_PROGRESSCounter is countingScan, count, batch, add unexpected, lock-related ops, submit
SUBMITTEDAwaiting manager reviewView only; manager can approve or reject
APPROVEDInventory reconciledView only. Source of truth.
REJECTEDManager sent backView only; counter can reopen → status returns to IN_PROGRESS

5.2 Blind count

When blindCount: true (set on the task or toggled at session start):

  • The systemQty is hidden from the counter throughout the count. Both the per-line panel and the scan response omit it.
  • The counter must count without knowing what the system expected. This eliminates anchoring bias — if the system says "100" and physical count is "97," a non-blind counter is more likely to recount until they get 100; a blind counter records 97 and lets the variance speak.
  • At submit and review, systemQty and variance are revealed to the manager.
  • Blind counts are slower and more rigorous. Use them for high-value SKUs, theft-suspected zones, and quarterly compliance counts. Don't use them for routine daily rotation — they slow throughput.

5.3 Cycle-count audit table

The cycle_count_audits table is the per-action log for a session. Action codes:

ActionWhen
SESSION_STARTEDNew session created at §4.2
SCAN_SUCCESS / SCAN_UNEXPECTED / SCAN_UNKNOWNPer-scan in §4.3
COUNT_UPDATEDSingle-line count in §4.4
BATCH_COUNTMulti-line debounced flush in §4.4
UNEXPECTED_ADDEDPer §4.5
SUBMITTEDPer §4.6
APPROVEDPer §4.8
REJECTEDPer §4.9
REOPENEDPer §4.10

Rows are insert-only. Joining cycle_count_audits to users and cycle_count_lines reconstructs the full history of any session.

5.4 What to do with UNKNOWN_BARCODE

  • Damaged or vendor-internal barcode: try the unit's manufacturer barcode if different. Try the SKU label. If multiple barcodes per unit are present, the inner one is usually the manufacturer code that the WMS knows.
  • Genuinely new SKU: the SKU was never receivied through the WMS — escalate per WMS-INV-003 (floor count and SKU-to-location mapping). A cycle count is not the right place to introduce a new SKU; that's a separate procedure that creates the variant first, then assigns a barcode.
  • Don't skip the unknown unit. Document on paper, hand to the manager at submit time, and let them decide whether to add the SKU and re-run the count or accept the gap.

5.5 Adjustment number (linkage to WMS-INV-007)

Approval of a cycle-count session generates one InventoryAdjustment row per non-zero variance line. Format ADJ-{YYYY}-{NNNNN}. The adjustment has sourceType: "CYCLE_COUNT" and sourceId: <thisSessionId>, joinable back to this session. See WMS-INV-007 §4.1 Path A for the full row shape and §4.2 for how to query it during shrinkage investigation (WMS-AUD-002).

  • WMS-INV-001 — Per-unit moves, adjusts, damage (the lightweight per-unit alternative)
  • WMS-INV-003 — Floor count & location discovery (when an unknown SKU appears)
  • WMS-INV-006 — Backorder resolution (post-approval enqueue for variants where stock went up)
  • WMS-INV-007 — Formal inventory adjustments (the audit table this SOP populates)
  • WMS-REC-001 — Receiving session counting (sibling lock+heartbeat pattern)
  • WMS-AUD-001 — Cycle-count approval review (manager-side governance)
  • WMS-AUD-002 — Shrinkage investigation (cross-session variance analysis)

6. Audit & compliance

Every cycle-count session writes:

  • One CycleCountAudit row per significant action (see §5.3) — insert-only
  • One CycleCountSession row, updated through its lifecycle
  • One CycleCountLine row per (variant + lot) at the location at session start, plus any unexpected items added during count
  • At approval: one InventoryAdjustment row per non-zero-variance line (see WMS-INV-007 Path A)

These four tables together are the system of record for "what was at this location, when we counted, and what we did about the variance."

Manager weekly review:

  • Pull all CycleCountSession rows from the past 7 days. Confirm each has status IN ('APPROVED', 'REJECTED') — sessions stuck in SUBMITTED for more than 24 hours are unreviewed and need attention.
  • For each APPROVED session, sum ABS(InventoryAdjustment.changeQty) joined by sourceId. Sessions with > 50 unit absolute variance warrant a verbal post-mortem with the counter.
  • Sessions where countedById = approvedById — the self-approve flag. Should be zero. Investigate any non-zero count.
  • For each REJECTED session, confirm the counter has either reopened or marked the location for a reschedule.

Quarterly governance (Warehouse Operations Manager):

  • Variance trend by counter: SUM(ABS(changeQty)) GROUP BY countedById, timeframe.
  • Variance trend by location: same, GROUP BY locationId.
  • Variance trend by SKU: same, GROUP BY productVariantId.
  • Mean time from submittedAt to reviewedAt. Should trend down (faster reviews); creeping up is a manager-staffing issue.
  • Reopen rate: rejected sessions / total submitted. High rate (> 10%) is a counter-training issue.

7. Troubleshooting

SymptomCauseResolution
"Session is not in progress" (HTTP 400)Status changed under you (submitted from another tab, approved, rejected)Refresh the page.
"Session is not submitted" (HTTP 400) on approveCounter hasn't submitted yet, or you're approving an already-approved/rejected sessionRefresh. If counter is still counting, ask them to submit.
"{n} items not counted" (HTTP 400) on submitSome lines still have countedQty: nullLook for lines with status PENDING or VERIFIED (verified means scanned but not counted). Enter the count for each.
"Item already exists in this count" (HTTP 400) on add unexpectedVariant + lot pair is already on a lineDon't add — find the existing line and use the count flow. The unique constraint protects against duplicates.
"Only rejected sessions can be reopened" (HTTP 400)Trying to reopen an APPROVED or IN_PROGRESS sessionApproved sessions are terminal — corrections require a new cycle count. In-progress sessions don't need reopening.
Lock won't release after I submitSubmit transaction failed partwayRefresh. The submit transaction releases the lock atomically — if it didn't, the submit didn't actually complete. Check the session status.
Bell notification arrived but the link 404sSession was deleted (rare) or notification has a stale linkNotify IT; should not happen.
Approve succeeded but Shopify quantity didn't updateShopify sync is best-effort and post-transaction; failures are logged not raisedCheck #wms-support for queue health. The inventory and adjustments are correct in the WMS regardless. Manually trigger a sync per WMS-INV-004 §5 if needed.
Approve succeeded but no InventoryAdjustment rows appearedAll lines had variance: 0 or nullExpected — the approve transaction only writes adjustment rows where variance is non-zero. A perfect count produces no adjustments, just an APPROVED session row.
The counter approved their own sessionAPI allows it; only operational discipline blocks itThis is the audit-flag scenario in §3. Have a manager record reconcile by walking the location, then either accept or open a fresh cycle count. The original approval row is permanent in the audit.
Two CycleCountSession rows exist for the same location at the same timeShould not be possible — start checks for active sessions and routes to the existing oneIf you genuinely see two IN_PROGRESS sessions for the same locationId, that's a database bug. Escalate to IT immediately.

8. Escalation

  • Self-approval pattern repeats: Warehouse Operations Manager. The right fix is a code-level role check on /cycle-count/sessions/:sessionId/approve enforcing countedById !== userId. Until that ships, manager discipline is the control.
  • High variance pattern on a SKU across multiple cycle counts: Buyer + warehouse manager. Either the receiving math is off (per WMS-REC-001), the SKU is mis-labeled at vendor, or there's actual shrinkage. Cross-reference WMS-AUD-002.
  • High variance pattern on a counter across multiple sessions: Warehouse manager. Coaching, retraining, or assignment changes — not punishment per WMS-000 §7.
  • Approval transaction partially committed (some adjustments written, inventory not updated, or vice versa): IT on-call. The approve transaction is large; partial commits should not be possible but if one occurs, the database needs hand-fixing. Don't repeat the approval.
  • Backorder check or Shopify sync silently failing: IT — both are best-effort post-transaction enqueues; if logs show repeated failures, the queue is unhealthy. Inventory state in the WMS is unaffected.

9. Revision history

VersionDateAuthorChanges
1.0[DATE][NAME]Initial release. Documents end-to-end cycle-count flow grounded in apps/api/src/routes/cycle-count.routes.ts, packages/domain/src/services/cycle-count.service.ts, apps/web/src/pages/cycle-count/{start,session,review,index}.tsx, and packages/db/prisma/schema/cycle-count.prisma. Documents the five CycleCountType values (LOCATION/ZONE/SKU/ABC/AD_HOC), the four CycleCountSessionStatus values, the four CycleCountLineStatus values. Documents the lock+heartbeat parallel with WMS-REC-001 (5-min timeout, 60s interval, automatic stale takeover), with the noted difference that scan does not auto-increment in cycle counts (it only marks VERIFIED). Documents the approve transaction's full side-effect chain: InventoryAdjustment rows per non-zero variance (linkage to WMS-INV-007 Path A), InventoryUnit create/update, post-transaction backorder check enqueue (WMS-INV-006), post-transaction Shopify inventory sync per non-MANUAL/non-WPULLS/non-STOREFRONT variant. Documents reopen behavior (line statuses reset to PENDING, countedQty preserved). Calls out absence of API-level role gate on approve/reject and the operational expectation that counter ≠ approver. Cross-references WMS-INV-001, WMS-INV-003, WMS-INV-006, WMS-INV-007, WMS-REC-001, WMS-AUD-001, WMS-AUD-002.