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").
| State | Set by | Notes |
|---|---|---|
Created | createinventory | Raises 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. |
Processing | Set around every mutating operation | Transient UI overlay only — see below. |
InProgress | After each successful snapshot add | "Has at least one snapshot, not yet resolved." |
Complete | Resolve with validation passing and ApprovalType = Auto, or manager approve | The only path that mutates item states and places. Terminal for data; stamps CompletedOn/CompletedById. |
ValidationFailed | Resolve where a gated variance check failed (EnforceInventoryValidation = true) | No item changes applied; parked for manager action. The Comments column gets a generated summary. |
PendingApproval | Resolve passed validation but ApprovalType = "Manual" | No item changes applied yet. |
Rejected | Manager reject via manageinventory | No 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.
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.
ValidationFailedandPendingApprovaldo not block theresolveinventory/resolveinventorywithoutinputendpoints — onlyCompletedoes. 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:
| Source | Baseline |
|---|---|
ExternalCount | PlaceSkuRequiredItem.CurrentCount rows, with a per-SKU AllowedVariance override |
LastInventoryCount | The previous completed inventory's per-SKU counts |
CurrentStock | Current 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(sourceInventory). - 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 stateMoved.
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.
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
resolvecyclecountrequest 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
CycleCountItemswith the literal state string"Created"); scanned items already at the place →Present(sourceCycleCount); scanned items located elsewhere → physical move +Moved. - No missing sweep and no
LastStocktakeOnstamp — both are deliberately Inventory's job. A cycle count never marks anythingMissing. - Out-of-scope tags in a SkuGroup/CustomSku count are captured on the snapshot but excluded from the Present/Moved/Created results.
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 | |
|---|---|
| Counted | Present, Moved, Missing, Cleared, Received, Returned, Restored |
| Excluded → Notifications | Allocated, 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.
Related
- Asynchronous operations — the
NotifyOnceCompletebackground pattern used by snapshot adds and resolves. - Item lifecycle — the item state machine that completion cascades into.
- Errors & response conventions — the error
array shape, the
Field-not-Messagequirk on create, and the 200-despite-failure family.