Skip to main content

SOP: Floor Count & SKU Location Discovery

Document ID: WMS-INV-003 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Staff walking the floor to map unmapped SKUs to real bin locations, ensure scannable barcodes exist on every variant, and stage locations for cycle counts


1. Purpose

This procedure governs how staff walk the floor to map physical stock to the right system location, when the system records show stock at a placeholder UNASSIGNED location or the variant has no scannable barcode. It also covers ensuring every variant has a barcode (auto-generating from SKU when needed), printing product labels on the ZQ511, and optionally handing off into a CycleCountSession per WMS-INV-002.

Floor count is the routine "fix-as-you-go" inventory hygiene workflow. Picking, putaway, cycle counts, and customer returns all assume each variant has (a) a scannable barcode and (b) a known location. When either is missing — usually because a new SKU was received without a barcode, or stock was loaded directly into UNASSIGNED via an integration — floor count is the path to fix it without opening a full receiving session.

2. Scope

In scope:

  • Looking up a location by barcode or name (GET /floor-count/location)
  • Creating a new location on the fly (POST /floor-count/location)
  • Looking up a variant by SKU / UPC / barcode / partial match (POST /floor-count/sku-lookup)
  • Mapping a SKU to a location:
    • /assign-location — moves units from UNASSIGNED to the target location, creates the SkuLocationMap row
    • /confirm-mapping — creates the SkuLocationMap row only (no inventory move; for SKUs already at the location)
  • Ensuring a variant has a barcode (POST /floor-count/ensure-barcode — auto-generates from SKU if missing)
  • Printing a product label on the ZQ511 (POST /floor-count/print-label)
  • Handing off into a cycle count session at the location (POST /floor-count/sessions, hands to WMS-INV-002)

Out of scope:

  • Counting and reconciling stock at a known location with known SKUs — see WMS-INV-002 (cycle counts)
  • Receiving labels — see WMS-REC-005 (different endpoint, different ZPL helper)
  • Per-unit moves and adjustments — see WMS-INV-001
  • Location administration (zone reorganization, deactivation, mass renaming) — see WMS-INV-004

3. Roles & permissions

API enforcement: all floor-count endpoints check authentication only — no inline role gate. The downstream cycle-count session inherits cycle-count's auth model (also no role gate; see WMS-INV-002 §3).

RoleLookupCreate locationMap SKUPrint labelStart cycle count
READONLY
STAFF
MANAGER
ADMIN
SUPER_ADMIN

Operational expectations:

  • Locations created on the fly during floor count default to type: GENERAL and isPickable: true. If a more specific type is needed (STORAGE, PICKING, RECEIVING, etc.), use WMS-INV-004 §4.1 to create the location through the proper admin path with the correct type and zone metadata, then come back to map SKUs.
  • Do not map a SKU to a location and then walk away without verifying a unit physically exists there. The SkuLocationMap row carries no quantity — it's a suggestion for picking and a hint for putaway, not an assertion of stock. If the floor doesn't match the map, the next picker hits a short-pick (per WMS-PICK-003).

4. Procedures

4.1 The two-step flow (overview)

The floor-count UI at /floor-count is a two-step wizard:

  1. Step 1 — Location. Pick or create a location. The page calls GET /floor-count/location?q=... and returns the location with its current inventory (grouped by variant) and per-variant hasBarcode and isMapped flags.
  2. Step 2 — Scan. With a location selected, scan or search SKUs. Each scanned SKU shows its current state and offers the right action: Assign, Confirm Mapping, Ensure Barcode, Print Label, or Start Cycle Count if you want to count the whole location.

The wizard shows two dots in the navbar — the current step (blue) and the completed step (green). Tap the back arrow to step backward.

4.2 Looking up or creating a location

Use when: Step 1. You're starting a floor count and need to pick the location.

Lookup steps:

  1. From /floor-count, ensure you're on Step 1 (the page title is Floor Count, the location step shows a search input).
  2. Search by barcode (preferred — scan the bin barcode) or by location name (e.g., A-01-02). The query is case-insensitive and exact match (equals, not contains).
  3. If found: the page shows the location card with its current inventory. Each item shows SKU, name, total quantity at this location, and two flags:
    • hasBarcode — does the variant have any of upc / barcode / sku to scan against?
    • isMapped — does a SkuLocationMap row exist for this SKU at this location?
  4. If not found ({ found: false } in the response): the page shows a Create Location form.

Create steps (when needed):

  1. Tap Create Location.
  2. Enter:
    • Name (required) — typically the bin code (A-01-02)
    • Barcode (optional) — defaults to the name if blank; the request body submits the trimmed value
    • Zone (optional) — for zone-based filtering and pick-path optimization
    • Type (optional) — defaults to GENERAL; valid values per the LocationType enum, see WMS-INV-004 §5
  3. Submit.

Result of create:

  • A new Location row with isPickable: true, active: true, barcode set to whichever value was provided (or the name as fallback).
  • 201 Created with the new location object.

Common errors on create:

HTTPAPI messageWhat it means
400"Location name is required"Name was blank or whitespace.
409"Location name already exists"A location with this name (case-sensitive after trim) is already in the system. The response includes the existing location object — switch to using that one.
409"Barcode already assigned to another location"The barcode is taken. Either pick a different barcode, or use the existing location that already owns this barcode.

⚠ The location-create endpoint defaults to type: GENERAL. That's fine for a quick stand-up, but a location's type matters for downstream pick-path optimization, receiving fallbacks, and the inventory-status rules in WMS-INV-001 §5.1. If you're creating a location that should be STORAGE, PICKING, RECEIVING, or DAMAGED_INVENTORY, pass the right type or fix it later via WMS-INV-004 §4.x. Quick floor-count creates are easy to leave as GENERAL and forget about.

4.3 Looking up a SKU and reading its state

Use when: Step 2. You're at a location and you've scanned or typed a SKU / UPC / barcode.

Steps:

  1. With a location selected (Step 1 complete), the page is in scan mode. The TC22 scanner is auto-focused on the SKU input.
  2. Scan or type. The query normalizes to uppercase. Match priority:
    • First try: exact match on sku, upc, or barcode (case-insensitive equals)
    • Second try (if first miss): partial match on sku or name (case-insensitive contains)
  3. Found: the page shows the variant card with:
    • SKU, name, image
    • hasBarcode boolean — the variant has either a UPC or a barcode field set
    • inventory.totalQuantity — units across all locations
    • inventory.isUnassigned — true if any units are at the UNASSIGNED location
    • inventory.unassignedQuantity — sum of units in UNASSIGNED
    • inventory.locations — array of (locationId, locationName, zone, quantity) for assigned units (excluding UNASSIGNED)
  4. Decide what action the SKU needs (see §4.4–§4.7).

Not found ({ found: false }): the SKU isn't in the system at all. This is not the path to add a new variant — variants come from product imports or manual product creation through the product UI. Document the unknown SKU on paper, escalate to the buyer, and continue counting other items.

⚠ The lookup is forgiving but not infallible. If a SKU has a typo and the scanner reads "LIT-PAX-3OOO" instead of "LIT-PAX-3000", the partial match might still find it via name search. The label printed in §4.7 will use the SKU exactly as stored — verify the variant card shows the SKU you expect before assigning or printing.

4.4 Assigning a SKU to a location (moves stock from UNASSIGNED)

Use when: The SKU lookup shows inventory.isUnassigned: true or inventory.unassignedQuantity > 0. There's stock that the system thinks is at UNASSIGNED and you've physically located it at the current floor-count location.

Prerequisites:

  • A Location named exactly UNASSIGNED exists in the database. (If it doesn't, the assign endpoint silently does nothing for the move portion — see callout below.)
  • The target location has been selected in Step 1.

Steps:

  1. In the variant card, tap Assign.
  2. The system runs POST /floor-count/assign-location with { productVariantId, locationId }.
  3. The request:
    • Looks up the UNASSIGNED location.
    • Finds all InventoryUnit rows for this variant at UNASSIGNED.
    • If the target location already has units of this variant: increments the existing unit's quantity by the total UNASSIGNED quantity, then deletes the UNASSIGNED unit rows.
    • If the target location has no units: re-points the UNASSIGNED units to the target location via updateMany.
    • Upserts a SkuLocationMap row with (sku, locationId) (no-op on update if already exists).
  4. The response shows movedQuantity and movedUnits. The page refreshes the variant card.

Two assignment outcomes:

  • Stock moved: response message reads Moved {n} units to {locationName}. Inventory math is now correct.
  • No stock to move (variant had no UNASSIGNED units, but you still wanted the mapping): response message reads SKU mapped to {locationName} (no unassigned inventory to move). The map row is created either way.

⚠ The assign flow does not write inventory_events rows. Unlike the WMS-INV-001 §4.1 Move Location path that produces a inventory:unit_moved audit row per unit, assign-location runs the move via raw updateMany / delete / update calls. The audit story for assigns is the SkuLocationMap.createdAt timestamp and nothing else. If you need a per-unit audit, do the move via WMS-INV-001 instead — but you'll need to create the map separately afterward via §4.5.

UNASSIGNED must literally be the location name. The endpoint queries prisma.location.findUnique({ where: { name: "UNASSIGNED" } }). If your warehouse uses Unassigned, UNKNOWN, or anything else, the move portion silently no-ops (the map row still gets created). Standardize on the exact string UNASSIGNED if you want this flow to work. Verify with SELECT name FROM locations WHERE upper(name) = 'UNASSIGNED'.

4.5 Confirming a mapping (no inventory move)

Use when: The SKU is already physically at the location (and the system already shows units here), but no SkuLocationMap row exists. Picking and putaway logic prefer mapped locations, so confirming the mapping helps downstream.

Indicator: in the location lookup response from §4.2, the variant has isMapped: false.

Steps:

  1. In the variant card (or the location's inventory list), tap Confirm Location (or the equivalent button).
  2. The system runs POST /floor-count/confirm-mapping with { productVariantId, locationId }.
  3. The endpoint upserts a SkuLocationMap row. No inventory move. No event log.

Difference from §4.4: Both endpoints upsert the same map row. assign-location additionally moves stock from UNASSIGNED; confirm-mapping does not. Use confirm-mapping when there's nothing in UNASSIGNED and you just need the suggestion-link for picking.

4.6 Ensuring a variant has a barcode

Use when: The SKU lookup or the location's inventory list shows hasBarcode: false. The variant has neither a upc nor a barcode. Without one, every downstream scan (during picking, putaway, cycle counting) falls back to manual SKU entry.

Steps:

  1. Tap Ensure Barcode (or the equivalent CTA).
  2. The system runs POST /floor-count/ensure-barcode with { productVariantId }.
  3. If the variant already has a upc or barcode (with non-empty trim): returns { barcode: <existing>, generated: false }. No write.
  4. If the variant has neither: the endpoint persists the variant's sku as its barcode and returns { barcode: <sku>, generated: true }.

⚠ The auto-generated barcode is the SKU itself. This is a meaningful product decision the system makes for you. SKUs like LIT-PAX-3000 work fine in Code128. SKUs containing non-printable characters or spaces will misencode (the sanitize helper in zpl.ts strips them at print time). After auto-generation, the variant's barcode field is no longer empty, so subsequent calls to ensure-barcode no-op. To replace the auto-generated value with a real vendor UPC later, edit the variant via the product page directly — do not call this endpoint again.

4.7 Printing a product label

Use when: You've assigned the SKU and ensured a barcode exists; now you want a physical sticker on the unit so future scans work.

Prerequisites:

  • ZQ511 paired and connected per WMS-REC-005 §4.1 (the PrinterStatus indicator in the navbar shows green).
  • The variant has a barcode (after §4.6).

Steps:

  1. Tap Print Label. The button appears next to the variant (compact style for inline, full button on the variant detail panel).
  2. The system runs POST /floor-count/print-label with { productVariantId, locationName?, copies? }.
  3. The endpoint:
    • Resolves the barcode via resolveBarcode(variant)upcbarcodesku (same priority as everywhere else; see WMS-REC-005 §5.1).
    • If neither upc nor barcode is set, persists variant.barcode = variant.sku (same auto-generation as §4.6 — defensive, in case ensure-barcode wasn't called first).
    • Generates ZPL via generateProductLabel({ productName, sku, barcode, location?: locationName }). Note: this is the product label format (generateProductLabel), not the receiving label (generateReceivingLabel) used in WMS-REC-005. The product label has a location field rendered in the bottom-right where the receiving label has a quantity.
    • For copies > 1, returns the same ZPL repeated copies times concatenated with newlines (no batch helper, no hard cap; large copy counts are accepted but each is a full label cycle on the printer).
  4. The Capacitor ZebraPrint plugin sends the ZPL to the printer via sendZPL.
  5. The button flashes green for 3 seconds on success.

Common print failures: identical to WMS-REC-005 §4.2 — printer not paired, web platform (no native plugin), printer out of paper or out of range. Resolution paths there apply here.

4.8 Handing off to a cycle count

Use when: You've finished the discovery work at this location (mapped, barcoded, labeled) and you want to formally count the location.

Steps:

  1. From Step 2 (scan view), tap Start Cycle Count (or whatever the page calls the handoff CTA).
  2. The system runs POST /floor-count/sessions with { locationId }. Internally this calls cycleCountService.startSession({ locationId, blindCount: false, userId }) — the same service method documented in WMS-INV-002 §4.2.
  3. On success (201 Created), the page redirects to /cycle-count/session/:sessionId.
  4. From here on, WMS-INV-002 §4.3 onwards applies — counting via scan or manual, submit, manager review, approve.

What this means:

  • Floor count is the prep step; cycle count is the count step. They share the same destination data — both end up writing the same CycleCountSession and (on cycle-count approval) the same InventoryAdjustment rows.
  • A floor-count handoff session is a non-blind count by definition. If you need blind, start a cycle count directly from /cycle-count/start per WMS-INV-002 §4.2 instead.

5. Reference

5.1 The SkuLocationMap table

FieldPurpose
idcuid
skuThe variant's SKU string
locationIdFK to Location
createdAtWhen the mapping was first created

Unique constraint: (sku, locationId). The table is insert-only in practice — neither floor-count endpoint updates an existing row, both use upsert with update: {}. There's no audit log for mapping changes; the createdAt timestamp is the only history.

The map is consumed downstream by:

  • Picking allocation (WMS-PICK-001) — when allocating an order, the system prefers locations with a matching SkuLocationMap row over locations that just happen to have stock.
  • Putaway suggestion (WMS-REC-004 — when implemented) — the destination bin is derived from the most recent SkuLocationMap for the variant.
  • Floor count Step 1 inventory display — drives the isMapped flag on each variant card.

5.2 What the system does not do automatically

  • Does not delete SkuLocationMap rows when a SKU stops being kept at a location. The maps accumulate. If you move all stock of a SKU out of bin A-01-02, the SkuLocationMap(sku=X, locationId=A-01-02) row remains. Cleanup is manual via SQL, or via cascading on Location delete.
  • Does not validate that physical stock matches the map. The map is a hint, not an assertion. A pick-allocation that goes to a mapped location and finds it empty produces a short-pick (WMS-PICK-003), not a system error.
  • Does not retire or fix auto-generated barcodes. Once variant.barcode = variant.sku is set by ensure-barcode or print-label, that value sticks until manually updated through the product editor.

5.3 Difference: floor-count print vs. receiving print (cross-reference)

AspectFloor count (/floor-count/print-label)Receiving (/receiving/:sessionId/lines/:lineId/label)
ZPL generatorgenerateProductLabelgenerateReceivingLabel
Label contentProduct name, SKU, barcode, location (optional)Product name, SKU, barcode, quantity
Target useStick on the unit at the binStick on the case as it lands at receiving
Auto-generates barcode?Yes — falls back to SKU and persistsYes (via generatedBarcode on the ReceivingLine)
Label size2"×1" at 203 DPI2"×1" at 203 DPI

A unit physically labeled by the floor-count flow scans correctly during a receiving session and during picking, because both use resolveBarcode with the same priority order.

  • WMS-INV-001 — Per-unit moves and the audit gap with assign-location (see §4.4 callout)
  • WMS-INV-002 — Cycle counts (the handoff destination)
  • WMS-INV-004 — Location management (proper location creation with type/zone, deactivation, etc.)
  • WMS-INV-007 — Formal adjustments (created at cycle-count approval, downstream of the handoff)
  • WMS-REC-001 §4.3 — Receiving session scan with UNKNOWN_BARCODE (the inbound case where this SOP is also referenced)
  • WMS-REC-005 — ZQ511 setup, printer pairing, ZPL format conventions
  • WMS-PICK-001 — Picking allocation (consumer of SkuLocationMap)
  • WMS-PICK-003 — Short pick handling (when the map points at an empty bin)

6. Audit & compliance

Floor count produces less audit trail than any other inventory operation in the system:

  • Location creation — written to the locations table with createdAt. No structured audit log.
  • SkuLocationMap upsert — written to sku_location_maps with createdAt. No update timestamp; no log of attempts that no-op'd.
  • assign-location inventory move — performed via updateMany / delete / update. No inventory_events rows are written. The audit story is "the units used to be at UNASSIGNED, now they're here, on this date" — implicit and reconstructable only by joining tables.
  • ensure-barcode write — updates productVariant.barcode. No log of the change. The variant's updatedAt shifts but doesn't record what changed.
  • print-label — no audit log.

For high-stakes operations (cannabis/vape regulatory reporting), the floor-count flow is not the right path — it leaves no per-unit trail. Use WMS-INV-001 §4.1 Move Location for the inventory move (which produces inventory_events), then run /floor-count/confirm-mapping afterward to create the map row.

Manager weekly review (current-state):

  • Pull the count of SkuLocationMap rows created in the past 7 days. Sudden spikes usually indicate either (a) a new SKU rollout — fine, expected, or (b) someone using floor-count as a workaround for unmapped existing inventory — investigate.
  • Pull the count of locations created via floor count. Locations with type: GENERAL and zone: NULL created in the past 7 days are usually quick floor-count creates that should be retyped via WMS-INV-004.

7. Troubleshooting

SymptomCauseResolution
"Query is required" (HTTP 400) on location lookupEmpty or whitespace q parameterThe UI shouldn't submit empty queries; if you see this, refresh the page.
Location lookup returns { found: false } for a barcode you know existsLookup is case-insensitive equals, not contains. The barcode has trailing whitespace or different casing in DBTry the location name. If still missing, query SELECT name, barcode FROM locations WHERE name ILIKE '%X%' OR barcode ILIKE '%X%' to find the actual value, then fix the data.
"Location name already exists" (HTTP 409) on createName collision (case-sensitive after trim)Use the existing location returned in the error response, or pick a different name.
"Barcode already assigned to another location" (HTTP 409)Barcode collisionSame — use the existing or rebarcode.
Variant card shows hasBarcode: false even after pressing Ensure BarcodeThe endpoint runs but the page didn't refetchRefresh the variant lookup. The DB is correct; the UI is stale.
Tap Assign and movedQuantity: 0Either no UNASSIGNED location exists, or the variant has no units thereIf you expected stock to move and it didn't, check SELECT * FROM inventory_units WHERE productVariantId = ? AND locationId = (SELECT id FROM locations WHERE name = 'UNASSIGNED'). The map row is still created either way.
Print Label runs but no printPrinter not paired, web platform, printer out of paper or rangeSee WMS-REC-005 §7. Same diagnostic ladder applies.
Start Cycle Count 400s with "Session is locked by another user"Someone else is already counting this locationWait or coordinate. Cycle-count session lock is the same 5-min/heartbeat model from WMS-INV-002 §4.7.
Picking goes to the wrong bin even after I mapped the SKUMap is a hint, not authoritative. The picker's actual location was decided by allocation policy at order-timeIf a SKU should always pick from a specific bin, ensure SkuLocationMap has a row there and the location is isPickable: true and it has stock at order-allocation time.
Two SkuLocationMap rows for the same SKU exist (different locations)Expected — a SKU can be at multiple binsBoth map rows are valid. Picking allocation chooses among them based on policy. To consolidate to one bin, move stock via WMS-INV-001, then delete the unused map row in SQL.

8. Escalation

  • Audit-required move that floor count is being used for: do the move via WMS-INV-001 §4.1 (which produces inventory_events per unit), then call /confirm-mapping afterward for the map row. Don't use /assign-location if you need a per-unit audit trail.
  • UNASSIGNED location is missing from the database: IT — the assign-location flow silently no-ops the move portion. Create the location via WMS-INV-004 §4.1 with name: UNASSIGNED, type: GENERAL, isPickable: false. This should be a permanent fixture.
  • Variant has no barcode and the auto-generated SKU contains characters that don't encode in Code128 cleanly (spaces, control chars): edit the variant via the product page to set a real barcode field. Then call ensure-barcode again — it will return the new value as already-set, no auto-generation.
  • Map rows accumulating for retired SKUs or deactivated locations: cleanup is manual via SQL today. Cascading deletes happen on Location deletion (per the schema's onDelete: Cascade), but variants don't cascade-delete their map rows. Quarterly cleanup task for the warehouse manager.
  • Suspected data drift between physical stock and SkuLocationMap (pickers repeatedly hitting empty bins that the map points at): cycle-count the affected locations per WMS-INV-002. The cycle-count approval will reconcile stock; the map needs separate cleanup.

9. Revision history

VersionDateAuthorChanges
1.0[DATE][NAME]Initial release. Documents the two-step floor-count UI (location → scan) grounded in apps/api/src/routes/floor-count.routes.ts and apps/web/src/pages/floor-count/index.tsx. Documents the seven endpoints: /location GET (lookup with isMapped/hasBarcode flags), /location POST (create with type=GENERAL default, isPickable=true), /sku-lookup (exact + partial match), /assign-location (move from UNASSIGNED + upsert SkuLocationMap), /confirm-mapping (upsert only), /ensure-barcode (auto-generate from SKU and persist if missing), /print-label (uses generateProductLabel, distinct from receiving's generateReceivingLabel), /sessions (handoff to WMS-INV-002 cycle count). Documents the audit gap: assign-location writes no inventory_events rows for the moves, only the SkuLocationMap.createdAt timestamp; for audit-grade per-unit moves, use WMS-INV-001 §4.1 then call /confirm-mapping. Documents the UNASSIGNED literal-name dependency — the move portion silently no-ops if no location is named exactly UNASSIGNED. Documents the auto-generated-barcode-equals-SKU behavior in §4.6 / §4.7 and the cascade implications. Cross-references WMS-INV-001, WMS-INV-002, WMS-INV-004, WMS-INV-007, WMS-REC-001, WMS-REC-005, WMS-PICK-001, WMS-PICK-003.