Skip to main content

Stocktakes & counting

Loca.fi has two counting workflows. An inventory (stocktake) is a full, audited count of a place: it walks a state machine, runs a validation engine, can require manager approval, and — on completion — corrects item states and locations, including marking unscanned items Missing. A cycle count is the lightweight sibling: one create, one resolve, no approval gate, and no missing sweep. Both are fed by snapshots — immutable scan-capture records.

The inventory state machine

Every inventory carries an InventoryState string ("Created", "InProgress", "Processing", "Complete", "ValidationFailed", "PendingApproval", "Rejected").

StateSet byNotes
CreatedcreateinventoryRaises InventoryCreated + a Created transaction row. Open-inventory uniqueness is enforced at create: per place, at most one open standard inventory, one open SkuGroup inventory per SkuGroupId, unlimited CustomSku inventories; a Consolidated inventory requires no other open inventory. A violation returns 400 InvalidOperation — with the descriptive text in Field, not Message.
ProcessingSet around every mutating operationTransient UI overlay only — see below.
InProgressAfter each successful snapshot add"Has at least one snapshot, not yet resolved."
CompleteResolve with validation passing and ApprovalType = Auto, or manager approveThe only path that mutates item states and places. Terminal for data; stamps CompletedOn/CompletedById.
ValidationFailedResolve where a gated variance check failed (EnforceInventoryValidation = true)No item changes applied; parked for manager action. The Comments column gets a generated summary.
PendingApprovalResolve passed validation but ApprovalType = "Manual"No item changes applied yet.
RejectedManager reject via manageinventoryNo item changes are ever applied. Terminal — and it also stamps CompletedOn/CompletedById, matching Complete. Rejected inventories remain deletable.

Deleting (deleteinventory) is allowed from any state except Complete (400 InvalidOperation "Complete"); an unknown id is 400 NotFound. Delete is a soft delete and raises InventoryDeleted.

Wire format: no spaces

The state enum internally carries spaced [EnumMember] values ("In Progress"), but the InventoryState you receive on the summary/detail DTOs is a plain ToString()unspaced: "Created", "InProgress", "ValidationFailed", "PendingApproval". Match on the unspaced forms.

Processing is a transient UI overlay — not a real state

Every mutating inventory operation sets Processing on entry and reverts it on exit. It is not the concurrency mechanism: the real mutex is a per-inventory SQL application lock, taken for the duration of the operation. The flag self-heals after a crash (any new lock holder overwrites it) and is cleared in every finally before the lock is released. If the lock is busy — or the inventory is not found or already Complete — the request is refused with 400 InvalidOperation.

The asynchronous request path (below) also fabricates InventoryState: "Processing" in the immediate response DTO — that value is synthetic, not read from the database.

The manager approval gate

manageinventory is the manager's approve/reject action and is the only operation that requires the inventory to be in PendingApproval or ValidationFailed. Approve moves the inventory to Complete (applying all item changes); reject moves it to Rejected (applying none).

Two useful subtleties:

  • Parked inventories can be re-counted. ValidationFailed and PendingApproval do not block the resolveinventory / resolveinventorywithoutinput endpoints — only Complete does. You can add more scans and re-resolve a parked inventory instead of approving or rejecting it.
  • A managed resolve does not rebuild validation records — the manager acts on the records produced by the last resolve.

The validation engine

Validation records are rebuilt (deleted and recreated) on every resolve. The expected baseline comes from the configured InventoryValidationSource:

SourceBaseline
ExternalCountPlaceSkuRequiredItem.CurrentCount rows, with a per-SKU AllowedVariance override
LastInventoryCountThe previous completed inventory's per-SKU counts
CurrentStockCurrent in-stock items

Per SKU: Variance = round((scanned / expected − 1) × 100, 2); expected 0 with scanned > 0 gives 100; both 0 gives 0. IsValid is only populated when the check is gated (EnforceInventoryValidation = true); ungated records carry IsValid = null and IsValidationGated = false.

With ValidationType = "TotalValidation" one extra SkuId = null total row is added which, when gated, solely decides pass/fail; otherwise all gated per-SKU rows must pass. Each record snapshots SkuMaster.UnitCost as UnitCostAtCount. Settings resolve from the place row only when OverrideGlobalSettings = true, otherwise the organisation row (see the inventory-settings endpoints). Inspect the records via GetInventoryValidationRecords.

What Complete does to items

Completion is the only point where a stocktake touches items (see Item lifecycle for the full item state machine):

  • Expected items found in the snapshots → state Present (source Inventory).
  • Expected but unscanned → state Missing — the "missing sweep".
  • Unexpected items (scanned here but expected elsewhere) → a physical place update to the counted place, with movement history and a per-item ItemPlaceUpdatedNotificationEvent, then state Moved.

Every touched item gets an InventoryItem result row. Completed standard inventories stamp LastStocktakeOn on every item — cycle counts deliberately do not. The materialised SkuPlaceStock table is maintained by a database trigger in the same transaction. Scope-restricted subtypes (SkuGroup / CustomSku) restrict the expected baseline via the subtype repository.

Consolidated inventories

A consolidated inventory rolls up child counts instead of scanning: on resolve it loads, for each descendant place, the latest Complete inventory matching the parent's own constraint, records ConsolidatedInventorySource rows, and merges their InventoryItems keeping the newest count per item. It does not move items or set Missing — it copies source results, then goes through the same validation and approval gates as any other inventory.

Audit trail and events

Every step writes an InventoryTransactionType row (Created, SnapshotAdded, ValidatonFailed, PendingApproval, Approved, Rejected, Completed) — readable via GetInventoryTransactions. On a managed approve, the Approved row is backdated one second before the Completed row so they sort correctly.

Persisted typo

The transaction-type value really is stored as ValidatonFailed (missing "i") — match that literal string, not ValidationFailed.

System events: InventoryCreated, InventorySnapshotAdded, InventoryCompleted (resolve → Complete), InventoryStateChanged (→ ValidationFailed/PendingApproval, and manage → Rejected), InventoryDeleted, and InventoryOperationCallback on the async path.

Running a stocktake asynchronously

Snapshot adds, resolves, and manage all accept NotifyOnceComplete with a NotificationRecipientId. When used, the call returns 200 immediately with a BackgroundTaskId and a placeholder body (the fabricated InventoryState: "Processing" DTO noted above) — the real work happens in a background worker. The full pattern — including the recipient-id rule, the auto-created subscriptions, and how to poll — is on Asynchronous operations.

Cycle counts — the one-shot count

Cycle counts share the snapshot pipeline but not the inventory state machine. A cycle count has a single boolean (IsComplete), one transition, and no approval or validation stage:

  • The resolve call is the scan upload. The resolvecyclecount request body is a snapshot (ResolveCycleCountDto : AddSnapshotDto) — there is no separate snapshot endpoint, and no update or delete endpoints at all.
  • No open-count uniqueness check at create — unlike inventories, you can run unlimited concurrent cycle counts per place.
  • Resolve cascade: unknown tags are auto-created as items at the count place for in-scope SKUs (recorded as CycleCountItems with the literal state string "Created"); scanned items already at the place → Present (source CycleCount); scanned items located elsewhere → physical move + Moved.
  • No missing sweep and no LastStocktakeOn stamp — both are deliberately Inventory's job. A cycle count never marks anything Missing.
  • Out-of-scope tags in a SkuGroup/CustomSku count are captured on the snapshot but excluded from the Present/Moved/Created results.
200 despite failure — with a partial commit

The item-resolution block of resolvecyclecount sits inside a blanket exception catch: any mid-resolve failure (including the ArgumentException from resolving the same count twice) is silently swallowed — the action still raises CycleCountResolved and returns 200 with whatever partially committed. Do not treat a 200 from resolvecyclecount as proof the results are complete; verify via the count's results. This is the harshest member of the 200-despite-failure family.

Snapshots — the capture record

A snapshot is an immutable, write-once record of a scan: the place, the client-reported StartTime/EndTime (the scan window — clamped to the SQL date range, not lifecycle stamps), a SnapshotType string (Add / Remove), and the captured tags. Snapshots have no state machine and are created exclusively server-side by the order, inventory, and cycle-count flows — the only direct API surface is the read-only snapshot endpoints (Get / GetSnapshots), which are authentication- only (no module permission — the only failure mode is 401).

Tag resolution (shared by all three flows): tags are deduped by TagNumber keeping the latest read time (null/empty dropped); known tags are included unless the item's current state disallows it — excluded items surface as "Notifications" (ItemNotificationDto) rather than counted results; unknown tags are captured raw, and auto-created as items by the inventory and cycle-count flows.

Snapshot eligibility by item state — for inventory/cycle-count Add snapshots:

Item states
CountedPresent, Moved, Missing, Cleared, Received, Returned, Restored
Excluded → NotificationsAllocated, Reserved, Consumed, Dispatched, OnLoan

The excluded states are order-owned: those items only participate in order-phase snapshots. This is why items in-flight on an order appear in a stocktake's Notifications list instead of being counted — see Item lifecycle and Order lifecycle.