Skip to main content

Running a stocktake

A stocktake (an inventory in the API surface) is Loca.fi's audited count of a place: configure how variance is judged, create the count, upload scan batches, resolve, and — depending on your settings — pass through a validation gate and a manager approval gate before the platform corrects item states and locations. This guide walks the full loop end to end, including the blind-count handoff between a back-office creator and a scanning device, and the lightweight cycle-count alternative.

Read Stocktakes & counting first for the state machine and the inventory-vs-cycle-count split — this guide is the doing, that page is the understanding.

Overview

The happy path is six moves:

  1. Configure validation and approval once — setorganisationinventorysettings (and per-place overrides).
  2. Create the stocktake — createinventory (state Created).
  3. Scan — upload one or more scan batches with addinventorysnapshot (state InProgress).
  4. Finish — reviewed (resolveinventory) or unattended (resolveinventorywithoutinput, or the one-shot addsnapshotandcomplete).
  5. Fork on outcomeComplete, ValidationFailed, or PendingApproval; parked counts need a place manager's manageinventory decision.
  6. Report — variance via getinventoryvalidationrecords/{id}, audit via getinventorytransactions/{id}.

Every inventory action is gated on the Inventory module permission (settings endpoints accept Administration / Management / Organisation-or-Place instead — any one suffices).

Prerequisites

  • A token with Inventory permissions — see Getting started.
  • A place to count (not a system place) — see Places & visibility.
  • Items/tags at that place. Unknown tags are handled — the scan upload auto-creates items for them — but a meaningful variance report needs an expected baseline (stock on hand, a previous stocktake, or external counts).
  • For the approval flow: the approving user must be a place manager of the counted place.

Step-by-step

1. Configure validation and approval

Settings drive what the resolve engine does with the count. Set them organisation-wide, then override per place where needed:

curl -sk -X POST "http://<host>/api/inventorysettings/setorganisationinventorysettings" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"EnforceInventoryValidation":true,
"ValidationSource":"CurrentStock",
"ValidationType":"SkuValidation",
"ApprovalType":"Manual",
"AllowedVariance":5.0}'
FieldEffect
ValidationSourceWhere the expected baseline comes from: CurrentStock (items currently in stock), LastInventoryCount (previous completed stocktake), or ExternalCount (ERP-fed counts, honouring per-SKU variance overrides). Unset means scanned-only rows, never gated.
ValidationTypeSkuValidation — every gated per-SKU row must pass; TotalValidation — a single aggregate row (SkuId: null) solely decides pass/fail.
EnforceInventoryValidationtrue makes variance rows gating (fail → ValidationFailed); false still produces the rows, purely informational.
ApprovalTypeAuto completes a passing resolve immediately; Manual parks it at PendingApproval for a place manager.
AllowedVarianceDefault tolerated variance percentage per gated row.

Per-place overrides go to setplaceinventorysettings with a PlaceId plus OverrideGlobalSettings: true — without that flag the resolve engine ignores the place row and falls back to the organisation row. Always supply PlaceId on the place endpoint (omitting it saves the payload as the organisation row, then fails the read-back with a 500). Read back with getorganisationinventorysettings / getplaceinventorysettings/{placeId} — the place read returns the raw place row without applying the fallback.

2. Create the stocktake

curl -sk -X POST "http://<host>/api/inventories/createinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Store 1 EOFY stocktake","PlaceId":"<placeId>",
"StocktakeScope":"Full","IsBlind":true}'

Returns 200 with an InventoryDetailDto (InventoryState: "Created") — capture the Id for every later call.

Three optional fields pick the subtype, which restricts the expected baseline at resolve time:

  • Nothing extra → Standard (full-place count).
  • SkuGroupIdSKU Group (count judged against one SKU group).
  • SkuIds (non-empty list) → Custom SKU (an ad-hoc SKU list).
  • InventoryType: "Consolidated Inventory" → the multi-zone roll-up (see below) — the explicit InventoryType string ("Standard", "SKU Group", "Custom SKU", "Consolidated Inventory" — spaced wire values) is the only way to create one.

Supplying SkuGroupId and SkuIds together is rejected. Open-count uniqueness is enforced per place: one open Standard count, one open SKU Group count per group, unlimited Custom SKU counts — and a Consolidated count requires no other open count at all. A violation is 400 InvalidOperation with the descriptive text in Field, not Message.

StocktakeScope and IsBlind are reporting metadata, covered in Blind counts & StocktakeScope below — they do not change what the API counts.

3. Upload scans (repeat as needed)

Each scan batch from the handheld/reader becomes an immutable snapshot at the inventory's place. Upload as many batches as the count needs:

curl -sk -X POST "http://<host>/api/inventories/addinventorysnapshot" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>",
"SnapshotType":"Add",
"StartTime":"2026-07-07T09:00:00Z","EndTime":"2026-07-07T09:12:30Z",
"Tags":[
{"TagNumber":"3034257BF7194E4000001A85","TagType":"PassiveRfid",
"ReadCount":12,"Rssi":-52.5,"LastReadTime":"2026-07-07T09:11:58Z"}]}'

Returns 200 with an InventoryOperationResultDto (always this shape on this endpoint): InventoryDto is the refreshed detail (now InProgress) and Notifications lists items whose tags were scanned but excluded because their current item state disallows counting — items mid-order (Allocated, Reserved, Dispatched, OnLoan, Consumed). Surface these to the operator; they are not an error.

Worth knowing:

  • Tags are deduped by TagNumber (latest LastReadTime wins); empty tag numbers are dropped.
  • Unknown tags auto-create items at the counted place (in-scope SKUs only for SkuGroup/CustomSku subtypes).
  • SnapshotType: "Remove" removes the submitted tags from the count — the undo for a mis-scanned batch.
  • Concurrent mutations on the same inventory are serialised by a per-inventory lock; a busy lock is refused with 400 InvalidOperation — retry.

4. Finish the count — three ways

Reviewed — the operator confirms each item's disposition. Fetch the live lists from getinventory/{id}, let the operator review, then post the three lists back:

curl -sk -X POST "http://<host>/api/inventories/resolveinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"Id":"<inventoryId>","ReturnResultDto":true,
"FoundItemsExpected":[{"Id":"<itemId1>"},{"Id":"<itemId2>"}],
"FoundItemsUnexpected":[{"Id":"<itemId3>","ReasonId":"<reasonId>"}],
"MissingItems":[{"Id":"<itemId4>","ReasonId":"<reasonId>"}]}'

The list an item appears in decides its outcome — FoundItemsExpectedPresent, FoundItemsUnexpectedMoved (physically relocated here), MissingItemsMissing. Any ItemState you send per item is ignored. ReasonId must reference a Reason with Scope: "Item". Each list's count must reconcile with the server's own computation within ±5%, and duplicate item ids within a list are rejected — both fail with FailedToResolveInventory telling you to re-fetch the server's lists.

Unattended — trust the scans; the server derives everything itself, no reconciliation check, no per-item reasons:

curl -sk -X POST "http://<host>/api/inventories/resolveinventorywithoutinput" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>","ReturnResultDto":true}'

One-shotaddsnapshotandcomplete takes the same body as the scan upload and immediately runs the unattended resolve: a single call for single-scan counts. Its response never carries the Notifications list, so prefer the two-step flow when excluded items matter.

With ReturnResultDto: true the response is InventoryOperationResultDto; false (the default) returns the legacy bare InventoryDetailDto.

5. Fork on the outcome

The resolved InventoryState is one of three:

StateMeaningNext move
CompleteValidation passed (or was ungated) and ApprovalType is Auto. Item states and places are corrected now: found → Present, unscanned expected → Missing, foreign items → moved here + Moved.Done — read the reports.
ValidationFailedA gated variance check failed. No item changes applied; parked.Manager approves/rejects — or add more scans and re-resolve.
PendingApprovalValidation passed but ApprovalType is Manual. No item changes yet.Manager approves/rejects.

When parked, getinventory/{id} populates CurrentUserIsPlaceManager — use it to show or hide the approve/reject UI. Parked counts stay re-resolvable: only Complete blocks further scans and resolves.

6. Read the variance report

curl -sk "http://<host>/api/inventories/getinventoryvalidationrecords/<inventoryId>" \
-H "Authorization: Token $TOKEN"

Returns InventoryValidationRecordDto rows — per SKU: ExpectedQty, ScannedQty, Variance (signed %, round((scanned/expected − 1) × 100, 2)), tri-state IsValid (null when the row wasn't gated — check IsValidationGated), and UnitCostAtCount for valuing the variance. A SkuId: null row is the TotalValidation aggregate. Rows are deleted and rebuilt on every resolve — export before re-counting if you need the old report. Cross-stocktake shrinkage reporting uses the OData variant, getfilteredinventoryvalidationrecords.

7. Manager approve or reject

Only a place manager of the counted place may decide (agents always fail the check), and only from PendingApproval / ValidationFailed:

curl -sk -X POST "http://<host>/api/inventories/manageinventory" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"InventoryId":"<inventoryId>","ApprovalState":0,
"Comment":"Variance investigated - denim shrinkage confirmed."}'

ApprovalState is int-serialized (unlike most enums in this domain): 0 = Approved, 1 = Rejected. Approve runs the full item cascade and moves the count to Complete; reject moves it to Rejected (terminal, no item changes ever applied). The comment lands on the audit row.

8. Audit trail

curl -sk "http://<host>/api/inventories/getinventorytransactions/<inventoryId>" \
-H "Authorization: Token $TOKEN"

One InventoryTransactionSummaryDto per lifecycle step (Created, SnapshotAdded, PendingApproval, Approved, Rejected, Completed — and the persisted-typo literal ValidatonFailed), newest first, with the acting user/agent and comments. On an approve, the Approved row is backdated one second before Completed so they sort correctly.

Running it asynchronously

Large counts can take minutes to resolve. Every mutating call in steps 3–7 accepts NotifyOnceComplete: true plus NotificationRecipientId set to your own user/agent id — the work is queued, the response returns immediately with a BackgroundTaskId and a placeholder body (InventoryState: "Processing" — synthetic, not read from the database), and a completion notification is delivered when done. A recipient id that isn't yours silently falls back to synchronous processing (by design). The polling and subscription mechanics are on Asynchronous operations.

Blind counts & StocktakeScope

IsBlind and StocktakeScope on the create request are stored metadata: the API round-trips them on the summary/detail DTOs but no server behaviour branches on them today. Scope filtering of what gets counted is driven by the subtype (SkuGroupId/SkuIds), never by StocktakeScope. The metadata exists so clients and reporting can agree on the count's purpose:

  • IsBlind: true — the operator UI must hide expected quantities and live variance during scanning (audit-grade, compliance, and loss-prevention counts). Enforcing that is the client's job — the API still returns ExpectedQty and variance to anyone with Read permission.
  • StocktakeScopeFull (annual/EOFY whole-place), Cycle (rolling program slice), Spot (targeted SKU subset), HotZone (high-shrink area sweep). Defaults per subtype: Standard/Consolidated → Full, SkuGroup / CustomSku → Spot (cycle counts default to Cycle). An incompatible value (e.g. Full on a SkuGroup count) is silently normalised to the subtype default — no 400 — so send the right value rather than relying on the server.

The canonical blind handoff — created in the back office, counted on a device:

  1. Back office creates with IsBlind: true (step 2) and hands over the id.
  2. Device fetches getinventory/{id} on first open, reads IsBlind and StocktakeScope, and caches them for the whole count (they are fixed at create — no update endpoint accepts them). Blind → show only what has been scanned, never expected counts or variance.
  3. The device runs the normal endpoints — addinventorysnapshot, then resolveinventorywithoutinput or addsnapshotandcomplete. Nothing about the wire flow changes when blind.
  4. After resolve, the variance report is visible to reviewers regardless of IsBlind — blindness gates the operator during scanning, not the audit.

Prefer a cycle count for routine zone checks

A cycle count is the lightweight sibling: no settings, no validation gate, no approval, and one call does the whole count — the resolve is the scan upload:

curl -sk -X POST "http://<host>/api/cyclecounts/createcyclecount" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"PlaceId":"<placeId>","StocktakeScope":"Cycle"}'

curl -sk -X POST "http://<host>/api/cyclecounts/resolvecyclecount" \
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \
-d '{"CycleCountId":"<cycleCountId>","SnapshotType":"Add",
"StartTime":"2026-07-07T09:00:00Z","EndTime":"2026-07-07T09:05:00Z",
"Tags":[{"TagNumber":"3034257BF7194E4000001A85","TagType":"PassiveRfid",
"ReadCount":3,"Rssi":-58.0,"LastReadTime":"2026-07-07T09:04:41Z"}]}'

The result (getcyclecount/{id}) labels items Present / Moved / Created (unknown tags auto-created for in-scope SKUs). The deliberate contrasts with an inventory: no missing sweep — a cycle count never marks anything Missing — and no LastStocktakeOn stamp on items; both remain the inventory's job. There is also no open-count uniqueness check, so concurrent cycle counts per place are fine. One sharp edge: resolvecyclecount returns 200 even when resolution partially fails — verify via the count's results, not the status code (see the 200-despite-failure family).

Consolidated multi-zone stocktakes

To stocktake a whole site zone by zone: run and complete a normal inventory on each child place, then create one on the parent place with "InventoryType": "Consolidated Inventory" and resolve it. The consolidated resolve does not count anything itself — it rolls up each descendant place's latest Complete inventory (newest count per item wins), then walks the same validation and approval gates as any other stocktake. It never moves items or marks Missing. Creation requires no other open inventory on the parent place.

Retail stock queries after the count

Two read models answer "what's in stock?" afterwards:

  • GET retail/v1/stock/getcurrentstocklive per-SKU counts from the materialised stock table (maintained in the same transaction as inventory completion). Supports placeId or placeName, sub-location roll-ups (includeSubLocations / countSubLocations), barcode/skuNo filters, and a fields list to enrich records with SKU dimensions.
  • GET retail/v1/stock/getlateststocktake — counts as at the latest completed stocktake for the place, where Available = Total − Missing − Reserved. A place with no completed stocktake returns 200 with a null body.
curl -sk "http://<host>/retail/v1/stock/getcurrentstock?placeName=Store%201&fields=Colour,Size" \
-H "Authorization: Token $TOKEN"
Retail vs Asset

The retail/v1/stock endpoints are the retail-facing read model, and variance valuation uses UnitCostAtCount — a snapshot of the SKU's supplier cost (SkuMaster.UnitCost) at count time. For retail tenants all pricing lives on the SKU; the per-item UnitCost field is asset-management-only (individual asset cost/depreciation) and plays no part in stocktake variance or retail stock reporting. Asset-tracking tenants typically care more about the per-item Present/Missing/Moved outcomes than SKU-level variance percentages.

Error handling

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

SituationResponse
Duplicate open inventory on the place (per subtype rules)400 InvalidOperationdescriptive text in Field, not Message
SkuGroupId and SkuIds supplied together400 InvalidOperation
InventoryType: "SKU Group" without a SkuGroupId400 MissingRequiredField, field "SkuGroupId"
Unparseable StocktakeScope / InventoryType string400 validation error
Scan/resolve gate refused — lock busy, unknown id, or already Complete400 InvalidOperation
resolveinventory lists off by more than ±5%, or duplicate item ids in a list400 FailedToResolveInventory — re-fetch the server's lists and retry
ReasonId whose Reason is not Scope: "Item"400 InvalidReference
manageinventory by a non-place-manager (agents always)400 InvalidOperation ("PlaceManager")
manageinventory on a count not PendingApproval/ValidationFailed400 InvalidOperation ("InventoryState")
deleteinventory on a Complete count400 InvalidOperation (Rejected counts remain deletable)
Background scheduling failure on the async path400 OperationFailed
Stock endpoints without placeId or placeName400 InvalidValue
setplaceinventorysettings without PlaceId500 after the write already saved to the org row — always supply PlaceId
No token / wrong scheme (Bearer)401
Token lacks the Inventory module permission for the verb403