Skip to main content

Place hierarchy & stock counts

Places are Loca.fi's locations and zones — warehouses, stores, aisles, dock doors — arranged in one tree per organisation. Everything else hangs off that tree: items live at a place, stocktakes count a place, and user visibility grants expand down the tree.

Read Place hierarchy & visibility first for how the tree is stored (ParentPlaceId as source of truth, derived LeftBower/RightBower nested-set ranges) — this guide is the doing, that page is the understanding.

Overview

The working loop:

  1. Build the treePOST api/places/CreatePlace, roots first (omit ParentPlaceId), then children.
  2. Read the treeGET api/places/getfilteredplaces; a subtree is a bower-range filter.
  3. Move placesPOST api/places/updateplace with a new ParentPlaceId; the server rebuilds the whole tenant's bowers.
  4. Tag zones for RFIDPOST api/places/updateplacetag.
  5. Read stock per placeGET api/places/getdashboard/{id} and the count endpoints below.

Place CRUD is gated on the Place module permission; UpdatePlaceStructure requires Management Update; the retail/v1/stock/* reads require Inventory Read.

Prerequisites

  • A token with Place read/write permission — see Getting started.
  • A Place template id for TemplateId on create — templates define the place's custom fields; see Templates & extended properties.
  • The one rule to internalise before touching the tree: never cache LeftBower/RightBower across writes — any place create or parent change anywhere in the tenant renumbers every place's bowers. Re-read after every mutation.

Step-by-step

1. Create root-level places

Omit ParentPlaceId and the place is auto-parented to the tenant root:

curl -sk -X POST "http://<host>/api/places/CreatePlace" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Store 1","TemplateId":"<placeTemplateId>",
"PlaceCode":"S1","ExternalRef":"store_01"}'

Returns 200 with a PlaceDetailDto — capture Id. The response already carries the freshly rebuilt LeftBower/RightBower. Name must be unique per organisation. Creating a place also auto-grants visibility of it to the creator (relevant for place-filtered users — see the concepts page).

2. Create children

Same endpoint, plus the parent's id:

curl -sk -X POST "http://<host>/api/places/CreatePlace" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Aisle 1","TemplateId":"<placeTemplateId>",
"ParentPlaceId":"<store1Id>"}'

Every create triggers a tenant-wide nested-set rebuild — the parent's bowers widen to enclose the new child, and unrelated places may be renumbered too.

3. Read the tree and query subtrees

getfilteredplaces returns an OData-paged PageResult<PlaceSummaryDto> (visibility-filtered, system places excluded). Each row carries ParentPlaceId, ParentPlaceName, LeftBower, and RightBower — enough to render the tree client-side.

To fetch a place and everything under it, read the ancestor's bowers first (getplace/{id}), then filter on the range ($ is %24 in curl):

# Parent's bowers, e.g. LeftBower=2, RightBower=9 → subtree is (2..9) inclusive
curl -sk "http://<host>/api/places/getfilteredplaces?%24filter=LeftBower%20ge%202%20and%20RightBower%20le%209" \
-H "Authorization: Token $TOKEN"

Both reads must happen in the same "generation" of the tree — if any place mutation lands in between, the range may be stale. Treat bowers as a disposable query mechanism and ParentPlaceId as the durable shape.

4. Move a place

A move is just an updateplace with a different ParentPlaceId. The update is full-replace: send every field you want to keep, because omitted fields are nulled on the entity. Fetch the current detail first, mutate, post back:

curl -sk -X POST "http://<host>/api/places/updateplace" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Id":"<aisle1Id>","Name":"Aisle 1","TemplateId":"<placeTemplateId>",
"ParentPlaceId":"<store2Id>"}'

Changing ParentPlaceId triggers the tenant-wide bower rebuild. Setting it to the place itself or to one of its own descendants is rejected with 400 InvalidOperation ("ParentPlaceId would create a cycle") — the server walks the proposed parent chain upward.

Re-read after every mutation

After any create, move, or structure write, previously fetched bowers are unreliable for the entire tenant — not just the branch you touched. Re-fetch before the next subtree query. Note also that a place-filtered user may briefly see a stale subtree after a move: the visibility cache is re-seeded asynchronously.

5. Bulk re-arrange with UpdatePlaceStructure

For a drag-and-drop tree editor that reorganises many places at once, POST api/places/UpdatePlaceStructure writes client-computed bowers in one call. It requires the Management module's Update permission, and it is the one endpoint where the server trusts your bower values verbatim — no rebuild, no recomputation:

curl -sk -X POST "http://<host>/api/places/UpdatePlaceStructure" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '[{"Id":"<store1Id>","LeftBower":2,"RightBower":7},
{"Id":"<aisle1Id>","LeftBower":3,"RightBower":4},
{"Id":"<aisle2Id>","LeftBower":5,"RightBower":6}]'

Returns 200 with true (an empty payload is a 200 no-op; an unknown place id in the payload is 400 NotFound). Only use this if you are supplying a complete, valid nested set for the affected branch — a malformed set corrupts every bower-range consumer (subtree queries, visibility expansion, dashboards) until the next server-side rebuild.

6. Delete places

curl -sk -X DELETE "http://<host>/api/places/deleteplace/<aisle1Id>" \
-H "Authorization: Token $TOKEN"

Returns 200 with true. The deleted place's direct children are re-parented to its parent (their own subtrees ride along), so the tree never orphans a branch. Deletion does not rebuild the nested set — the surviving bowers keep a gap until the next create or parent change closes it.

Guards: a place holding items is rejected (IllegalDeleteAttempt, "Items in Place"), and the tenant root cannot be deleted — delete validators accumulate, so a root-delete attempt returns both errors in one array. Batch deletion: batchdeleteplaces.

7. Tag a place for RFID zone tracking

Fixed readers report zones by tag, so give the place an RFID tag with updateplacetag. A place holds one tag, surfaced as a one-element list; posting a new entry replaces the current tag (recording a tag-replacement history row), and an empty list clears it:

curl -sk -X POST "http://<host>/api/places/updateplacetag" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Id":"<dockDoorId>",
"PlaceTagList":[{"TagType":"PassiveRfid","TagNumber":"303402662C00000000000001"}]}'

To find which place a scanned tag belongs to, use GetPlacesWithProperty:

curl -sk "http://<host>/api/places/GetPlacesWithProperty?propertyName=TagNumber&propertyValue=303402662C00000000000001" \
-H "Authorization: Token $TOKEN"

propertyName accepts standard place properties or an extended-property name; the response is a list of PlaceDetailDto.

8. Read stock counts per place

Three complementary reads, plus the count-maintenance writes:

  • getdashboard/{id} — the aggregate stock/order position of one place (PlaceDashboardDto): items currently in the place (TotalItemsInPlace), missing items, last stocktake date, the top per-SKU counts at this place only (SkuStockCountList) and rolled up across the subtree (RolledUpStockCountList), recent stock-count history, and pending inbound/outbound orders.
  • GET api/skus/getfilteredskustockcountforplace/{placeId}/includechildplaces/{true|false} — per-SKU, per-item-state counts for a place, optionally including its subtree (SKU Read permission).
  • GET retail/v1/stock/getcurrentstock — live per-SKU counts from the materialised stock table. Pass placeId (or placeName), plus countSubLocations=true to include descendants in the totals or includeSubLocations=true for a per-place breakdown, and barcode/skuNo/fields filters. Requires Inventory Read. Its companion, getlateststocktake, reports the position as at the last completed stocktake — the full count workflow that feeds both lives in Running a stocktake.
curl -sk "http://<host>/retail/v1/stock/getcurrentstock?placeId=<store1Id>&countSubLocations=true" \
-H "Authorization: Token $TOKEN"
Retail vs Asset

The retail/v1/stock/* endpoints are the retail surface — quantity-per-SKU counts maintained by stocktakes and reader traffic. Asset-management tenants, which track each item individually, get more value from the place dashboard's item-state breakdowns and the per-state SKU stock-count endpoint above.

For externally fed counts (an ERP pushing stock levels), maintain the place-SKU rows with setplaceskucurrentcount (absolute value, auto-creates the row) or adjustplaceskucurrentcount (signed deltaAdjustment: 2 on a count of 5 yields 7; it never auto-creates the row, and a delta that would go below zero is rejected). Place and SKU are resolvable by id, code, number, or barcode — ERP-friendly.

Related per-place reads worth knowing: weekly accuracy trend via get-inventory-score/{id}/{weeks}, and writing off long-missing stock with consume-missing-items.

Error handling

All failures follow the platform-wide error and response conventions400 with a JSON array of { ErrorCode, Message, Field }.

SituationResponse
Duplicate place Name (or PlaceCode/ExternalRef)400 InvalidOperation — the offending field name rides in Message, not Field
Invalid PlaceType / Channel enum string400 InvalidOperation naming the field
Unknown ParentPlaceId on create/update400 with an empty error array [] — the FK failure is swallowed with no error codes
ParentPlaceId set to the place itself or a descendant400 InvalidOperation, "ParentPlaceId would create a cycle"
Get / update / delete with an unknown place id400 NotFound ("place")
Delete the tenant root400 with two accumulated errors: IllegalDeleteAttempt ("Items in Place") and InvalidOperation ("Tenant root Place")
UpdatePlaceStructure with an unknown place id400 NotFound ("PlaceId")
Adjust that would take a count below zero400 InvalidOperation; the count is left unchanged
Adjust against a place-SKU row that doesn't exist400 — the Adjust endpoints never auto-create the row (use Set)
getcurrentstock without placeId or placeName400 InvalidValue
getcurrentstock with an unknown place200 with a null body — known quirk; the internal InvalidReference code is never surfaced
No token / wrong scheme (Bearer)401