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
CycleCountSessionfor 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_adjustmentsaudit 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 roleADMIN,MANAGER, orSUPER_ADMIN.
| Role | Create task | Count | Submit | Approve / reject | Reopen 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 equalscountedById, the audit trail shows self-approval — a flag for any external auditor. - High-variance sessions (where
varianceCount > 5or 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
countedByIdandapprovedByIdand 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:
- From
/cycle-count, tap the Tasks tab. Tap Create Task (or whatever the page calls the create CTA). - 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.
- 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.
- Blind count (
- Submit. The task is created with
status: PENDING,taskNumber: CC-{YYYY}-{NNNN}(4-digit, year-scoped).
What this writes:
- One
CycleCountTaskrow. - No
CycleCountAuditrow 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
CycleCountTaskyou 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:
- 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.
- (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. - Tap Start.
- 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
InventoryUnitrows at the location. - Creates a
CycleCountSessionwithstatus: IN_PROGRESS,version: 1,lockedBy: <you>,lockedAt: <now>,countedById: <you>,totalExpected: SUM(quantity). - Creates one
CycleCountLineper inventory unit, withsystemQty: unit.quantity,countedQty: null,variance: null,status: PENDING. - If
taskIdwas provided, updates the task tostatus: IN_PROGRESS. - Writes a
CycleCountAuditrow withaction: "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:
- The session page auto-focuses the Scan item barcode input.
- Trigger the scan from the TC22. The barcode normalizes (trim + uppercase) and is matched in this priority:
line.sku === barcodevariant.upc === barcodevariant.barcode === barcodeline.sku.toUpperCase() === barcode(defensive duplicate)
- On success: the matching line's
statusflips toVERIFIED. 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). - 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. - 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
quantityCountedby 1. In a cycle count, scanning marks the lineVERIFIEDbut does not populatecountedQty— 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:
- 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
- Enter the counted quantity. Three paths:
- Tap
+or−— single-step adjustment, callsPOST /sessions/:sessionId/countwith{ lineId, quantity }. - Set Exact — opens a modal, enter the new total, submits.
- Multi-line batch — debounced UI flushes pending edits via
POST /sessions/:sessionId/batchwith{ updates: [{ lineId, quantity }, ...], expectedVersion }.
- Tap
- The line's
statusflips fromPENDINGtoCOUNTED.variance = countedQty - systemQtyis computed automatically. - Repeat for every line.
Common errors:
| HTTP | API message | What 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:
- From the line list, tap Add Unexpected Item (or scan an unknown-but-known barcode — see §4.3 step 4).
- Pick the variant (SKU search, or scan barcode).
- Enter the counted quantity.
- Optionally enter a lot number.
- Submit.
What this does:
- Creates a
CycleCountLinewithproductVariantId,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 > 0triggers creation of a newInventoryUnitat the session's location withstatus: AVAILABLE, the lot number, and the counted quantity. - An unexpected item with
countedQty: 0does 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 atnullare 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:
- Tap Submit for Review at the bottom.
- 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)
- Review. Tap Submit.
What this does (transaction):
- Updates session:
status: SUBMITTED,submittedAt: now, clearslockedByandlockedAt. - Writes a
CycleCountAuditrow withaction: "SUBMITTED"and the totals. - Creates
Notificationrows withtype: "cycle_count:submitted"for every active user with roleADMIN,MANAGER, orSUPER_ADMIN. The notification links to/cycle-count/review/:sessionId. - Emits a
CYCLE_COUNT_SUBMITTEDevent 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.
| Parameter | Value | Where |
|---|---|---|
| Lock timeout | 5 minutes of silence after last lockedAt update | acquireLockAndReturn in cycle-count.service.ts |
| Heartbeat interval | 60 seconds while session page is open | setInterval in pages/cycle-count/session.tsx line 244 |
| Lock takeover | Automatic when stale (>5 min) on next session-start or session-load | acquireLockAndReturn |
| Lock release on submit | Atomic with status change | submitForReview 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, orSUPER_ADMIN(see §3 — operational expectation, not technically enforced). - Session status is
SUBMITTED.
Steps:
- Open
/cycle-count/review/:sessionIdfrom the bell notification or the Pending Review list. - 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)
- For sessions with material variance (your judgment, but anything
varianceCount > 0deserves looking at), walk to the location and physically verify the variance items before approving. - 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
InventoryAdjustmentrow withreason: "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): updatesInventoryUnit.quantity = countedQty. - Unexpected line with
countedQty > 0: creates a newInventoryUnitat the session's location withstatus: AVAILABLE, the lot number, the counted quantity. - Unexpected line with
countedQty: 0: no-op (the adjustment row alone is the audit).
- Existing unit (line had
- Writes a
CycleCountAuditrow withaction: "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 acheckBackordersjob per WMS-INV-006. - Shopify inventory sync: for every distinct variant where
productVariant.sourceStoreis a Shopify store (notMANUAL,WPULLS, orSTOREFRONT), enqueues ashopifyInventorySyncjob. 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,reviewedAtset. - Inventory is now reconciled to physical truth.
- Adjustments are formally recorded in the
inventory_adjustmentsaudit table. - Backorders for any newly-found stock are checked.
- Shopify (if applicable) gets the new totals within seconds.
Common errors:
| HTTP | API message | What 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:
- From the review page, tap Reject.
- The Reject Count modal opens. The text reads: Please provide a reason for rejection. The counter will be notified.
- Enter a reason (textarea, required — empty submits are blocked client-side; the server returns
400if posted empty). - Tap Reject.
What rejection does:
- Updates session:
status: REJECTED,reviewedById: <you>,reviewedAt: <now>,reviewNotes: <reason>. - Writes a
CycleCountAuditrow withaction: "REJECTED"and the reason. - Creates one
Notificationforsession.countedByIdwithtype: "cycle_count:recount_required", titleRecount Required: {locationName}, message set to the reason, link to the session. - Emits a
CYCLE_COUNT_REJECTEDevent. - 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:
- Open the session via the bell notification link.
- The page renders read-only with the rejection reason banner. Tap Reopen (or whatever the button is labeled — same endpoint).
- The session resets:
status: IN_PROGRESSlockedBy: <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,reviewNotescleared.
- 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
CycleCountAuditrow withaction: "REOPENED".
⚠
countedQtyis preserved through reopen, butstatusis not. A line you previously counted at 12 still hascountedQty: 12after reopen, butstatus: 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
| Status | Meaning | What's allowed |
|---|---|---|
IN_PROGRESS | Counter is counting | Scan, count, batch, add unexpected, lock-related ops, submit |
SUBMITTED | Awaiting manager review | View only; manager can approve or reject |
APPROVED | Inventory reconciled | View only. Source of truth. |
REJECTED | Manager sent back | View 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
systemQtyis 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,
systemQtyandvarianceare 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:
| Action | When |
|---|---|
SESSION_STARTED | New session created at §4.2 |
SCAN_SUCCESS / SCAN_UNEXPECTED / SCAN_UNKNOWN | Per-scan in §4.3 |
COUNT_UPDATED | Single-line count in §4.4 |
BATCH_COUNT | Multi-line debounced flush in §4.4 |
UNEXPECTED_ADDED | Per §4.5 |
SUBMITTED | Per §4.6 |
APPROVED | Per §4.8 |
REJECTED | Per §4.9 |
REOPENED | Per §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).
5.6 Related SOPs
- 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
CycleCountAuditrow per significant action (see §5.3) — insert-only - One
CycleCountSessionrow, updated through its lifecycle - One
CycleCountLinerow per (variant + lot) at the location at session start, plus any unexpected items added during count - At approval: one
InventoryAdjustmentrow 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
CycleCountSessionrows from the past 7 days. Confirm each hasstatus IN ('APPROVED', 'REJECTED')— sessions stuck inSUBMITTEDfor more than 24 hours are unreviewed and need attention. - For each
APPROVEDsession, sumABS(InventoryAdjustment.changeQty)joined bysourceId. 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
REJECTEDsession, 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
submittedAttoreviewedAt. 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
| Symptom | Cause | Resolution |
|---|---|---|
"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 approve | Counter hasn't submitted yet, or you're approving an already-approved/rejected session | Refresh. If counter is still counting, ask them to submit. |
"{n} items not counted" (HTTP 400) on submit | Some lines still have countedQty: null | Look 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 unexpected | Variant + lot pair is already on a line | Don'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 session | Approved sessions are terminal — corrections require a new cycle count. In-progress sessions don't need reopening. |
| Lock won't release after I submit | Submit transaction failed partway | Refresh. 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 404s | Session was deleted (rare) or notification has a stale link | Notify IT; should not happen. |
| Approve succeeded but Shopify quantity didn't update | Shopify sync is best-effort and post-transaction; failures are logged not raised | Check #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 appeared | All lines had variance: 0 or null | Expected — 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 session | API allows it; only operational discipline blocks it | This 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 time | Should not be possible — start checks for active sessions and routes to the existing one | If 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/approveenforcingcountedById !== 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
| Version | Date | Author | Changes |
|---|---|---|---|
| 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. |