---
description: Stock changes only through movements. Never modify levels directly.
globs: ["app/Domain/Inventory/**", "database/migrations/**inventory**", "database/migrations/**product**", "database/migrations/**warehouse**"]
---

# Inventory — Movements Are Law

## The Iron Rule

NEVER modify `inventory_levels.quantity_on_hand` directly. ALL changes go through `InventoryService::createMovement()` which:
1. Locks the level row (`lockForUpdate()`)
2. Records `quantity_before`
3. Adjusts level
4. Records `quantity_after`
5. Creates the movement record

## Movement Type → Direction (hardcoded, no exceptions)

| Type | Direction |
|------|-----------|
| `purchase_received` | `in` |
| `sale` | `out` |
| `transfer_out` | `out` |
| `transfer_in` | `in` |
| `return_to_stock` | `in` |
| `return_to_supplier` | `out` |
| `adjustment_up` | `in` |
| `adjustment_down` | `out` |
| `damage` | `out` |
| `loss` | `out` |
| `consumption` | `out` |
| `kit_assembly` | `out` |
| `kit_disassembly` | `in` |
| `initial_stock` | `in` |
| `count_adjustment_up` | `in` |
| `count_adjustment_down` | `out` |

If type doesn't match direction → throw exception.

## Stock Deduction Rules

- `track_inventory = false` products: NO movement created on sale
- `track_inventory = true` products: movement MUST be created on sale
- Kit sale: one movement PER component (type='kit_assembly')
- Stock can go to 0 but not negative (throw InsufficientStockException)
- Override: admin can force-sell with warning if physical stock exists but system shows 0

## Integrity

- `quantity_available = quantity_on_hand - quantity_reserved`
- Weekly reconciliation job: sum(in movements) - sum(out movements) should equal current level
- If mismatch: LOG WARNING, notify admin — do NOT auto-fix
