SOP: Formal Inventory Adjustments
Document ID: WMS-INV-007
Version: 1.0
Effective date: 04/30/2026
Owner: Warehouse Operations Manager
Next review: [six months from effective date]
Applies to: Anyone needing to understand the inventory_adjustments audit table — managers reviewing variance, accountants reconciling shrinkage, anyone investigating where a quantity change came from
1. Purpose
This procedure documents the inventory_adjustments table — the audit-grade record of every quantity change that originates from a cycle count or a customer return restock. Each adjustment row carries: previous quantity, adjusted quantity, change quantity, reason, source linkage, the user who created it, and (in the schema) the user who approved it.
WMS-INV-001 v2 and WMS-REC-003 both cross-reference this SOP as "the formal adjustment workflow with PENDING → APPROVED/REJECTED status." That cross-reference describes the schema's intent, not the codebase's behavior — see the gap callout below. INV-007 v1 is honest about that, and is the canonical reference for what the table really contains today.
⚠ MAJOR SCHEMA-VS-IMPLEMENTATION GAP. The schema models a three-state approval workflow:
The schema models a three-state approval workflow:
AdjustmentStatus { PENDING, APPROVED, REJECTED }.No code path in the entire codebase ever writes
PENDINGorREJECTED. Every call site that creates anInventoryAdjustmentwritesstatus: "APPROVED"immediately, withcreatedById === approvedById(or justcreatedByIdfor the return-restock path). The PENDING/APPROVED/REJECTED workflow exists in the data model but does not exist in operations. There is no:
- Standalone API endpoint to create a pending adjustment for review
- GET endpoint to list pending adjustments
- UI page to view, approve, or reject them
- Any code that flips
statusfromPENDINGto anything elseThis SOP documents the table as it actually behaves today — an immutable audit record automatically created at cycle-count approval and return restock — and flags the gap as engineering work in §8. Do not rely on a separate "approval queue" for adjustments. None exists.
2. Scope
In scope:
- The
inventory_adjustmentstable structure and what gets written to it - The two code paths that create adjustment rows today: cycle-count approval (WMS-INV-002 / WMS-AUD-001) and customer-return restock (WMS-RET-002 / WMS-RET-003)
- How to read an adjustment row when investigating a variance
- The
adjustmentNumberformat (ADJ-YYYY-NNNNN) and how to query by it
Out of scope:
- Per-unit moves, lightweight adjusts, and damage via the inventory unit page — see WMS-INV-001
- Cycle-count session lifecycle (start, count, submit, approve) — see WMS-INV-002
- Return restock procedure — see WMS-RET-002 / WMS-RET-003
- Cycle-count audit review (the manager-facing variance review that triggers adjustment creation) — see WMS-AUD-001
- Direct manual adjustment creation — does not exist as a feature today; see §8
3. Roles & permissions
| Role | View adjustments (via DB) | Cause adjustments to be created |
|---|---|---|
| READONLY | ✓ (read-only DB query, no UI today) | — |
| STAFF | ✓ | ✓ (when restocking a return per WMS-RET-002) |
| MANAGER | ✓ | ✓ (cycle-count approval per WMS-AUD-001) |
| ADMIN | ✓ | ✓ |
| SUPER_ADMIN | ✓ | ✓ |
API enforcement: there are no API endpoints specific to adjustments. The endpoints that cause adjustments to be created (cycle-count approve at POST /cycle-count/sessions/:sessionId/approve, return restock as part of return inspection) carry their own role checks documented in their respective SOPs. There are no permissions to enforce on adjustments themselves because there is no direct adjustment surface.
4. Procedures
4.1 How adjustments are created today (read-only reference)
There are exactly two write paths that produce an InventoryAdjustment row.
Path A — Cycle-count approval (WMS-INV-002 / WMS-AUD-001)
When a manager approves a submitted cycle-count session via POST /cycle-count/sessions/:sessionId/approve, the CycleCountService.approve() transaction does the following per line where variance ≠ 0:
- Generates the next adjustment number:
ADJ-{YYYY}-{NNNNN}(5-digit zero-padded counter, year-scoped). UsesgenerateAdjustmentNumber()which counts existing rows for the year. - Creates an
InventoryAdjustmentrow with:reason: "CYCLE_COUNT"sourceType: "CYCLE_COUNT"sourceId: <cycleCountSessionId>— points at theCycleCountSessionrow (this is the foreign key that makes the variance traceable)productVariantId,locationId,inventoryUnitId(if the line referenced an existing unit)previousQty: line.systemQty,adjustedQty: line.countedQty,changeQty: line.variancelotNumber: line.lotNumberstatus: "APPROVED"(written immediately)createdById: <approver's userId>approvedById: <approver's userId>(same person)approvedAt: <now>
- Applies the actual inventory change in the same transaction:
- If the cycle-count line had an
inventoryUnitId, updates thatInventoryUnit.quantitytoline.countedQty. - If the line is
isUnexpectedandcountedQty > 0, creates a newInventoryUnitwith statusAVAILABLE.
- If the cycle-count line had an
Path B — Customer return restock (WMS-RET-002)
When a staff member marks a return item as restocked, ReturnService.restockItem() runs a transaction that:
- Creates or updates the destination
InventoryUnit. - Creates an
InventoryAdjustmentrow with:reason: "OTHER"(notRECEIVING_VARIANCE— see §5.1 callout)sourceType: "RETURN"sourceId: null(the schema'ssourceIdforeign key points atCycleCountSession, not at returns — so this field cannot link to the return)previousQty: existing.quantity ?? 0,adjustedQty: previousQty + quantityReceived,changeQty: quantityReceivednotes: "Restocked from return {returnNumber}"— the only forward-traceable link from the adjustment back to the return is in the notes field, parsed by string matchadjustmentNumber: ADJ-RET-{returnNumber}-{returnItemId.slice(-6)}— a different format from Path Astatus: "APPROVED"createdById: <user>,approvedById: undefined
⚠ The two paths produce inconsistent data. Path A and Path B don't share the adjustment-number format, the
sourceIdlinkage, or thereasonenum value. A query like "all adjustments from returns this month" requires anotes ILIKE 'Restocked from return %'scan. A query like "all adjustments from cycle counts" usessourceType = 'CYCLE_COUNT' AND sourceId IS NOT NULL. Two different idioms, two different lookup paths.
4.2 Reading an adjustment row when investigating a variance
Use when: A manager noticed an unexplained quantity change on a SKU or location and wants to find what triggered it.
Steps:
- Identify the SKU (
productVariantId) and the time window of the change. - Query
inventory_adjustments:SELECTadjustmentNumber, reason, sourceType, sourceId,previousQty, adjustedQty, changeQty,locationId, lotNumber, inventoryUnitId,status, createdById, approvedById, approvedAt,notes, createdAtFROM inventory_adjustmentsWHERE productVariantId = '<variant-id>'AND createdAt BETWEEN '<from>' AND '<to>'ORDER BY createdAt DESC; - For each row:
sourceType = 'CYCLE_COUNT'andsourceIdis set → join toCycleCountSessiononsourceIdto see the count session, the counter, the location, and the variance. Cross-reference WMS-AUD-001.sourceType = 'RETURN'(andsourceIdis null per §4.1's gap) → parse thenotesfield. The format is exactlyRestocked from return {returnNumber}. Look upReturnbyreturnNumber. Cross-reference WMS-RET-002.adjustmentNumberstarts withADJ-RET-is another tell that this is a return-path adjustment, regardless ofsourceType.
The adjustment is the audit row, not the truth. The truth is in the related InventoryUnit (current state) and inventory_events (per-unit history per WMS-INV-001 §4.4). For a complete picture of what happened to a unit, query both tables — inventory_adjustments tells you "the system recorded that 2 units appeared at location L from a return on date D"; inventory_events tells you "after that, the unit was moved to bin B-04, then partially shipped, then 1 was marked damaged."
4.3 What you cannot do today
- Cannot manually create an adjustment. No UI, no API. If you need to record a quantity change that didn't come from a cycle count or a return, your options are:
- Use the lightweight
/inventory/:id/adjustper WMS-INV-001 §4.2 — produces aninventory:unit_adjustedevent, but does not create anInventoryAdjustmentrow. This is the most practical path. - Open a cycle count for the affected location per WMS-INV-002, count it, submit, approve — produces
InventoryAdjustmentrows the right way. - Direct database INSERT — not approved as a procedure; reserved for IT during incident recovery.
- Use the lightweight
- Cannot approve a pending adjustment. No adjustments are ever in
PENDINGstatus (see §1 gap callout). - Cannot reject an adjustment. Same reason. Rolling back a wrongful adjustment requires a second adjustment that inverts the first — and today, the only way to write that second row is to run another cycle count or restock another return. Direct rollback is not a feature.
- Cannot view adjustments in a UI. No page exists. Database query only.
5. Reference
5.1 Reason codes (enum vs. usable)
The schema defines eight reason values:
| Reason | Used by code today? |
|---|---|
CYCLE_COUNT | ✓ — Path A |
DAMAGED | — Reserved; no code path writes this |
LOST | — Reserved; no code path writes this |
FOUND | — Reserved; no code path writes this |
CORRECTION | — Reserved; no code path writes this |
RECEIVING_VARIANCE | — Reserved; note the receiving-approval flow per WMS-REC-003 §4.2 does not create InventoryAdjustment rows. Approved variances bake into InventoryUnit directly. |
SHIPPING_VARIANCE | — Reserved; no code path writes this |
OTHER | ✓ — Path B (customer-return restock — somewhat surprising; RECEIVING_VARIANCE would be a more accurate reason for the return restock case, but the code uses OTHER) |
Five of the eight enum values are dead. They exist in the database but no code path writes them. When a future feature needs to record (say) a damage write-off as a formal adjustment, the table is ready — the wiring is missing.
5.2 Adjustment number formats
| Path | Format | Example |
|---|---|---|
| Cycle count (A) | ADJ-{YYYY}-{NNNNN} (5-digit zero-padded year counter) | ADJ-2026-00471 |
| Return restock (B) | ADJ-RET-{returnNumber}-{returnItemId.slice(-6)} | ADJ-RET-RMA-00104-ax9k2j |
A filter on adjustmentNumber LIKE 'ADJ-RET-%' cleanly isolates the return-path rows. A filter on adjustmentNumber LIKE 'ADJ-2026-%' isolates the cycle-count-path rows for a given year.
5.3 Status field — current values
In the database today, inventory_adjustments.status only ever contains APPROVED. The other two values (PENDING, REJECTED) are unreachable through the application. Querying for WHERE status != 'APPROVED' will return zero rows.
5.4 Related SOPs
- WMS-INV-001 §4.2 — Lightweight
/inventory/:id/adjust(the path that actually exists for ad-hoc per-unit corrections; producesinventory_events, notinventory_adjustments) - WMS-INV-002 — Cycle count campaigns (the upstream source for Path A)
- WMS-AUD-001 — Cycle-count approval review (the manager-facing trigger for Path A)
- WMS-RET-002 — Customer return inspection & disposition (where the restock decision is made)
- WMS-RET-003 — Customer return putaway (the actual restock into a bin)
- WMS-AUD-002 — Shrinkage investigation (combines this table with
inventory_eventsandtask_events)
6. Audit & compliance
The inventory_adjustments table is insert-only in practice — neither path updates an existing row, and there is no DELETE codepath outside of prisma.inventoryAdjustment.deleteMany() calls in the product-deletion routes (apps/api/src/routes/product.routes.ts:388, 464), which cascade-delete adjustments when a ProductVariant is deleted. Deleting a variant deletes its adjustment history — that's a real audit gap noted in §8.
Where the table fits into governance reporting today:
- Monthly shrinkage report: SUM(
changeQty) WHEREreason = 'CYCLE_COUNT' AND changeQty < 0GROUP BY month. This gives net loss from cycle-count variance. - Restock volume from returns: COUNT(*) WHERE
adjustmentNumber LIKE 'ADJ-RET-%' AND createdAt BETWEEN .... Total return-restock events. - Per-counter variance contribution: SUM(ABS(
changeQty)) GROUP BYcreatedById. High-variance counters warrant retraining per WMS-INV-002 governance.
What the table does not capture but the governance docs imply it should:
- Damage write-offs (currently in
inventory_eventsonly, notinventory_adjustments) - Receiving-variance approvals (currently bake straight into
InventoryUnit, no formal adjustment row) - Shipping variance / lost in transit (no codepath writes this)
- Manual corrections by managers (no codepath; the lightweight
/inventory/:id/adjustis the workaround, but writes events not adjustments)
The shrinkage and variance reports the warehouse manager generates today are therefore partial views of warehouse losses — they see cycle-count variance and not much else. Per WMS-AUD-002, the full picture requires joining inventory_adjustments with inventory_events and filtering by event type and reason. That's a real query, and not one that's surfaced through any UI today.
Manager weekly review:
- Pull all
InventoryAdjustmentrows from the past 7 days. Confirm each is traceable to its source (cycle-count session or return). - Pull
inventory_eventsof typeinventory:unit_adjustedfrom the past 7 days. These are NOT ininventory_adjustments— they're the lightweight path. If any of them are large (say|delta| > 50), they probably should have gone through a cycle count.
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
I see a quantity change in the unit Activity feed (inventory_events) but no row in inventory_adjustments | The change came from the lightweight /inventory/:id/adjust endpoint, not a cycle count or return | This is correct behavior. Per §1 gap, only cycle-count and return-restock paths create formal adjustment rows. Use WMS-INV-001 §4.4 (Activity feed) for full per-unit history. |
I see a row in inventory_adjustments with sourceType = 'RETURN' but sourceId IS NULL | The schema FK targets CycleCountSession only; returns can't use sourceId | Per §4.1 Path B, parse the notes field. Format: Restocked from return {returnNumber}. |
| Wanted to "approve" a pending adjustment in a UI but no such page exists | No PENDING adjustments are ever written; the UI was never built | This is by design today. See §1 gap. |
Querying WHERE status = 'PENDING' returns zero rows | Same — no path writes that status | Expected. Use WHERE status = 'APPROVED' (the only value used today). |
| A wrongful cycle-count adjustment was approved and is now in the table; how do I undo it | There is no UI rollback. Schema-wise the row is immutable in practice | Run a second cycle count over the same location, count correctly, approve. The new row appears alongside the old one — both are kept; the inventory math nets out. Document the second count's notes field with reference to the first adjustment number. |
Two adjustments for the same SKU and location have wildly different previousQty baselines | Each row records the state at the time of that adjustment, not absolute history | Sort by createdAt. Each row's adjustedQty should equal the next row's previousQty for the same inventoryUnitId. If they don't, an unrelated process changed the unit between the two adjustments — query inventory_events for the gap. |
The adjustment-number sequence has gaps (e.g., ADJ-2026-00470, then ADJ-2026-00472 with no 00471) | A transaction was attempted but rolled back after the count was taken; or a variant was deleted (cascade-deletes adjustments per §6) | Query inventory_events and cycle_count_sessions around the missing number's timestamp. Most likely a rolled-back transaction; less likely a deleted variant taking its history with it. The latter is more concerning — flag for IT. |
8. Escalation
- Build the formal-adjustment standalone surface (highest priority among inventory-system gaps once cycle counts are stable): a UI to create a manual
InventoryAdjustmentwithstatus: PENDING, an approval queue, the approve/reject endpoints. Once built, INV-001 §4.2 should redirect all|delta| > 10adjustments through this path. The schema is ready; the wiring is the work. - Reconcile the schema enum with code: either delete the dead reason values (
DAMAGED,LOST,FOUND,CORRECTION,RECEIVING_VARIANCE,SHIPPING_VARIANCE) via a migration, or wire them into the appropriate code paths. Currently they are confusing audit signal — anyone reading the schema reasonably assumes they get used somewhere. - Fix the
sourceIdFK: either (a) make it polymorphic (sourceType+sourceIdwith no FK), or (b) add a separatereturnIdcolumn. Today it points specifically atCycleCountSession, which is why Path B can't use it. - Decide on cascade-delete of adjustments when a variant is deleted: today, deleting a
ProductVariantcascades to delete all itsInventoryAdjustmentrows (perapps/api/src/routes/product.routes.ts). For audit-grade compliance, adjustments should probably be retained — soft-delete the variant, keep the history. - Suspected adjustment-related fraud or intentional miscounting: Warehouse Operations Manager directly. Don't discuss in Slack. Pull all rows where
createdById = <suspect user>, sumABS(changeQty), compare against peers. Cross-reference withcycle_count_auditsandtask_events.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | [DATE] | [NAME] | Initial release. Documents the inventory_adjustments table as it actually behaves today: an audit log auto-created by exactly two code paths (cycle-count approval and customer-return restock), both writing status: "APPROVED" immediately. Documents the schema-vs-implementation gap — AdjustmentStatus.PENDING and REJECTED are dead values, never written, and the implied PENDING → APPROVED/REJECTED workflow does not exist as a feature. Walks back the cross-references in WMS-INV-001 v2 and WMS-REC-003 that described this SOP as covering "the formal approval workflow" — INV-001 should be revised to say "the audit-grade table that auto-records cycle-count and return-restock adjustments" once the gap is closed. Documents the two adjustment-number formats (ADJ-{YYYY}-{NNNNN} for Path A, ADJ-RET-{returnNumber}-{shortId} for Path B), the inconsistent sourceType/sourceId/reason usage between paths, the dead enum values (DAMAGED/LOST/FOUND/CORRECTION/RECEIVING_VARIANCE/SHIPPING_VARIANCE), and the cascade-delete behavior when a variant is deleted. Cross-references WMS-INV-001 §4.2 (lightweight /adjust for ad-hoc), WMS-INV-002 (cycle count source), WMS-AUD-001 (cycle-count approval), WMS-RET-002/003 (return restock source), WMS-AUD-002 (full shrinkage view requires joining adjustments with events). Code references: packages/db/prisma/schema/inventory.prisma (model and enums), packages/domain/src/services/cycle-count.service.ts:864 (Path A), packages/domain/src/services/return.service.ts:576 (Path B), apps/worker/src/processors/cycle-count.processor.ts:63 (worker variant of Path A), apps/api/src/routes/product.routes.ts:388,464 (cascade-delete on variant delete). |