Skip to main content

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 PENDING or REJECTED. Every call site that creates an InventoryAdjustment writes status: "APPROVED" immediately, with createdById === approvedById (or just createdById for 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 status from PENDING to anything else

This 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_adjustments table 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 adjustmentNumber format (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

RoleView 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:

  1. Generates the next adjustment number: ADJ-{YYYY}-{NNNNN} (5-digit zero-padded counter, year-scoped). Uses generateAdjustmentNumber() which counts existing rows for the year.
  2. Creates an InventoryAdjustment row with:
    • reason: "CYCLE_COUNT"
    • sourceType: "CYCLE_COUNT"
    • sourceId: <cycleCountSessionId> — points at the CycleCountSession row (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.variance
    • lotNumber: line.lotNumber
    • status: "APPROVED" (written immediately)
    • createdById: <approver's userId>
    • approvedById: <approver's userId> (same person)
    • approvedAt: <now>
  3. Applies the actual inventory change in the same transaction:
    • If the cycle-count line had an inventoryUnitId, updates that InventoryUnit.quantity to line.countedQty.
    • If the line is isUnexpected and countedQty > 0, creates a new InventoryUnit with status AVAILABLE.

Path B — Customer return restock (WMS-RET-002)

When a staff member marks a return item as restocked, ReturnService.restockItem() runs a transaction that:

  1. Creates or updates the destination InventoryUnit.
  2. Creates an InventoryAdjustment row with:
    • reason: "OTHER" (not RECEIVING_VARIANCE — see §5.1 callout)
    • sourceType: "RETURN"
    • sourceId: null (the schema's sourceId foreign key points at CycleCountSession, not at returns — so this field cannot link to the return)
    • previousQty: existing.quantity ?? 0, adjustedQty: previousQty + quantityReceived, changeQty: quantityReceived
    • notes: "Restocked from return {returnNumber}"the only forward-traceable link from the adjustment back to the return is in the notes field, parsed by string match
    • adjustmentNumber: ADJ-RET-{returnNumber}-{returnItemId.slice(-6)} — a different format from Path A
    • status: "APPROVED"
    • createdById: <user>, approvedById: undefined

⚠ The two paths produce inconsistent data. Path A and Path B don't share the adjustment-number format, the sourceId linkage, or the reason enum value. A query like "all adjustments from returns this month" requires a notes ILIKE 'Restocked from return %' scan. A query like "all adjustments from cycle counts" uses sourceType = '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:

  1. Identify the SKU (productVariantId) and the time window of the change.
  2. Query inventory_adjustments:
    SELECT
    adjustmentNumber, reason, sourceType, sourceId,
    previousQty, adjustedQty, changeQty,
    locationId, lotNumber, inventoryUnitId,
    status, createdById, approvedById, approvedAt,
    notes, createdAt
    FROM inventory_adjustments
    WHERE productVariantId = '<variant-id>'
    AND createdAt BETWEEN '<from>' AND '<to>'
    ORDER BY createdAt DESC;
  3. For each row:
    • sourceType = 'CYCLE_COUNT' and sourceId is set → join to CycleCountSession on sourceId to see the count session, the counter, the location, and the variance. Cross-reference WMS-AUD-001.
    • sourceType = 'RETURN' (and sourceId is null per §4.1's gap) → parse the notes field. The format is exactly Restocked from return {returnNumber}. Look up Return by returnNumber. Cross-reference WMS-RET-002.
    • adjustmentNumber starts with ADJ-RET- is another tell that this is a return-path adjustment, regardless of sourceType.

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:
    1. Use the lightweight /inventory/:id/adjust per WMS-INV-001 §4.2 — produces an inventory:unit_adjusted event, but does not create an InventoryAdjustment row. This is the most practical path.
    2. Open a cycle count for the affected location per WMS-INV-002, count it, submit, approve — produces InventoryAdjustment rows the right way.
    3. Direct database INSERT — not approved as a procedure; reserved for IT during incident recovery.
  • Cannot approve a pending adjustment. No adjustments are ever in PENDING status (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:

ReasonUsed 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

PathFormatExample
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.

  • WMS-INV-001 §4.2 — Lightweight /inventory/:id/adjust (the path that actually exists for ad-hoc per-unit corrections; produces inventory_events, not inventory_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_events and task_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) WHERE reason = 'CYCLE_COUNT' AND changeQty < 0 GROUP 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 BY createdById. 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_events only, not inventory_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/adjust is 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 InventoryAdjustment rows from the past 7 days. Confirm each is traceable to its source (cycle-count session or return).
  • Pull inventory_events of type inventory:unit_adjusted from the past 7 days. These are NOT in inventory_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

SymptomCauseResolution
I see a quantity change in the unit Activity feed (inventory_events) but no row in inventory_adjustmentsThe change came from the lightweight /inventory/:id/adjust endpoint, not a cycle count or returnThis 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 NULLThe schema FK targets CycleCountSession only; returns can't use sourceIdPer §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 existsNo PENDING adjustments are ever written; the UI was never builtThis is by design today. See §1 gap.
Querying WHERE status = 'PENDING' returns zero rowsSame — no path writes that statusExpected. 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 itThere is no UI rollback. Schema-wise the row is immutable in practiceRun 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 baselinesEach row records the state at the time of that adjustment, not absolute historySort 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 InventoryAdjustment with status: PENDING, an approval queue, the approve/reject endpoints. Once built, INV-001 §4.2 should redirect all |delta| > 10 adjustments 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 sourceId FK: either (a) make it polymorphic (sourceType + sourceId with no FK), or (b) add a separate returnId column. Today it points specifically at CycleCountSession, which is why Path B can't use it.
  • Decide on cascade-delete of adjustments when a variant is deleted: today, deleting a ProductVariant cascades to delete all its InventoryAdjustment rows (per apps/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>, sum ABS(changeQty), compare against peers. Cross-reference with cycle_count_audits and task_events.

9. Revision history

VersionDateAuthorChanges
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 gapAdjustmentStatus.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).