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
SkuLocationMapflow) - 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 onlyPOST /locations,PATCH /locations/:id,DELETE /locations/:id→ auth only (no role gate)POST /locations/:id/label,POST /locations/labels/batch→ auth onlyPOST /locations/import→ requiresADMIN,MANAGER, orSUPER_ADMIN(returns HTTP 403"Admin or Manager required"otherwise) — one of the few endpoints in the WMS API with a real role gateGET /locations/import/template→ auth only
| Role | List & view | Create / edit / deactivate | Print labels | Bulk 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
STORAGEif 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:
- From
/locations, tap New Location (or hitPOST /locationsdirectly with the body). - 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 scanningtype(optional, defaultSTORAGE) — see §5.1zone,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 optimizationisPickable(optional, defaulttrue) — false for receiving zones, packing stations, hold areas
What this writes:
- One
Locationrow withactive: true, the supplied or defaulted fields, andcreatedAt/updatedAttimestamps. - No audit log row. The
Locationtable has no separate audit table; thecreatedAttimestamp is the only history.
Common errors:
| HTTP | API message | What 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 secondINSERTwith 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/locationendpoint defaults totype: GENERAL. This is a real inconsistency — locations created through different surfaces end up with different types unless explicitly specified. Always passtypeexplicitly 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:
- From
/locations/:id(the detail page), tap the relevant edit affordance — but see callout below. - Submit
PATCH /locations/:idwith any subset of the editable fields:name,barcode,zone,aisle,rack,shelf,bin,pickSequence,isPickable,active. - The API runs
prisma.location.update({ where: { id }, data })— partial update; only supplied fields are touched.
What this writes:
- The
Locationrow is updated.updatedAtadvances. - No audit trail. No
Locationaudit 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/:idpage 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 callPATCHdirectly; (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:
| HTTP | API message | What 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:
- 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. - Tap Delete.
- The
ConfirmModalopens 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. - Type the name. Tap Delete Location.
What this does (transaction):
- Sets
Location.active: false. The row is not deleted. Soft-delete only. - Deletes all
SkuLocationMaprows referencing this location (mappingsRemovedcount returned). - Deletes any
InventoryUnitrows at this location withquantity <= 0(emptyUnitsRemovedcount returned). This is a cleanup pass — units with positive quantity would have failed the precheck already.
Common errors:
| HTTP | API message | What 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
SkuLocationMaprows are hard-deleted in the same transaction. If you reactivate the location later (PATCHactive: 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: trueviaPATCHbrings 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
PrinterStatusicon is green). - For batch print: a list of location IDs ready.
Single-location label:
- (No UI button mounted today; see §8.) Call
POST /locations/:id/labelwith optional{ copies?: number }(default 1, hard-capped at 10). - The response is
{ zpl: string, barcode: string, name: string }. - Send the ZPL to the connected printer via
zebraPrint.printZpl(zpl).
Batch-location labels:
- (No UI button mounted today; see §8.) Call
POST /locations/labels/batchwith{ locationIds: string[] }(any length; no hard cap). - 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. - 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:
| HTTP | API message | What 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, orSUPER_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:
- Download the template via
GET /locations/import/template(returns a CSV withContent-Disposition: attachment). - Fill the CSV. Each row is one (SKU, location) pair. Locations are created on first encounter; subsequent rows for the same
LOCATIONreuse it. - Submit
POST /locations/importwith body{ csv: <full text> }. (No file upload — pass the CSV body as a string in the JSON body.) - The endpoint:
- Parses the CSV.
- Normalizes each row (trim, basic field mapping).
- Filters out rows with missing required fields.
- Calls
LocationService.importLocations(rows, userId).
- Response:
{ success: true, locationsCreated, mappingsCreated, skipped }.
Common errors:
| HTTP | API message | What 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
SkuLocationMaprow 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.,
AISLEfromAtoB) and re-import, the existingLocation.aisleis 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:
- Call
GET /locations/unassigned?skip=0&take=50. (No UI surfaces this today — see §8.) - The response lists
productVariantrows that have noInventoryUnitrows 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
| Type | Operational meaning | Used by |
|---|---|---|
RECEIVING | Inbound staging zone — where goods land after receipt and before putaway | WMS-REC-001 §4.1 uses this as the preferred receiving-location for sessions; falls back to STORAGE if none exists |
STORAGE | General bulk storage — pickable but not the optimal pick face | Default bin type. Default type for POST /locations |
PICKING | Pickable bins along an optimized pick path | Pick allocation (WMS-PICK-001) prefers these |
PACKING | Packing station, not pickable | WMS-PACK-001 |
SHIPPING | Shipping staging area, not pickable | WMS-SHIP-001 |
RETURNS | Inbound customer-return staging zone | WMS-RET-001 through WMS-RET-003 |
QUARANTINE | Holding zone for items pending inspection or regulatory hold | WMS-INV-005 (recall handling) |
DAMAGED_INVENTORY | Holding zone for damaged stock awaiting disposition | WMS-INV-001 §4.3 |
GENERAL | Catch-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 zoneclause on list/batch endpoints, and is whatpick_sequenceis 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
pickSequencenull on packing/receiving/shipping zones. They aren't picked. - Within a zone, give each pickable bin a unique
pickSequence. Tie-breakers default toname 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)
| Effect | What happens |
|---|---|
Location.active flag | Set to false |
SkuLocationMap rows | Hard-deleted (count returned) |
InventoryUnit rows with quantity > 0 | Blocks the deletion — must be moved first |
InventoryUnit rows with quantity ≤ 0 | Hard-deleted (count returned) |
ReceivingSession, CycleCountSession, WorkTask, etc. references | Untouched — historical records preserved |
| Reactivation possible? | Yes, via PATCH active: true. SKU mappings stay deleted. |
5.5 Related SOPs
- WMS-INV-001 §4.1 — Move Location (use this to vacate a location before deactivating)
- WMS-INV-002 — Cycle counts (uses
Locationfor 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, orpickSequenceover time. If a location was renamed, the old name is gone — only the current row exists. - Soft-deleted locations carry their
active: falseflag forever, but there's no record of who deactivated when (beyond the row'supdatedAttimestamp, which represents some update — not necessarily the deletion). - Bulk-imported locations have no record of which import created them. The
LocationService.importLocationscall passes auserIdbut the implementation does not write an audit log — only thecreatedAtis 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 toGENERAL; a rising count means the GENERAL bins should be reviewed and retyped per §4.2.
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
"Cannot delete location with inventory" (HTTP 409) | At least one InventoryUnit here has quantity > 0 | Move 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 create | Names are globally unique | Pick a different name, or use the existing location. |
PATCH succeeds but the rename "didn't apply" in the receiving session | The session was loaded with the old name cached on the client | Refresh the session. The DB has the new name. |
| Deactivated a location, now picks for an allocated order are failing | The order's Allocation rows still reference the location ID (FK preserved across soft-delete) but the location is active: false | Either 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 locations | The import skips updating existing locations — only creates new ones | Per §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 barcode | The unique constraint on barcode should prevent this — but if it happened, it's a database bug or a race condition during concurrent inserts | Escalate to IT. Pick a unique barcode for one of them. |
inventory_count > 0 in stats but the inventory list at the location is empty | One 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-import | The service-level transaction fails partway through | Some 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 info | The label template only renders zone and type if those fields are non-empty | Confirm the location has both fields populated. Edit via PATCH if needed, then reprint. |
| The label printed with the bin code centered but truncated | The sanitize helper strips non-printable chars, but font 30 + a long name overflows | Shorten the name or change the label template (generateLocationLabel in zpl.ts) to use a smaller font. |
Tried to call POST /locations/import and got 403 | Your role is below MANAGER | Have 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/unassignedreturns useful data, but no page surfaces it. Mount a tab on the locations dashboard. - Add a
Locationaudit log: the highest-impact compliance ticket in this SOP. Track every create / edit / deactivate / reactivate withuserId,timestamp, and a JSON snapshot of the change. The pattern is the same ascycle_count_auditsoraudit_logs(used by receiving). Cross-reference with WMS-INV-007 §8 — adjustments have similar audit-trail concerns. - Standardize default
typebetween create endpoints: today,POST /locationsdefaults toSTORAGE,POST /floor-count/locationdefaults toGENERAL. Pick one (probablySTORAGE) 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>', thenUPDATE locations SET active = false WHERE created_at > '<import time>'. The import isn't atomic so partial recovery is sometimes needed.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 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 semantics — Location.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). |