Skip to main content

SOP: Location Management

Document ID: WMS-INV-004 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Managers and admins setting up the location grid; staff who need to understand zones and pick sequences


1. Purpose

This procedure governs how warehouse locations (bins, racks, zones) are created, edited, deactivated, bulk-imported, and labeled. The Location table is the spatial spine of the WMS — every InventoryUnit, every WorkTask, every CycleCountSession, and every Allocation references a location. Pick-path optimization lives or dies on whether locations have correct zones and pick sequences. Receiving needs at least one location of type RECEIVING to function (per WMS-REC-001 §4.1). Almost every other inventory SOP cross-references this one.

This SOP is also the canonical reference for the nine values of the LocationType enum and what they mean operationally — most other SOPs link here for that table rather than restating it.

2. Scope

In scope:

  • Listing and searching locations (GET /locations)
  • Creating a location (POST /locations)
  • Editing a location (PATCH /locations/:id) — name, barcode, zone hierarchy, pick sequence, pickable flag
  • Deactivating a location (DELETE /locations/:id — soft delete with side effects)
  • Viewing inventory at a location (GET /locations/:id/inventory)
  • Generating ZPL labels: single (POST /locations/:id/label) and batch (POST /locations/labels/batch)
  • Bulk importing from CSV (POST /locations/import)
  • Reading the unassigned-variant report (GET /locations/unassigned)

Out of scope:

  • Quick on-the-fly location creation during a floor-count session — see WMS-INV-003 §4.2 (uses a different endpoint with different defaults)
  • Mapping SKUs to locations — see WMS-INV-003 §4.4 / §4.5 (the SkuLocationMap flow)
  • Cycle-count flagging (needsCycleCount / cycleCountReason / lastCountedAt) — see WMS-INV-002
  • Inventory operations on units at a location — see WMS-INV-001
  • Receiving-location auto-fallback during session start — see WMS-REC-001 §4.1

3. Roles & permissions

API enforcement, verified against routes:

  • GET /locations, GET /locations/:id, GET /locations/:id/inventory, GET /locations/stats, GET /locations/unassigned → auth only
  • POST /locations, PATCH /locations/:id, DELETE /locations/:id → auth only (no role gate)
  • POST /locations/:id/label, POST /locations/labels/batch → auth only
  • POST /locations/importrequires ADMIN, MANAGER, or SUPER_ADMIN (returns HTTP 403 "Admin or Manager required" otherwise) — one of the few endpoints in the WMS API with a real role gate
  • GET /locations/import/template → auth only
RoleList & viewCreate / edit / deactivatePrint labelsBulk CSV import
READONLY— (operational expectation; not API-blocked)
STAFF✓ technically; — operationally— (HTTP 403)
MANAGER
ADMIN
SUPER_ADMIN

Operational expectations (not technically enforced, except where noted):

  • Creating, renaming, and deactivating locations is a manager/admin operation. The API allows STAFF to do it, but it shouldn't be a routine STAFF action — location renames break barcode mappings, breaking pickers. Manager review of location changes is the control.
  • The CSV bulk import is the only manager-gated endpoint here, and that's deliberate: a malformed CSV import can create hundreds of bad locations in one call. Keep it role-gated.

4. Procedures

4.1 Creating a single location

Use when: Setting up a new bin, rack, zone, or special-purpose area (receiving, packing, shipping, returns, quarantine, damaged-inventory holding).

Prerequisites:

  • Decide the location's type in advance (see §5.1). Default is STORAGE if you don't specify.
  • Decide the zone if you use zone-based picking. Locations without a zone still function but won't be picked into the zone-based pick-path optimization.
  • Decide the pick sequence if you want to control the order pickers walk this location.

Steps:

  1. From /locations, tap New Location (or hit POST /locations directly with the body).
  2. Submit:
    • name (required, must be globally unique) — typically the bin code (A-01-02)
    • barcode (optional, must be globally unique if provided) — the printed barcode used for scanning
    • type (optional, default STORAGE) — see §5.1
    • zone, aisle, rack, shelf, bin (optional) — geographic descriptors used for sorting and for the location label (per §4.4)
    • pickSequence (optional) — integer used to order locations within a zone for pick-path optimization
    • isPickable (optional, default true) — false for receiving zones, packing stations, hold areas

What this writes:

  • One Location row with active: true, the supplied or defaulted fields, and createdAt/updatedAt timestamps.
  • No audit log row. The Location table has no separate audit table; the createdAt timestamp is the only history.

Common errors:

HTTPAPI messageWhat it means
400"Name is required"The body is missing name or it's empty/whitespace.
409"Location name already exists"Names are globally unique (case-sensitive). Pick another name or use the existing one.

⚠ Barcode collision is not pre-checked on POST. Unlike the floor-count create (per WMS-INV-003 §4.2) which explicitly checks for duplicate barcode and returns 409 with the existing record, this admin endpoint does not. If two locations end up with the same barcode, the database's unique constraint will reject the second INSERT with a Prisma error and a 500 response — but the error message will be a raw constraint violation, not a friendly message. Always verify the barcode is unique before creating, or use the floor-count create flow for safety.

⚠ Default type differs by endpoint. This endpoint defaults type: STORAGE. The floor-count /floor-count/location endpoint defaults to type: GENERAL. This is a real inconsistency — locations created through different surfaces end up with different types unless explicitly specified. Always pass type explicitly when creating from this endpoint.

4.2 Editing a location

Use when: Renaming a bin, changing its zone, adjusting the pick sequence, marking a packing station as not pickable, reactivating a soft-deleted location.

Steps:

  1. From /locations/:id (the detail page), tap the relevant edit affordance — but see callout below.
  2. Submit PATCH /locations/:id with any subset of the editable fields: name, barcode, zone, aisle, rack, shelf, bin, pickSequence, isPickable, active.
  3. The API runs prisma.location.update({ where: { id }, data }) — partial update; only supplied fields are touched.

What this writes:

  • The Location row is updated. updatedAt advances.
  • No audit trail. No Location audit table exists. There is no record of what changed, only that something did. Reconstructing history requires comparing snapshots if you take them.

⚠ The detail-page UI does not expose editing today. The /locations/:id page exists and renders the location's data, but the only mutation control rendered is the Delete button. There is no Edit button, no inline edit, no rename modal. The PATCH endpoint works fine — the UI just doesn't call it. To edit a location today, you have three options: (a) use a tool like Bruno/Postman with your auth cookie to call PATCH directly; (b) edit the database row manually (fastest but no validation, no app-layer checks); (c) wait for the UI to be built. See §8 — this is a tractable engineering task.

Common errors:

HTTPAPI messageWhat it means
404"Location not found"The id doesn't exist. Verify by listing first.
500(Prisma constraint error)Renaming to a name or barcode that already exists hits the unique constraint. The error response is raw — read the message for the column name.

4.3 Deactivating (soft-deleting) a location

Use when: A bin is being permanently retired — physical bin removed from the warehouse, zone restructured, packing station decommissioned.

Prerequisites:

  • The location must have no inventory units with quantity > 0. The endpoint enforces this — attempting to delete a location with active stock returns HTTP 409.

Steps:

  1. From the location detail page (/locations/:id), scroll to the Delete this location section. The text reads: Deactivates the location and removes it from workflows. Cannot be deleted if it still holds inventory.
  2. Tap Delete.
  3. The ConfirmModal opens with: This will deactivate {location.name} and remove all SKU-to-location mappings. This action cannot be undone. The modal requires you to type the location name as confirmation.
  4. Type the name. Tap Delete Location.

What this does (transaction):

  • Sets Location.active: false. The row is not deleted. Soft-delete only.
  • Deletes all SkuLocationMap rows referencing this location (mappingsRemoved count returned).
  • Deletes any InventoryUnit rows at this location with quantity <= 0 (emptyUnitsRemoved count returned). This is a cleanup pass — units with positive quantity would have failed the precheck already.

Common errors:

HTTPAPI messageWhat it means
404"Location not found"The id is wrong. Refresh the list.
409"Cannot delete location with inventory"At least one InventoryUnit at this location has quantity > 0. The response includes inventoryCount. Move the inventory out first per WMS-INV-001 §4.1, then retry.

⚠ "Soft delete" is a misleading name here. The active flag flips, but SkuLocationMap rows are hard-deleted in the same transaction. If you reactivate the location later (PATCH active: true), the SKU-to-location hints are gone — they need to be recreated via the floor-count flow per WMS-INV-003 §4.4 / §4.5. The deletion is irreversible for those rows.

⚠ Reactivation works. Setting active: true via PATCH brings the location back. Inventory units, sessions, and adjustments still reference it (the FKs are intact — they only got soft-deleted, not cascade-removed). Only the SKU mappings are gone. If you accidentally deactivated a location, this is the recovery path.

4.4 Generating ZPL labels for locations

Use when: Labeling new bins, replacing a damaged location label, batch-printing labels for a freshly-imported zone.

Prerequisites:

  • The Zebra ZQ511 is paired and connected per WMS-REC-005 §4.1 (the navbar PrinterStatus icon is green).
  • For batch print: a list of location IDs ready.

Single-location label:

  1. (No UI button mounted today; see §8.) Call POST /locations/:id/label with optional { copies?: number } (default 1, hard-capped at 10).
  2. The response is { zpl: string, barcode: string, name: string }.
  3. Send the ZPL to the connected printer via zebraPrint.printZpl(zpl).

Batch-location labels:

  1. (No UI button mounted today; see §8.) Call POST /locations/labels/batch with { locationIds: string[] } (any length; no hard cap).
  2. The endpoint loads each location ordered by (zone ASC, pickSequence ASC, name ASC) — labels print in pick-walk order, ready to be applied as you walk.
  3. Returns { zpl: string, count: number }.

Label content (from generateLocationLabel, 2"×1" at 203 DPI):

| Element | Source field | Notes | | ----------------- | --------------- | ------------------------------------------ | --- | ----- | | Top center, large | location.name | Font 30, sanitized | | Middle | Code128 barcode | Auto-scaled module width; encodes barcode | | name | | Bottom-left | Zone:{zone} | Only rendered if zone is set | | Bottom-right | {type} | Only rendered if type is set |

The aisle, rack, shelf, bin descriptors are stored on the Location row but not rendered on the label (the template ignores them). If your physical location naming encodes those descriptors in name (e.g., A-01-02-03), they're visible. If they live only in the structured fields, they don't appear on the printed label.

Common errors:

HTTPAPI messageWhat it means
404"Location not found"Single-label call with bad id.
400"No location IDs provided"Batch call with empty locationIds.

4.5 Bulk importing locations from CSV

Use when: Setting up a warehouse from scratch, migrating from a spreadsheet, adding a new aisle of locations all at once. Also imports SKU-to-location mappings as a side effect.

Prerequisites:

  • Your role is MANAGER, ADMIN, or SUPER_ADMIN. STAFF cannot run this.
  • A CSV file matching the template format (download via GET /locations/import/template).

Template CSV format:

SKU,LOCATION,WAREHOUSE,AISLE,BAY,TIER,SPACE,BIN
EXAMPLE-SKU-001,1-A-1-A-1-X,1,A,1,A,1,X
EXAMPLE-SKU-002,1-A-1-B-1-X,1,A,1,B,1,X

Each row creates (or finds) a Location and creates a SkuLocationMap linking the SKU to it. The WAREHOUSE / AISLE / BAY / TIER / SPACE / BIN columns map to the structured fields on Location.

Steps:

  1. Download the template via GET /locations/import/template (returns a CSV with Content-Disposition: attachment).
  2. Fill the CSV. Each row is one (SKU, location) pair. Locations are created on first encounter; subsequent rows for the same LOCATION reuse it.
  3. Submit POST /locations/import with body { csv: <full text> }. (No file upload — pass the CSV body as a string in the JSON body.)
  4. The endpoint:
    • Parses the CSV.
    • Normalizes each row (trim, basic field mapping).
    • Filters out rows with missing required fields.
    • Calls LocationService.importLocations(rows, userId).
  5. Response: { success: true, locationsCreated, mappingsCreated, skipped }.

Common errors:

HTTPAPI messageWhat it means
403"Admin or Manager required"Role gate. Have a manager run it.
400"CSV data required"Body is missing the csv field.
400"No valid rows found in CSV"Every row was filtered out by normalizeRow — likely missing required columns or all rows have empty SKU/LOCATION. Check the CSV against the template format.
500<error.message>Service-level error during the import transaction. Read the response body. Common cause: a CSV row tries to create a location that already exists with a different barcode or type than the row implies — the service fails the import partway.

⚠ The import does not validate SKUs against existing variants before creating mappings. A CSV with a typo in the SKU column will create a SkuLocationMap row pointing at a non-existent SKU. The picker won't error — it just won't find the SKU at that location. Validate the SKU column against your variant table before importing.

⚠ Re-importing the same CSV is mostly idempotent — but not entirely. Locations are looked up by name and reused. SKU mappings are upserted. But if you change a row's structured fields (e.g., AISLE from A to B) and re-import, the existing Location.aisle is not updated by the import — it skips the location create. The CSV is treated as a one-shot setup, not an ongoing source of truth.

4.6 Viewing the unassigned-variant report

Use when: Auditing which SKUs have no inventory units anywhere — newly imported variants that haven't been received yet, or variants whose stock got moved to UNASSIGNED but never re-mapped.

Steps:

  1. Call GET /locations/unassigned?skip=0&take=50. (No UI surfaces this today — see §8.)
  2. The response lists productVariant rows that have no InventoryUnit rows associated.

This is a discovery tool for the warehouse manager — variants with no stock anywhere are either (a) brand-new SKUs awaiting first receipt, (b) SKUs that have sold through and need reordering, or (c) SKUs that lost stock through a deletion or migration and need investigation.

5. Reference

5.1 The LocationType enum

TypeOperational meaningUsed by
RECEIVINGInbound staging zone — where goods land after receipt and before putawayWMS-REC-001 §4.1 uses this as the preferred receiving-location for sessions; falls back to STORAGE if none exists
STORAGEGeneral bulk storage — pickable but not the optimal pick faceDefault bin type. Default type for POST /locations
PICKINGPickable bins along an optimized pick pathPick allocation (WMS-PICK-001) prefers these
PACKINGPacking station, not pickableWMS-PACK-001
SHIPPINGShipping staging area, not pickableWMS-SHIP-001
RETURNSInbound customer-return staging zoneWMS-RET-001 through WMS-RET-003
QUARANTINEHolding zone for items pending inspection or regulatory holdWMS-INV-005 (recall handling)
DAMAGED_INVENTORYHolding zone for damaged stock awaiting dispositionWMS-INV-001 §4.3
GENERALCatch-all. Default for floor-count quick-create. Use sparingly.WMS-INV-003 §4.2 quick-create

The only types with system-level behavior are RECEIVING (auto-fallback in WMS-REC-001 §4.1) and the soft preferences in pick-allocation (per WMS-PICK-001). Most types are descriptive — they don't change what the system does, just what the staff and reports see.

5.2 Zone, aisle, rack, shelf, bin

The Location row has five structured geographic fields — zone, aisle, rack, shelf, bin — all optional strings.

  • Zone is the only one used by application logic today. It groups locations for filtering on the locations page, drives the ORDER BY zone clause on list/batch endpoints, and is what pick_sequence is within.
  • Aisle / rack / shelf / bin are descriptive only — they're stored, displayed on the location detail page, and not rendered on the printed label (per §4.4). They're not used by allocation, picking, or any other workflow.

If you want labels to show full location coordinates, encode them in the name field (e.g., Z01-A03-R02-S04-B05). The name is what the label prominently displays.

5.3 Pick sequence

pickSequence is an optional integer used to order locations within a zone for pick-path optimization. The list endpoint orders by (zone ASC, pickSequence ASC, name ASC) — which is also the order batch labels print in (per §4.4). The picking allocation algorithm (WMS-PICK-001) consults this to decide the walk order across multiple locations within a single pick task.

Conventions:

  • Leave pickSequence null on packing/receiving/shipping zones. They aren't picked.
  • Within a zone, give each pickable bin a unique pickSequence. Tie-breakers default to name ASC.
  • Use sparse numbering (10, 20, 30, ...) so you can insert a new location between two existing ones without renumbering.

5.4 What deactivation does and doesn't do (cheat sheet)

EffectWhat happens
Location.active flagSet to false
SkuLocationMap rowsHard-deleted (count returned)
InventoryUnit rows with quantity > 0Blocks the deletion — must be moved first
InventoryUnit rows with quantity ≤ 0Hard-deleted (count returned)
ReceivingSession, CycleCountSession, WorkTask, etc. referencesUntouched — historical records preserved
Reactivation possible?Yes, via PATCH active: true. SKU mappings stay deleted.
  • WMS-INV-001 §4.1 — Move Location (use this to vacate a location before deactivating)
  • WMS-INV-002 — Cycle counts (uses Location for sessions)
  • WMS-INV-003 §4.2 — Floor-count create-on-the-fly (alternate creation path with different defaults)
  • WMS-INV-005 — Quarantine zone usage
  • WMS-REC-001 §4.1 — Receiving location auto-fallback
  • WMS-REC-005 — ZQ511 setup and ZPL conventions
  • WMS-PICK-001 — Pick-path optimization (consumes zone + pickSequence)

6. Audit & compliance

Location writes have no audit log. Unlike InventoryUnit (events), ReceivingSession (audit logs), CycleCountSession (audit logs), or WorkTask (events), the Location table has no companion table tracking what changed, when, or by whom.

This means:

  • You cannot reconstruct the history of a location's name, barcode, type, zone, or pickSequence over time. If a location was renamed, the old name is gone — only the current row exists.
  • Soft-deleted locations carry their active: false flag forever, but there's no record of who deactivated when (beyond the row's updatedAt timestamp, which represents some update — not necessarily the deletion).
  • Bulk-imported locations have no record of which import created them. The LocationService.importLocations call passes a userId but the implementation does not write an audit log — only the createdAt is preserved.

For audit-grade compliance (cannabis/vape regulatory reporting, financial-controls audits), this is a real gap. Practical workaround: take periodic snapshots of the locations table to a separate audit warehouse if your jurisdiction requires it.

Manager monthly review:

  • Pull GET /locations/stats — total, active, by-zone, by-type, with-inventory.
  • Pull GET /locations/unassigned — variants with no inventory anywhere.
  • Diff active count against last month. Sudden drops indicate someone deactivated a chunk of locations; sudden gains indicate a CSV import happened.
  • Cross-reference byType: { GENERAL: n } against last month. Floor-count quick-creates default to GENERAL; a rising count means the GENERAL bins should be reviewed and retyped per §4.2.

7. Troubleshooting

SymptomCauseResolution
"Cannot delete location with inventory" (HTTP 409)At least one InventoryUnit here has quantity > 0Move the units out via WMS-INV-001 §4.1, then retry. The 409 response includes inventoryCount so you know how many to move.
"Location name already exists" (HTTP 409) on createNames are globally uniquePick a different name, or use the existing location.
PATCH succeeds but the rename "didn't apply" in the receiving sessionThe session was loaded with the old name cached on the clientRefresh the session. The DB has the new name.
Deactivated a location, now picks for an allocated order are failingThe order's Allocation rows still reference the location ID (FK preserved across soft-delete) but the location is active: falseEither reactivate the location to clear the picks (PATCH active: true), or cancel the order's allocation and re-allocate to a different location.
Imported a CSV but the structured fields (aisle, bay, etc.) don't show on existing locationsThe import skips updating existing locations — only creates new onesPer §4.5, re-imports are mostly idempotent for creation. To update existing structured fields, use PATCH /locations/:id per location, or update via SQL.
Two locations have the same barcodeThe unique constraint on barcode should prevent this — but if it happened, it's a database bug or a race condition during concurrent insertsEscalate to IT. Pick a unique barcode for one of them.
inventory_count > 0 in stats but the inventory list at the location is emptyOne or more InventoryUnit rows have quantity = 0 and a status != AVAILABLE. The withInventory count uses some: {} (any unit, regardless of qty)Soft-delete will clean these up automatically. Or query and remove via SQL.
POST /locations/import returns 500 with a CSV error mid-importThe service-level transaction fails partway throughSome locations may have been created before the failure; the import is not atomic per-row. Diff the locations table against the CSV to see what got created, fix the CSV, re-run for the remainder.
The location label printed without zone or type infoThe label template only renders zone and type if those fields are non-emptyConfirm the location has both fields populated. Edit via PATCH if needed, then reprint.
The label printed with the bin code centered but truncatedThe sanitize helper strips non-printable chars, but font 30 + a long name overflowsShorten the name or change the label template (generateLocationLabel in zpl.ts) to use a smaller font.
Tried to call POST /locations/import and got 403Your role is below MANAGERHave a manager run it.

8. Escalation

  • Build the location editor UI (highest leverage — the PATCH endpoint works, the detail page renders, only the edit affordances are missing): one focused engineering task. Mount an Edit button on /locations/:id, expose a form for the editable fields, wire to PATCH. Probably half a day.
  • Build the bulk-print UI: the batch label endpoint exists and orders correctly for pick-walk printing, but no UI calls it. Mount a "Print labels for selected" CTA on the locations list page.
  • Build the unassigned-variants UI: GET /locations/unassigned returns useful data, but no page surfaces it. Mount a tab on the locations dashboard.
  • Add a Location audit log: the highest-impact compliance ticket in this SOP. Track every create / edit / deactivate / reactivate with userId, timestamp, and a JSON snapshot of the change. The pattern is the same as cycle_count_audits or audit_logs (used by receiving). Cross-reference with WMS-INV-007 §8 — adjustments have similar audit-trail concerns.
  • Standardize default type between create endpoints: today, POST /locations defaults to STORAGE, POST /floor-count/location defaults to GENERAL. Pick one (probably STORAGE) and align both. Or document the divergence prominently.
  • Suspected bad CSV import: roll back via SQL — DELETE FROM sku_location_maps WHERE created_at > '<import time>', then UPDATE locations SET active = false WHERE created_at > '<import time>'. The import isn't atomic so partial recovery is sometimes needed.

9. Revision history

VersionDateAuthorChanges
1.0[DATE][NAME]Initial release. Documents the seven core operations on the Location model — list/search, view, create, edit, deactivate, label-print, CSV-import — grounded in apps/api/src/routes/location.routes.ts, apps/api/src/routes/location-import.routes.ts, packages/db/prisma/schema/products.prisma, and apps/web/src/pages/locations/{index,[id]}.tsx. Documents the nine LocationType enum values as the canonical reference; most other inventory and receiving SOPs cross-link here for the type table. Documents the default-type inconsistency between POST /locations (defaults STORAGE) and /floor-count/location (defaults GENERAL). Documents the UI gap on edit and print (PATCH endpoint and label endpoints work, but the locations detail page only mounts the Delete affordance). Documents the role gate on bulk import (/locations/import requires Admin/Manager — the only role-gated location endpoint, returning HTTP 403 "Admin or Manager required" otherwise). Documents the soft-delete cascading semanticsLocation.active: false flips, SkuLocationMap rows hard-delete, zero-qty InventoryUnit rows hard-delete; all other FKs preserved. Documents the complete absence of an audit log for location changes (no location_audits table, no audit_logs row written for create/edit/deactivate). Cross-references WMS-INV-001 (move before deactivate), WMS-INV-002 (cycle-count flagging fields), WMS-INV-003 (alternate create endpoint with different default), WMS-INV-005 (quarantine type), WMS-REC-001 (receiving-location fallback), WMS-REC-005 (ZPL conventions), WMS-PICK-001 (zone + pickSequence consumption).