Inventory API
The Inventory API drives inventory sessions (stocktake): on start a snapshot of the expected assets is taken, then assets are recorded via QR/tag scan. From expected (snapshot) vs actual (scans) the missing and unexpected assets are derived. Scope is either a location (all assets of a location) or a user (their assigned assets).
Lifecycle
Start (snapshot of expected assets) │ POST /api/inventory-sessions ▼ ACTIVE ──scan──▶ POST /:id/scan (per asset, unique per session) │ POST /:id/ready-for-review (scanner hands off for review) ▼ READY FOR REVIEW │ POST /:id/complete (agent completes → endedAt set) ▼ COMPLETED → notification INVENTORY_SESSION_COMPLETED to creator
rescanBehavior: OVERWRITE (default) — re-scanning the same asset overwrites the previous one (one entry per session+asset). ACCUMULATE — multiple scans add up quantities (for consumables).
Authentication & Permissions
Self-service is built in: end users may start a session for THEMSELVES, scan and hand it off for review — but not complete it or see others' sessions. Completion, scan corrections and reports are agent/admin rights. Permissions & RBAC.
| Permission | Description |
|---|---|
inventory.startSession | Start session for others/location |
inventory.startOwnSession | Start own session (self-service, default on) |
inventory.scan | Scan assets + scan note, ready-for-review |
inventory.viewSessions / viewOwnSessions | View all resp. only own sessions |
inventory.completeSessions | Complete session |
inventory.editScans | Edit/delete scans |
inventory.viewReports / exportReports | View report / export |
inventory.viewDeleted / restore | See trash / restore a session (both off by default) |
Restoring requires two permissions: inventory.restore for the action itself and inventory.viewDeleted to see the trash — whoever may not see the trash cannot pull anything out of it. The same visibility as in the list also applies: you can only restore what you are allowed to see. Both permissions are off by default; only the administrator system role receives them automatically.
Endpoints Overview
Mount: /api/inventory-sessions
| Method | Endpoint | Description | Permission |
|---|---|---|---|
POST | / | Start session (snapshot of expected assets) → 201, session object | startSession / startOwnSession |
GET | / | List sessions — { data, pagination, counts }; ?q, ?page/per, ?sort, ?f.<field>, ?status=active|completed, ?deleted=1 | viewSessions / viewOwnSessions |
GET | /:id | Session detail | view… |
PATCH | /:id | Update session metadata | startSession |
POST | /:id/ready-for-review | Hand off for review | scan |
POST | /:id/complete | Complete session (endedAt) | completeSessions |
DELETE | /:id | Move session to trash (soft delete, completed ones too; scans are kept) → 204 | startSession / startOwnSession (own session only: started by or targeting the caller) |
POST | /:id/restore | Restore session from trash | restore + viewDeleted |
POST | /:id/scan | Scan asset — assetId OR code (scanned QR/barcode value), plus quantity, note | scan |
PATCH | /:id/scans/:scanId | Edit scan (quantity) | editScans |
PATCH | /:id/scans/:scanId/note | Set scan note | scan |
DELETE | /:id/scans/:scanId | Delete scan → 204 | editScans |
GET | /:id/scans | Scans of the session — { data, pagination } | view… |
GET | /:id/scans/stats | Scan statistics | view… |
GET | /:id/unexpected | Scanned but not expected — { data, pagination } | view… |
GET | /:id/missing | Expected but not scanned — { data, pagination } | view… |
GET | /:id/report | Inventory report (expected vs actual) | viewReports |
Fields
InventorySession
| Field | Type | Description |
|---|---|---|
name | String | e.g. "Stocktake Q4 2026 – server room" |
scopeType | enum | LOCATION, USER |
locationId | String? | for scope LOCATION (asset location) |
userId | String? | for scope USER (their assets) |
expectedAssetIds | String[] | Snapshot of expected asset IDs on start |
expectedSnapshot | JSON | Full expected snapshot (assetTag, name, quantity …) |
rescanBehavior | enum | OVERWRITE (default), ACCUMULATE |
readyForReviewAt / By | DateTime? / String? | Hand-off for review |
dueAt | DateTime? | Due date of the stocktake — only inventory.startSession may set or change it (403 INVENTORY_DUE_AT_FORBIDDEN); the target user therefore cannot move their own deadline |
deletedAt | DateTime? | set while the session sits in the trash |
startedBy / startedAt / endedAt | — | Creator, start, completion (endedAt = completed) |
Due-date monitoring: A built-in cron job ("Inventory Due Monitoring", hourly, enabled by default) reports approaching and missed deadlines: three days and one day ahead, then overdue. Each milestone is reported once. The recipient is the target user resp. the session starter — on overdue additionally the starter. Without a dueAt nothing happens. CronJobs API.
InventoryScan
| Field | Type | Description |
|---|---|---|
assetId | String | Scanned asset (unique per session — see rescanBehavior) |
quantity | Int | Counted quantity (default 1) |
expectedQty | Int? | Expected quantity from snapshot |
scanCount | Int | How often this asset was scanned in the session |
note | String? | Note for the scan |
scannedBy / scannedAt | — | Who/when scanned |
Missing Assets, Live Status & Auto-Account
- Static missing snapshot: The expected set stays fixed (stocktake record) — an asset marked LOST during the session remains in the missing tab and count.
- Live status (currentStatus): On read, missing rows are annotated with the current asset status ("now LOST / returned"); undefined = asset since deleted.
- Auto-account: An unscanned component counts as auto-accounted once its container was scanned in the session (across multiple levels, via the PART_OF/INSTALLED_ON relations). The component is only marked as accounted for; no scan is created. Components without a container relation stay missing. Components out of service (LOST/RETIRED/DISPOSED) are NOT auto-accounted — they stay missing and carry a currentStatus badge.
As a result GET /:id/scans/stats separates real from auto-accounted missing: { scanned, expected, missing, autoAccounted, unexpected }. GET /:id/missing returns missing (REAL missing only) plus autoAccountedAssets; each missing row carries currentStatus and optionally autoAccountedVia.
Asset Movements from Inventory
The inventory routes move NO assets — there is no movement endpoint under /api/inventory-sessions. Actions on scanned, unexpected or missing assets run through the asset flow endpoints (the assets.* rights apply, not inventory.*). Which action is offered depends on the session target, the tracking mode and the asset status; assignments always run via checkout, install or checkin:
| Context | Action | API |
|---|---|---|
| Unexpected · USER scope · PERSON · user-less | Issue to {user} | POST /assets/:id/checkout |
| Unexpected · USER scope · PERSON · IN_USE (other user) | Change owner | checkin + checkout (+ components via coReturn/coMove) |
| Unexpected · LOCATION scope · LOCATION AVAILABLE | Install here | POST /assets/:id/install |
| Unexpected · LOCATION scope · IN_USE elsewhere | Relocate | PATCH /assets/:id { locationId, coLocateAssetIds? } |
| Missing (both scopes) | Report lost | PATCH /assets/:id { status: LOST, note } |
| PERSON IN_USE | Take back | POST /assets/:id/checkin |
Locked states: with an open handover (PENDING_ACCEPTANCE / RETURN_PENDING) only "open detail" is available. Details on the flows: Asset Lifecycle.
Example Flow
# 1. Start a session for a location
POST /api/inventory-sessions
{ "name": "Stocktake Q4 2026 – Server Room", "scopeType": "LOCATION", "locationId": "clx-loc-id", "rescanBehavior": "OVERWRITE" }
# 2. Scan asset (QR → assetId)
POST /api/inventory-sessions/:id/scan
{ "assetId": "clx-asset-id", "quantity": 1, "note": "Rack 3" }
# 3. Hand off for review
POST /api/inventory-sessions/:id/ready-for-review
# 4. Check expected vs actual
GET /api/inventory-sessions/:id/missing # expected, not scanned
GET /api/inventory-sessions/:id/unexpected # scanned, not expected
# 5. Agent completes
POST /api/inventory-sessions/:id/complete