# Inventory Module — Technical & Code Documentation

## Route Map

All inventory routes live under the localized `/{locale}/admin/` prefix, protected by `auth:admin` middleware, and further guarded by individual Spatie permission middleware. The outer route group adds the named prefix `admin.`.

### Warehouses

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/warehouses` | `WarehouseController@index` | `admin.dashboard.inventory.warehouses.index` | `inventory.warehouses.view` |
| GET | `/admin/inventory/warehouses/create` | `WarehouseController@create` | `admin.dashboard.inventory.warehouses.create` | `inventory.warehouses.create` |
| POST | `/admin/inventory/warehouses` | `WarehouseController@store` | `admin.dashboard.inventory.warehouses.store` | `inventory.warehouses.create` |
| GET | `/admin/inventory/warehouses/{warehouse}/edit` | `WarehouseController@edit` | `admin.dashboard.inventory.warehouses.edit` | `inventory.warehouses.update` |
| PUT | `/admin/inventory/warehouses/{warehouse}` | `WarehouseController@update` | `admin.dashboard.inventory.warehouses.update` | `inventory.warehouses.update` |
| DELETE | `/admin/inventory/warehouses/{warehouse}` | `WarehouseController@destroy` | `admin.dashboard.inventory.warehouses.destroy` | `inventory.warehouses.delete` |

### Items

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/items` | `ItemController@index` | `admin.dashboard.inventory.items.index` | `inventory.items.view` |
| GET | `/admin/inventory/items/create` | `ItemController@create` | `admin.dashboard.inventory.items.create` | `inventory.items.create` |
| POST | `/admin/inventory/items` | `ItemController@store` | `admin.dashboard.inventory.items.store` | `inventory.items.create` |
| GET | `/admin/inventory/items/{item}/edit` | `ItemController@edit` | `admin.dashboard.inventory.items.edit` | `inventory.items.update` |
| PUT | `/admin/inventory/items/{item}` | `ItemController@update` | `admin.dashboard.inventory.items.update` | `inventory.items.update` |
| DELETE | `/admin/inventory/items/{item}` | `ItemController@destroy` | `admin.dashboard.inventory.items.destroy` | `inventory.items.delete` |

### Stock Movements

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/stock-movements` | `StockMovementController@index` | `admin.dashboard.inventory.stock_movements.index` | `inventory.stock.view` |
| GET | `/admin/inventory/stock-movements/create` | `StockMovementController@create` | `admin.dashboard.inventory.stock_movements.create` | `inventory.stock.create` |
| POST | `/admin/inventory/stock-movements` | `StockMovementController@store` | `admin.dashboard.inventory.stock_movements.store` | `inventory.stock.create` |
| GET | `/admin/inventory/stock-movements/{stockMovement}` | `StockMovementController@show` | `admin.dashboard.inventory.stock_movements.show` | `inventory.stock.view` |
| GET | `/admin/inventory/stock-movements/{stockMovement}/edit` | `StockMovementController@edit` | `admin.dashboard.inventory.stock_movements.edit` | `inventory.stock.update` |
| PUT | `/admin/inventory/stock-movements/{stockMovement}` | `StockMovementController@update` | `admin.dashboard.inventory.stock_movements.update` | `inventory.stock.update` |
| DELETE | `/admin/inventory/stock-movements/{stockMovement}` | `StockMovementController@destroy` | `admin.dashboard.inventory.stock_movements.destroy` | `inventory.stock.delete` |

### Item Categories (Setup)

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/setup/item-categories` | `ItemCategoryController@index` | `admin.dashboard.inventory.setup.item-categories.index` | `inventory.item_categories.view` |
| GET | `/admin/inventory/setup/item-categories/create` | `ItemCategoryController@create` | `admin.dashboard.inventory.setup.item-categories.create` | `inventory.item_categories.create` |
| POST | `/admin/inventory/setup/item-categories` | `ItemCategoryController@store` | `admin.dashboard.inventory.setup.item-categories.store` | `inventory.item_categories.create` |
| GET | `/admin/inventory/setup/item-categories/{itemCategory}/edit` | `ItemCategoryController@edit` | `admin.dashboard.inventory.setup.item-categories.edit` | `inventory.item_categories.update` |
| PUT | `/admin/inventory/setup/item-categories/{itemCategory}` | `ItemCategoryController@update` | `admin.dashboard.inventory.setup.item-categories.update` | `inventory.item_categories.update` |
| DELETE | `/admin/inventory/setup/item-categories/{itemCategory}` | `ItemCategoryController@destroy` | `admin.dashboard.inventory.setup.item-categories.destroy` | `inventory.item_categories.delete` |

### Units of Measure (Setup)

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/setup/units-of-measure` | `UnitOfMeasureController@index` | `admin.dashboard.inventory.setup.units-of-measure.index` | `inventory.units_of_measure.view` |
| GET | `/admin/inventory/setup/units-of-measure/create` | `UnitOfMeasureController@create` | `admin.dashboard.inventory.setup.units-of-measure.create` | `inventory.units_of_measure.create` |
| POST | `/admin/inventory/setup/units-of-measure` | `UnitOfMeasureController@store` | `admin.dashboard.inventory.setup.units-of-measure.store` | `inventory.units_of_measure.create` |
| GET | `/admin/inventory/setup/units-of-measure/{unitOfMeasure}/edit` | `UnitOfMeasureController@edit` | `admin.dashboard.inventory.setup.units-of-measure.edit` | `inventory.units_of_measure.update` |
| PUT | `/admin/inventory/setup/units-of-measure/{unitOfMeasure}` | `UnitOfMeasureController@update` | `admin.dashboard.inventory.setup.units-of-measure.update` | `inventory.units_of_measure.update` |
| DELETE | `/admin/inventory/setup/units-of-measure/{unitOfMeasure}` | `UnitOfMeasureController@destroy` | `admin.dashboard.inventory.setup.units-of-measure.destroy` | `inventory.units_of_measure.delete` |

### Suppliers

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/suppliers` | `SupplierController@index` | `admin.dashboard.inventory.suppliers.index` | `inventory.suppliers.view` |
| GET | `/admin/inventory/suppliers/create` | `SupplierController@create` | `admin.dashboard.inventory.suppliers.create` | `inventory.suppliers.create` |
| POST | `/admin/inventory/suppliers` | `SupplierController@store` | `admin.dashboard.inventory.suppliers.store` | `inventory.suppliers.create` |
| GET | `/admin/inventory/suppliers/{supplier}` | `SupplierController@show` | `admin.dashboard.inventory.suppliers.show` | `inventory.suppliers.view` |
| GET | `/admin/inventory/suppliers/{supplier}/statement` | `ARAPController@supplierStatement` | `admin.dashboard.inventory.suppliers.statement` | `inventory.reports.supplier_statement.view` |
| GET | `/admin/inventory/suppliers/{supplier}/edit` | `SupplierController@edit` | `admin.dashboard.inventory.suppliers.edit` | `inventory.suppliers.update` |
| PUT | `/admin/inventory/suppliers/{supplier}` | `SupplierController@update` | `admin.dashboard.inventory.suppliers.update` | `inventory.suppliers.update` |
| DELETE | `/admin/inventory/suppliers/{supplier}` | `SupplierController@destroy` | `admin.dashboard.inventory.suppliers.destroy` | `inventory.suppliers.delete` |

### Purchase Orders

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/purchase-orders` | `PurchaseOrderController@index` | `admin.dashboard.inventory.purchase-orders.index` | `inventory.purchase_orders.view` |
| GET | `/admin/inventory/purchase-orders/create` | `PurchaseOrderController@create` | `admin.dashboard.inventory.purchase-orders.create` | `inventory.purchase_orders.create` |
| POST | `/admin/inventory/purchase-orders` | `PurchaseOrderController@store` | `admin.dashboard.inventory.purchase-orders.store` | `inventory.purchase_orders.create` |
| GET | `/admin/inventory/purchase-orders/{purchaseOrder}` | `PurchaseOrderController@show` | `admin.dashboard.inventory.purchase-orders.show` | `inventory.purchase_orders.view` |
| GET | `/admin/inventory/purchase-orders/{purchaseOrder}/edit` | `PurchaseOrderController@edit` | `admin.dashboard.inventory.purchase-orders.edit` | `inventory.purchase_orders.update` |
| PUT | `/admin/inventory/purchase-orders/{purchaseOrder}` | `PurchaseOrderController@update` | `admin.dashboard.inventory.purchase-orders.update` | `inventory.purchase_orders.update` |
| POST | `/admin/inventory/purchase-orders/{purchaseOrder}/confirm` | `PurchaseOrderController@confirm` | `admin.dashboard.inventory.purchase-orders.confirm` | `inventory.purchase_orders.confirm` |
| POST | `/admin/inventory/purchase-orders/{purchaseOrder}/cancel` | `PurchaseOrderController@cancel` | `admin.dashboard.inventory.purchase-orders.cancel` | `inventory.purchase_orders.cancel` |
| DELETE | `/admin/inventory/purchase-orders/{purchaseOrder}` | `PurchaseOrderController@destroy` | `admin.dashboard.inventory.purchase-orders.destroy` | `inventory.purchase_orders.delete` |

### Purchase Receipts

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/purchase-receipts` | `PurchaseReceiptController@index` | `admin.dashboard.inventory.purchase-receipts.index` | `inventory.purchase_receipts.view` |
| GET | `/admin/inventory/purchase-receipts/create` | `PurchaseReceiptController@create` | `admin.dashboard.inventory.purchase-receipts.create` | `inventory.purchase_receipts.create` |
| POST | `/admin/inventory/purchase-receipts` | `PurchaseReceiptController@store` | `admin.dashboard.inventory.purchase-receipts.store` | `inventory.purchase_receipts.create` |
| GET | `/admin/inventory/purchase-receipts/{purchaseReceipt}` | `PurchaseReceiptController@show` | `admin.dashboard.inventory.purchase-receipts.show` | `inventory.purchase_receipts.view` |
| POST | `/admin/inventory/purchase-receipts/{purchaseReceipt}/post` | `PurchaseReceiptController@post` | `admin.dashboard.inventory.purchase-receipts.post` | `inventory.purchase_receipts.post` |
| POST | `/admin/inventory/purchase-receipts/{purchaseReceipt}/reverse` | `PurchaseReceiptController@reverse` | `admin.dashboard.inventory.purchase-receipts.reverse` | `inventory.purchase_receipts.reverse` |

### Reports

| Method | URI | Controller@method | Named Route | Permission |
|--------|-----|-------------------|-------------|------------|
| GET | `/admin/inventory/reports/stock-report` | `StockReportController@index` | `admin.dashboard.inventory.reports.stock_report.index` | `inventory.reports.stock.view` |
| GET | `/admin/inventory/reports/valuation` | `StockValuationController@valuation` | `admin.dashboard.inventory.reports.valuation` | `inventory.reports.view` |
| GET | `/admin/inventory/reports/low-stock` | `StockValuationController@lowStock` | `admin.dashboard.inventory.reports.low-stock` | `inventory.reports.view` |
| GET | `/admin/inventory/reports/stock-ledger` | `InventoryReportController@stockLedger` | `admin.dashboard.inventory.reports.stock-ledger` | `inventory.reports.view` |
| GET | `/admin/inventory/reports/movement-summary` | `InventoryReportController@movementSummary` | `admin.dashboard.inventory.reports.movement-summary` | `inventory.reports.view` |
| GET | `/admin/inventory/reports/purchase-history` | `InventoryReportController@purchaseHistory` | `admin.dashboard.inventory.reports.purchase-history` | `inventory.reports.view` |

---

## Controllers

### WarehouseController

`App\Http\Controllers\Admin\Inventory\WarehouseController`

Injects `WarehouseService`.

#### `index(): View`
- Calls `warehouseService->getAll()` (paginated, with branch eager-loaded).
- Returns view `dashboard.admin.inventory.warehouses.index` with `$warehouses`.

#### `create(): View`
- Calls `warehouseService->getBranches()` to populate the branch dropdown.
- Returns view `.../create` with `$branches`.

#### `store(StoreWarehouseRequest $request): RedirectResponse`
- Input: validated fields from `StoreWarehouseRequest`.
- Calls `warehouseService->create($request->validated())`.
- Redirects to index with success flash.

#### `edit(Warehouse $warehouse): View`
- Route model binding resolves the warehouse.
- Calls `warehouseService->getBranches()`.
- Returns view `.../edit` with `$warehouse` and `$branches`.

#### `update(UpdateWarehouseRequest $request, Warehouse $warehouse): RedirectResponse`
- Input: validated fields from `UpdateWarehouseRequest`.
- Calls `warehouseService->update($warehouse, $request->validated())`.
- Redirects to index with success flash.

#### `destroy(Warehouse $warehouse): RedirectResponse`
- Calls `warehouseService->delete($warehouse)` (soft delete).
- Redirects to index with success flash.

---

### ItemController

`App\Http\Controllers\Admin\Inventory\ItemController`

Injects `ItemService`.

#### `index(): View`
- Calls `itemService->getAll()` (paginated, eager-loads branch, category, unitOfMeasure).
- Returns view `.../items/index` with `$items`.

#### `create(): View`
- Calls `itemService->getFormOptions()` — returns array with keys `branches`, `categories`, `uoms`, `accounts`.
- Returns view `.../items/create` spreading the options array.

#### `store(StoreItemRequest $request): RedirectResponse`
- Input: validated fields from `StoreItemRequest`.
- Calls `itemService->create($request->validated())`.
- Redirects to index.

#### `edit(Item $item): View`
- Calls `itemService->getFormOptions()`.
- Returns view `.../items/edit` with merged array `['item' => $item] + options`.

#### `update(UpdateItemRequest $request, Item $item): RedirectResponse`
- Input: validated fields from `UpdateItemRequest`.
- Calls `itemService->update($item, $request->validated())`.
- Redirects to index.

#### `destroy(Item $item): RedirectResponse`
- Calls `itemService->delete($item)` (soft delete).
- Redirects to index.

---

### PurchaseOrderController

`App\Http\Controllers\Admin\Inventory\PurchaseOrderController`

Injects `PurchaseOrderService`.

#### `index(): View`
- Calls `service->getAll()` (paginated, eager-loads supplier and branch).
- Returns view `.../purchase-orders/index` with `$records`.

#### `create(): View`
- Calls `service->getSuppliers()`, `service->getBranches()`, `service->getStockItems()`.
- Returns view `.../purchase-orders/create` with `$suppliers`, `$branches`, `$items`.

#### `store(StorePurchaseOrderRequest $request): RedirectResponse`
- Input: validated PO fields + `lines` array (each line: `item_id`, `quantity`, `unit_cost`, optional `description`).
- Calls `service->create($request->validated())` which runs a DB transaction.
- Redirects to the `show` route of the newly created PO.

#### `show(PurchaseOrder $purchaseOrder): View`
- Eager-loads `supplier`, `branch`, `lines.item`, `creator`.
- Returns view `.../purchase-orders/show`.

#### `edit(PurchaseOrder $purchaseOrder): View`
- Calls `abort_unless($purchaseOrder->isEditable(), 403)` — aborts if not in draft status.
- Loads suppliers, branches, items for dropdowns.
- Returns view `.../purchase-orders/edit`.

#### `update(UpdatePurchaseOrderRequest $request, PurchaseOrder $purchaseOrder): RedirectResponse`
- Guards with `abort_unless($purchaseOrder->isEditable(), 403)`.
- Input: validated fields + `lines` array.
- Calls `service->update($purchaseOrder, $request->validated())`.
- Redirects to show.

#### `confirm(PurchaseOrder $purchaseOrder): RedirectResponse`
- No request body needed.
- Calls `service->confirm($purchaseOrder)` — throws `RuntimeException` if not draft.
- Redirects to show with success flash.

#### `cancel(PurchaseOrder $purchaseOrder): RedirectResponse`
- Calls `service->cancel($purchaseOrder)` — throws `RuntimeException` if not draft or confirmed.
- Redirects to show with success flash.

#### `destroy(PurchaseOrder $purchaseOrder): RedirectResponse`
- Calls `service->delete($purchaseOrder)` — throws `RuntimeException` if not draft.
- Redirects to index.

---

### PurchaseReceiptController

`App\Http\Controllers\Admin\Inventory\PurchaseReceiptController`

Injects `PurchaseReceiptService`.

#### `index(): View`
- Calls `service->getAll()` (paginated, eager-loads supplier, warehouse, branch, purchaseOrder).
- Returns view `.../purchase-receipts/index` with `$records`.

#### `create(): View`
- Loads suppliers, warehouses, branches, stock items, and confirmed purchase orders.
- Returns view `.../purchase-receipts/create`.

#### `store(Request $request): RedirectResponse`
- Validation is inline (not a FormRequest). Rules:
  - `receipt_date`: required date
  - `supplier_id`: required, exists in `suppliers`
  - `warehouse_id`: required, exists in `warehouses`
  - `branch_id`: nullable, exists in `branches`
  - `purchase_order_id`: nullable, exists in `purchase_orders`
  - `notes.ar`, `notes.en`: nullable strings
  - `lines`: required array, min 1
  - `lines.*.item_id`: required, exists in `items`
  - `lines.*.quantity`: required numeric, min 0.0001
  - `lines.*.unit_cost`: required numeric, min 0
- Calls `service->create($request->all())` (uses `all()` not `validated()`; trust the inline validation).
- Redirects to show of new receipt.

#### `show(PurchaseReceipt $purchaseReceipt): View`
- Eager-loads supplier, warehouse, branch, purchaseOrder, lines.item, creator.
- Returns view `.../purchase-receipts/show`.

#### `post(PurchaseReceipt $purchaseReceipt): RedirectResponse`
- Calls `service->post($purchaseReceipt)` inside a try/catch.
- On success: redirects to show with success flash.
- On `RuntimeException`: returns back with error flash.

#### `reverse(PurchaseReceipt $purchaseReceipt): RedirectResponse`
- Calls `service->reverse($purchaseReceipt)` inside a try/catch.
- On success: redirects to show with success flash.
- On `RuntimeException`: returns back with error flash.

---

### StockMovementController

`App\Http\Controllers\Admin\Inventory\StockMovementController`

Injects `StockMovementService`.

#### `index(): View`
- Calls `stockMovementService->getAll()` — paginated, ordered by `movement_date` desc then `id` desc, eager-loads warehouse, item, branch.
- Returns view `.../stock_movements/index` with `$stockMovements`.

#### `create(): View`
- Calls `stockMovementService->getFormOptions()` — returns `warehouses` (active, by code), `items` (active stock), `branches` (active), `movementTypes` (`['in', 'out', 'adjustment']`).
- Returns view `.../stock_movements/create` spreading the options array.

#### `store(StoreStockMovementRequest $request): RedirectResponse`
- Input: validated from `StoreStockMovementRequest`.
- Calls `stockMovementService->create($request->validated())` inside a DB transaction.
- Redirects to show of new movement.

#### `show(StockMovement $stockMovement): View`
- Eager-loads warehouse, item, branch, creator, updater.
- Returns view `.../stock_movements/show`.

#### `edit(StockMovement $stockMovement): View`
- Calls `stockMovementService->getFormOptions()` and merges with `['stockMovement' => $stockMovement]`.
- Returns view `.../stock_movements/edit`.

#### `update(UpdateStockMovementRequest $request, StockMovement $stockMovement): RedirectResponse`
- Input: validated from `UpdateStockMovementRequest`.
- Calls `stockMovementService->update($stockMovement, $request->validated())` inside a DB transaction.
- Redirects to show.

#### `destroy(StockMovement $stockMovement): RedirectResponse`
- Calls `stockMovementService->delete($stockMovement)` (hard delete).
- Redirects to index.

---

### StockReportController

`App\Http\Controllers\Admin\Inventory\StockReportController`

Injects `StockReportService`.

#### `index(StockReportFilterRequest $request): View`
- If `$request->hasFilters()` is true, calls `stockReportService->getReport($request->validated())` to compute rows and totals.
- Otherwise, returns empty rows and zeroed totals.
- Also calls `stockReportService->getFilterOptions()` for branch/warehouse/item dropdowns.
- Returns view `.../reports/stock_report/index` with `rows`, `totals`, `selectedFilters`, `hasFilters`, and filter option keys.

---

### StockValuationController

`App\Http\Controllers\Admin\Inventory\StockValuationController`

Injects `StockValuationService`.

#### `valuation(Request $request): View`
- Reads `warehouse_id` and `category_id` from query string (integer, null if absent/zero).
- Calls `service->getValuationReport($warehouseId, $categoryId)` — Collection of stock balance rows.
- Calls `service->getValuationTotals($warehouseId)` — array with `total_items`, `total_qty`, `total_value`.
- Calls `service->getWarehouses()` and `service->getCategories()` for filter dropdowns.
- Returns view `.../reports/valuation`.

#### `lowStock(Request $request): View`
- Calls `service->getLowStockReport()` — items where `quantity <= reorder_level` and `reorder_level > 0`.
- Returns view `.../reports/low-stock` with `$report`.

---

### InventoryReportController

`App\Http\Controllers\Admin\Inventory\InventoryReportController`

Injects `StockValuationService` (as `$valService`).

#### `stockLedger(Request $request): View`
- Reads `item_id`, `warehouse_id`, `date_from`, `date_to` from query string.
- If `item_id` is set, calls `valService->getItemMovementHistory($itemId, $warehouseId, $dateFrom, $dateTo)`.
- Loads active stock items and warehouses for filter dropdowns.
- Returns view `.../reports/stock-ledger`.

#### `movementSummary(Request $request): View`
- Reads `date_from` (default: start of current month), `date_to` (default: today), `warehouse_id`.
- Queries `stock_movements` joined with `items` and `warehouses`, filtered by date range and optional warehouse.
- Groups by `item_id`, `items.code`, `items.name`, `warehouse_id`, `warehouses.name`.
- Selects `total_in` (SUM where type='in'), `total_out` (SUM where type='out'), `total_cost` (SUM total_cost).
- Returns view `.../reports/movement-summary` with `$summary`, `$warehouses`, and filter parameters.

#### `purchaseHistory(Request $request): View`
- Reads `date_from`, `date_to`, `supplier_id`.
- Queries `PurchaseOrder` where `po_date` in range and `status != 'cancelled'`, optionally filtered by `supplier_id`.
- Eager-loads supplier and branch, orders by `po_date desc`.
- Loads active suppliers for filter dropdown.
- Returns view `.../reports/purchase-history`.

---

### SupplierController

`App\Http\Controllers\Admin\Inventory\Supplier\SupplierController`

Injects `SupplierService`.

#### `index(): View`
- Calls `service->getAll()` — paginated, eager-loads branch and apAccount.
- Returns view `.../suppliers/index` with `$records`.

#### `create(): View`
- Calls `service->getBranches()` and `service->getAccounts()` (postable, active).
- Returns view `.../suppliers/create`.

#### `store(StoreSupplierRequest $request): RedirectResponse`
- Calls `service->create($request->validated())`.
- Redirects to `dashboard.inventory.suppliers.index` (note: no `admin.` prefix in this redirect — this may be a minor inconsistency; the named route still resolves correctly within the locale group).

#### `show(Supplier $supplier): View`
- Eager-loads branch and apAccount.
- Calls `service->getBalance($supplier)` — returns the supplier's `opening_balance` as a float.
- Returns view `.../suppliers/show` with `$supplier` and `$balance`.

#### `edit(Supplier $supplier): View`
- Loads branches and accounts for dropdowns.
- Returns view `.../suppliers/edit`.

#### `update(UpdateSupplierRequest $request, Supplier $supplier): RedirectResponse`
- Calls `service->update($supplier, $request->validated())`.
- Redirects to index.

#### `destroy(Supplier $supplier): RedirectResponse`
- Calls `service->delete($supplier)`.
- Redirects to index.

---

### ItemCategoryController

`App\Http\Controllers\Admin\Inventory\Setup\ItemCategoryController`

#### `index(): View`
- Calls `service->getAll()` — paginated, eager-loads inventoryAccount, cogsAccount, revenueAccount.

#### `create(): View`
- Calls `service->getAccounts()` (postable, active Finance accounts).
- Returns `.../item-categories/create` with `$accounts`.

#### `store(StoreItemCategoryRequest $request): RedirectResponse`
- Calls `service->create($request->validated())`.

#### `edit(ItemCategory $itemCategory): View`
- Loads accounts. Returns `.../item-categories/edit`.

#### `update / destroy`
- Standard delegate-to-service pattern.

---

### UnitOfMeasureController

`App\Http\Controllers\Admin\Inventory\Setup\UnitOfMeasureController`

Injects `UnitOfMeasureService`. All methods follow the standard CRUD pattern. The `create` and `edit` views require no extra options (no foreign-key dropdowns).

---

## Services

### WarehouseService

`App\Services\Inventory\WarehouseService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Queries `Warehouse` with `branch` eager-loaded, ordered by `latest()`.

#### `getActive(): Collection`
Queries active warehouses ordered by code. Used by other services for dropdown population.

#### `getBranches(): Collection`
Returns active `Branch` records ordered by name.

#### `create(array $data): Warehouse`
Appends `created_by` from `auth('admin')->id()`. Calls `Warehouse::create($data)`.

#### `update(Warehouse $warehouse, array $data): bool`
Appends `updated_by`. Calls `$warehouse->update($data)`.

#### `delete(Warehouse $warehouse): bool`
Calls `$warehouse->delete()` — triggers the model's soft-delete.

---

### ItemService

`App\Services\Inventory\ItemService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates `Item` with `branch`, `category`, `unitOfMeasure` eager-loaded.

#### `getActive(): Collection`
Returns active items ordered by code.

#### `getActiveStock(): Collection`
Applies both `->active()` and `->stock()` scopes (type = 'stock').

#### `getFormOptions(): array`
Returns:
```php
[
    'branches'   => Branch::active()->orderBy('name')->get(),
    'categories' => ItemCategory::active()->orderBy('code')->get(),
    'uoms'       => UnitOfMeasure::active()->orderBy('code')->get(),
    'accounts'   => Account::where('is_postable', true)->where('is_active', true)->orderBy('code')->get(),
]
```

#### `create(array $data): Item` / `update(Item $item, array $data): bool` / `delete(Item $item): bool`
Standard audit-stamped CRUD. Delete is a soft-delete.

---

### PurchaseOrderService

`App\Services\Inventory\PurchaseOrderService`

Injects `NumberSequenceService`.

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates POs with supplier and branch eager-loaded.

#### `getSuppliers() / getBranches() / getStockItems(): Collection`
Returns active suppliers (by code), active branches (by name), active stock items (by code).

#### `generatePoNumber(?int $branchId = null): string`
Calls `$this->numbers->getNextNumber('inventory', 'purchase_order', $branchId)` and prepends `'PO-'`.

#### `create(array $data): PurchaseOrder`
Wrapped in `DB::transaction`. Steps:
1. Extracts `$data['lines']` and removes it from header data.
2. Sets `created_by`, `po_number` (via `generatePoNumber`), `status = 'draft'`.
3. Creates `PurchaseOrder` record.
4. Calls `syncLines($po, $lines)`.
5. Returns the PO.

#### `update(PurchaseOrder $po, array $data): bool`
Wrapped in `DB::transaction`. Extracts lines, sets `updated_by`, updates PO header, calls `syncLines`.

#### `confirm(PurchaseOrder $po): void`
Guards: `if (! $po->isDraft()) throw RuntimeException`. Updates `status = 'confirmed'`.

#### `cancel(PurchaseOrder $po): void`
Guards: `if (! in_array($po->status, ['draft', 'confirmed'])) throw RuntimeException`. Updates `status = 'cancelled'`.

#### `delete(PurchaseOrder $po): bool`
Guards: `if (! $po->isDraft()) throw RuntimeException`. Calls `$po->delete()`.

#### `syncLines(PurchaseOrder $po, array $lines): void` (private)
1. Deletes all existing lines for the PO (`$po->lines()->delete()`).
2. Iterates lines; skips any without `item_id` or `quantity`.
3. Computes `line_total = round(quantity * unit_cost, 2)`.
4. Creates each line via `$po->lines()->create(...)`.
5. Sums all line totals and updates `$po->total_amount`.

---

### PurchaseReceiptService

`App\Services\Inventory\PurchaseReceiptService`

Injects `NumberSequenceService`, `StockBalanceService`, `InventoryPostingService`.

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates receipts with supplier, warehouse, branch, purchaseOrder eager-loaded.

#### `getConfirmedPurchaseOrders(): Collection`
Returns POs with status in `['confirmed', 'partially_received']` with supplier eager-loaded.

#### `generateReceiptNumber(?int $branchId = null): string`
Uses sequence type `'purchase_receipt'` and prepends `'GRN-'`.

#### `create(array $data): PurchaseReceipt`
Wrapped in `DB::transaction`. Sets `created_by`, `receipt_number`, `status = 'draft'`. Creates receipt header, then calls `syncLines`.

#### `post(PurchaseReceipt $receipt): void`
Guard: `if (! $receipt->isDraft()) throw RuntimeException`.

Wrapped in `DB::transaction`:
1. Loads `lines.item`.
2. For each line:
   - Calls `stockBalance->applyMovement($itemId, $warehouseId, 'in', $qty, $unitCost)`.
   - Returns `['average_cost_before', 'average_cost_after', 'cogs_amount']`.
   - Creates a `StockMovement` record: type `'in'`, `reference_type = 'purchase_receipt'`, `posting_status = 'posted'`, stamps average costs.
   - Updates `$line->item->average_cost` to `average_cost_after`.
3. If `purchase_order_id` is set, calls `updatePoReceivedQuantities($receipt)`.
4. Calls `inventoryPosting->postReceipt($receipt)` (finance GL posting; silently skipped if not configured).
5. Sets `status = 'posted'`.

#### `reverse(PurchaseReceipt $receipt): void`
Guard: `if (! $receipt->isPosted()) throw RuntimeException`.

Wrapped in `DB::transaction`:
1. Loads lines.
2. For each line:
   - Calls `stockBalance->applyMovement($itemId, $warehouseId, 'out', $qty, $unitCost)`.
   - Creates a `StockMovement` record: type `'out'`, `reference_type = 'purchase_receipt_reversal'`, `posting_status = 'reversed'`.
3. Calls `inventoryPosting->reverseReceipt($receipt)`.
4. Sets `status = 'reversed'`.

#### `createFromPo(PurchaseOrder $po, int $warehouseId): PurchaseReceipt`
Builds a receipt data array from the PO, pre-populating lines with remaining quantities (`ordered - received`). Filters out zero-remaining lines. Delegates to `create()`.

#### `syncLines(PurchaseReceipt $receipt, array $lines): void` (private)
Deletes existing lines; iterates and creates each receipt line with `purchase_order_line_id` linkage (nullable).

#### `updatePoReceivedQuantities(PurchaseReceipt $receipt): void` (private)
For each receipt line that has a `purchase_order_line_id`, increments `received_quantity` on the PO line by the receipt quantity. Then reloads all PO lines:
- If every line has `received_quantity >= quantity` → PO status = `'received'`
- Else if any line has `received_quantity > 0` → PO status = `'partially_received'`
- Else no status change

---

### StockMovementService

`App\Services\Inventory\StockMovementService`

Injects `NumberSequenceService`.

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates movements ordered by `movement_date` desc, `id` desc. Eager-loads warehouse, item, branch.

#### `getFormOptions(): array`
Returns `warehouses` (active, by code), `items` (active stock, by code), `branches` (active), `movementTypes` (`['in', 'out', 'adjustment']`).

#### `create(array $data): StockMovement`
Wrapped in `DB::transaction`:
1. `getWarehouse($data['warehouse_id'])` — `findOrFail`.
2. `getStockItem($data['item_id'])` — `findOrFail`, then validates `type === 'stock'`; throws `ValidationException` if not.
3. `prepareData($data, $warehouse, $item)` — resolves `branch_id` (warehouse branch takes priority), casts `quantity` and `unit_cost`, computes `total_cost`.
4. Creates `StockMovement` with `movement_number` from `generateMovementNumber` and `created_by`.
5. Returns movement with relationships loaded.

#### `update(StockMovement $stockMovement, array $data): StockMovement`
Same validation and preparation as `create`, then calls `$stockMovement->update(...)`.

#### `delete(StockMovement $stockMovement): bool`
Hard delete (`$stockMovement->delete()`).

#### `generateMovementNumber(?int $branchId, string $movementDate): string` (private)
Extracts year from date. Calls `numberSequenceService->getOrCreate('inventory', 'stock_movements', $branchId, $year)`. Increments and pads the sequence. Prepends sequence prefix if set.

---

### StockBalanceService

`App\Services\Inventory\StockBalanceService`

The core engine for maintaining the `stock_balances` table.

#### `applyMovement(int $itemId, int $warehouseId, string $direction, float $quantity, float $unitCost = 0): array`

Signature returns `['average_cost_before', 'average_cost_after', 'cogs_amount']`.

Wrapped in `DB::transaction` with `lockForUpdate()` on the balance row (prevents race conditions under concurrent requests).

**'in' direction:**
```
old_value  = old_qty * average_cost_before
new_value  = old_value + (quantity * unitCost)
new_qty    = old_qty + quantity
new_avg    = new_qty > 0 ? round(new_value / new_qty, 6) : unitCost
Updates: quantity = round(new_qty, 4), average_cost = new_avg, total_value = round(new_qty * new_avg, 2)
```

**'out' direction:**
- Checks `allow_negative_stock` on the item; throws `RuntimeException` if balance would go negative and the flag is false.
- `cogs_amount = round(quantity * average_cost_before, 2)`
- `new_qty = round(balance.quantity - quantity, 4)`
- `total_value = round(new_qty * average_cost_before, 2)` (average cost does not change on out)

**'adjustment' direction:**
- Sets `quantity = round(quantity, 4)` directly (absolute value).
- `total_value = round(quantity * average_cost_before, 2)`.

Uses `firstOrCreate` so a balance row is auto-created with zeros if none exists yet.

#### `getBalance(int $itemId, int $warehouseId): StockBalance`
Returns the row, creating it with zeros if absent.

#### `getTotalStock(int $itemId): float`
Sums `quantity` across all warehouses for an item.

#### `getWarehouseStock(int $itemId, int $warehouseId): float`
Returns quantity for a specific item-warehouse pair, or 0.0 if no row exists.

#### `hasStock(int $itemId, int $warehouseId, float $qty): bool`
Returns `getWarehouseStock(...) >= $qty`.

#### `getLowStockItems(): Collection`
Joins `stock_balances` with `items`, filters active stock items where `stock_balances.quantity <= items.reorder_level` and `reorder_level > 0`. Eager-loads item and warehouse.

#### `rebuildBalances(): void`
Truncates `stock_balances`, then replays all `stock_movements` in chronological order (chunked in 500s) through `applyMovement`. Maps movement_type 'in'/'out' directly; everything else maps to 'adjustment'.

---

### StockValuationService

`App\Services\Inventory\StockValuationService`

#### `getValuationReport(?int $warehouseId = null, ?int $categoryId = null): Collection`
Queries `stock_balances` joined with `items`, `warehouses`, and `item_categories`. Filters to active stock items. Optional warehouse and category filters. Selects all balance columns plus `item_code`, `item_name`, `reorder_level`, `warehouse_name`, `category_name`. Returns ordered by `items.code`.

#### `getValuationTotals(?int $warehouseId = null): array`
Runs aggregation over the same base query. Returns:
```php
['total_items' => int, 'total_qty' => float, 'total_value' => float]
```
`total_items` uses `distinct('stock_balances.item_id')->count()`.

#### `getLowStockReport(): Collection`
Same as `StockBalanceService::getLowStockItems` but also selects `reorder_qty` and `warehouse_name`. Ordered by `stock_balances.quantity` ascending (most critical items first).

#### `getItemMovementHistory(int $itemId, ?int $warehouseId, ?string $dateFrom, ?string $dateTo): Collection`
Queries `StockMovement` filtered by `item_id`, optionally by `warehouse_id` and date range. Ordered by `movement_date` then `id` ascending. Eager-loads warehouse and item.

#### `getWarehouses(): Collection` / `getCategories(): Collection`
Return active warehouses (by code) and active item categories (by code) for filter dropdowns.

---

### StockReportService

`App\Services\Inventory\Reports\StockReportService`

#### `getFilterOptions(): array`
Returns `branches`, `warehouses`, `items` (active stock) for filter form dropdowns.

#### `getReport(array $filters): array`
Queries `stock_movements` joined with `items` and `warehouses`. Applies optional filters:
- `branch_id`: matches `stock_movements.branch_id` OR (NULL branch_id AND `warehouses.branch_id`).
- `warehouse_id`: exact match on `stock_movements.warehouse_id`.
- `item_id`: exact match.

Groups by item_id, warehouse_id, items.code, items.name, warehouses.name.

Selects via raw SQL:
- `total_in` = SUM of quantity where type = 'in'
- `total_out` = SUM of quantity where type = 'out'
- `adjustment_quantity` = SUM of quantity where type = 'adjustment'
- `average_unit_cost` = AVG(unit_cost)

Maps each row to a plain object adding:
- `net_quantity = round(total_in - total_out + adjustment_quantity, 2)`
- `stock_value = net_quantity * average_unit_cost` (null if no cost)
- Translates `item_name` and `warehouse_name` from JSON using `translateValue()` (current locale → 'en' → 'ar' → first available).

Returns `['rows' => Collection, 'totals' => array]` where totals aggregate total_in, total_out, net_quantity, stock_value across all rows.

---

### InventoryPostingService

`App\Services\Inventory\Finance\InventoryPostingService`

Bridges inventory events to the Finance GL. Injects `JournalEntryService`, `JournalPostingService`, `FiscalPeriodService`.

All posting is gated by the `purchase_journal_id` setting — if not set, methods return `null` without error.

#### `postReceipt(PurchaseReceipt $receipt): ?JournalEntry`
1. Reads `purchase_journal_id` from Settings; returns null if absent.
2. Groups line costs by inventory account (resolved per line via item → category → system default).
3. Resolves AP account from supplier or system default.
4. Validates total cost > 0.
5. Builds journal lines: one DR per inventory account, one CR to AP for total.
6. Asserts debits == credits (within 0.01 tolerance).
7. Resolves fiscal period for `receipt_date`; throws `RuntimeException` if none found.
8. Calls `postLines(...)` → `JournalEntryService::create(...)` → `JournalPostingService::post(...)`.
9. Saves `journal_entry_id` on the receipt.

#### `reverseReceipt(PurchaseReceipt $receipt): ?JournalEntry`
Loads original `JournalEntry` by `receipt->journal_entry_id`. Returns null if none. Builds mirror lines (swaps debit/credit). Posts into the original entry's period and journal.

#### `postAdjustment(StockMovement $movement): ?JournalEntry`
For positive adjustments: DR inventory, CR inventory adjustment account.
For negative: DR inventory adjustment, CR inventory.
Resolves accounts, asserts balance, posts, and updates `movement->posting_status = 'posted'`.

#### Account Resolution Priority (private helpers)
- `resolveInventoryAccount(Item $item)`: `item->inventory_account_id` → `item->category->inventory_account_id` → `Settings.default_inventory_account_id` → throws.
- `resolveApAccount(Supplier $supplier)`: `supplier->ap_account_id` → `Settings.default_ap_account_id` → throws.
- `inventoryAdjustmentAccountId()`: `Settings.inventory_adjustment_account_id` → throws.

---

### ItemCategoryService

`App\Services\Inventory\Setup\ItemCategoryService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates `ItemCategory` with `inventoryAccount`, `cogsAccount`, `revenueAccount` eager-loaded.

#### `getAccounts(): Collection`
Returns postable, active Finance accounts ordered by code.

#### `create / update / delete`
Standard audit-stamped CRUD. No soft-delete on categories.

---

### UnitOfMeasureService

`App\Services\Inventory\Setup\UnitOfMeasureService`

Simple CRUD service with `getAll()`, `getActive()`, `create()`, `update()`, `delete()`. Audit-stamps `created_by`/`updated_by`.

---

### SupplierService

`App\Services\Inventory\Supplier\SupplierService`

#### `getAll(int $perPage = 15): LengthAwarePaginator`
Paginates with `branch` and `apAccount` eager-loaded.

#### `getAccounts(): Collection`
Postable, active Finance accounts. Used to populate the AP account dropdown.

#### `getBalance(Supplier $supplier): float`
Returns `(float) $supplier->opening_balance`. Placeholder — full AP balance will incorporate transactions in a future iteration.

#### `create / update / delete`
Standard audit-stamped CRUD. No soft-delete.

---

## Models

### Warehouse

Table: `warehouses`

```
id, code (unique), name (json/translatable), branch_id (nullable FK→branches),
is_active (boolean), created_by (FK→admins), updated_by (FK→admins),
timestamps, soft_deletes
```

Fillable: `code`, `name`, `branch_id`, `is_active`, `created_by`, `updated_by`

Translatable: `name`

Casts: `is_active => boolean`

Relationships: `branch()` BelongsTo Branch, `creator()` BelongsTo Admin, `updater()` BelongsTo Admin

Scopes: `scopeActive($query)` — filters `is_active = true`

---

### Item

Table: `items` (base columns in `2026_03_16_000002`; extended by `2026_05_19_000006`)

Full column set:
```
id, code (unique), name (json), description (json, nullable), type (string, default 'stock'),
unit (string), branch_id (nullable FK), is_active (boolean),
category_id (nullable FK→item_categories), uom_id (nullable FK→units_of_measure),
purchase_price (decimal 18,4 nullable), sale_price (decimal 18,4 nullable),
reorder_level (decimal 18,4 default 0), reorder_qty (decimal 18,4 default 0),
cost_method (enum: average|fifo, default average), average_cost (decimal 18,6 default 0),
inventory_account_id (nullable FK→accounts), cogs_account_id (nullable FK→accounts),
revenue_account_id (nullable FK→accounts), allow_negative_stock (boolean default false),
created_by (FK→admins), updated_by (FK→admins), timestamps, soft_deletes
```

Fillable: all columns above.

Translatable: `name`, `description`

Casts:
```php
'is_active'            => 'boolean',
'allow_negative_stock' => 'boolean',
'purchase_price'       => 'decimal:4',
'sale_price'           => 'decimal:4',
'reorder_level'        => 'decimal:4',
'reorder_qty'          => 'decimal:4',
'average_cost'         => 'decimal:6',
```

Relationships: `branch()`, `category()` BelongsTo ItemCategory (FK `category_id`), `unitOfMeasure()` BelongsTo UnitOfMeasure (FK `uom_id`), `inventoryAccount()`, `cogsAccount()`, `revenueAccount()` all BelongsTo Account, `creator()`, `updater()`

Scopes:
- `scopeActive($query)` — `is_active = true`
- `scopeStock($query)` — `type = 'stock'`

---

### ItemCategory

Table: `item_categories`

```
id, code (unique), name (json), inventory_account_id (nullable FK→accounts),
cogs_account_id (nullable FK→accounts), revenue_account_id (nullable FK→accounts),
is_active (boolean default true), created_by, updated_by, timestamps
```

Fillable: `code`, `name`, `inventory_account_id`, `cogs_account_id`, `revenue_account_id`, `is_active`, `created_by`, `updated_by`

Translatable: `name`

Casts: `name => 'array'`, `is_active => 'boolean'`

Relationships: `inventoryAccount()`, `cogsAccount()`, `revenueAccount()` all BelongsTo Account, `creator()`, `updater()`

Scope: `scopeActive($query)`

---

### UnitOfMeasure

Table: `units_of_measure`

```
id, code (unique), name (json), is_active (boolean default true), created_by, updated_by, timestamps
```

Translatable: `name`

Scope: `scopeActive($query)`

---

### Supplier

Table: `suppliers`

```
id, code (unique), name (json), phone (nullable), email (nullable), address (json nullable),
tax_number (nullable), credit_limit (decimal 18,2 default 0),
payment_terms_days (unsigned int default 30), ap_account_id (nullable FK→accounts),
branch_id (nullable FK→branches), opening_balance (decimal 18,2 default 0),
opening_balance_date (date nullable), is_active (boolean default true),
created_by, updated_by, timestamps
```

Fillable: all above fields.

Translatable: `name`, `address`

Casts:
```php
'name'                 => 'array',
'address'              => 'array',
'credit_limit'         => 'decimal:2',
'opening_balance'      => 'decimal:2',
'opening_balance_date' => 'date',
'is_active'            => 'boolean',
```

Relationships: `apAccount()` BelongsTo Account (FK `ap_account_id`), `branch()`, `creator()`, `updater()`

Scope: `scopeActive($query)`

---

### PurchaseOrder

Table: `purchase_orders`

```
id, po_number (unique), po_date (date), supplier_id (FK→suppliers, restrictOnDelete),
branch_id (nullable FK→branches), status (enum: draft|confirmed|partially_received|received|cancelled, default draft),
total_amount (decimal 18,2 default 0), notes (json nullable),
expected_delivery_date (date nullable), created_by, updated_by, timestamps
```

Fillable: `po_number`, `po_date`, `supplier_id`, `branch_id`, `status`, `total_amount`, `notes`, `expected_delivery_date`, `created_by`, `updated_by`

Translatable: `notes`

Casts:
```php
'po_date'                => 'date',
'expected_delivery_date' => 'date',
'total_amount'           => 'decimal:2',
```

Relationships: `supplier()`, `branch()`, `lines()` HasMany PurchaseOrderLine, `creator()`, `updater()`

State helpers:
- `isDraft(): bool` — `status === 'draft'`
- `isConfirmed(): bool` — `status === 'confirmed'`
- `isCancelled(): bool` — `status === 'cancelled'`
- `isEditable(): bool` — `status === 'draft'` (same as isDraft; used for gate-keeping edit/update routes)

No soft-delete. Supplier deletion is restricted while POs reference it.

---

### PurchaseOrderLine

Table: `purchase_order_lines`

```
id, purchase_order_id (FK→purchase_orders, cascadeOnDelete),
item_id (FK→items, restrictOnDelete), description (json nullable),
quantity (decimal 18,4), unit_cost (decimal 18,4),
line_total (decimal 18,2 default 0), received_quantity (decimal 18,4 default 0), timestamps
```

Fillable: `purchase_order_id`, `item_id`, `description`, `quantity`, `unit_cost`, `line_total`, `received_quantity`

Translatable: `description`

Casts: `quantity`, `unit_cost` as `decimal:4`; `line_total` as `decimal:2`; `received_quantity` as `decimal:4`

Relationships: `purchaseOrder()`, `item()`

Computed attribute: `getRemainingQuantityAttribute(): float` = `quantity - received_quantity`

---

### PurchaseReceipt

Table: `purchase_receipts`

```
id, receipt_number (unique), receipt_date (date),
purchase_order_id (nullable FK→purchase_orders, nullOnDelete),
supplier_id (FK→suppliers, restrictOnDelete),
warehouse_id (FK→warehouses, restrictOnDelete),
branch_id (nullable FK→branches),
status (enum: draft|posted|reversed, default draft),
journal_entry_id (nullable FK→journal_entries, added by migration 2026_05_28_000001),
notes (json nullable), created_by, updated_by, timestamps
```

Fillable: all above.

Translatable: `notes`

Casts: `receipt_date => 'date'`

Relationships: `purchaseOrder()`, `supplier()`, `warehouse()`, `branch()`, `lines()` HasMany PurchaseReceiptLine, `creator()`, `journalEntry()` BelongsTo JournalEntry

State helpers: `isDraft(): bool`, `isPosted(): bool`

---

### PurchaseReceiptLine

Table: `purchase_receipt_lines`

```
id, purchase_receipt_id (FK→purchase_receipts, cascadeOnDelete),
purchase_order_line_id (nullable FK→purchase_order_lines, nullOnDelete),
item_id (FK→items, restrictOnDelete), description (json nullable),
quantity (decimal 18,4), unit_cost (decimal 18,4),
line_total (decimal 18,2 default 0), timestamps
```

Fillable: all above.

Translatable: `description`

Relationships: `purchaseReceipt()`, `purchaseOrderLine()`, `item()`

---

### StockMovement

Table: `stock_movements` (base in `2026_03_16_000003`; columns `posting_status`, `journal_entry_id`, `average_cost_before`, `average_cost_after` added by `2026_05_19_000008`)

Full column set:
```
id, movement_number (unique), movement_date (date), movement_type (string),
warehouse_id (FK→warehouses, cascadeOnDelete), item_id (FK→items, cascadeOnDelete),
quantity (decimal 18,2), unit_cost (decimal 18,2 nullable), total_cost (decimal 18,2 nullable),
reference_type (string nullable), reference_id (bigint nullable),
notes (json nullable), branch_id (nullable FK→branches),
posting_status (enum: pending|posted|reversed, default posted),
journal_entry_id (nullable FK→journal_entries),
average_cost_before (decimal 18,6 default 0),
average_cost_after (decimal 18,6 default 0),
created_by, updated_by, timestamps
```

Fillable: all above columns.

Translatable: `notes`

Casts:
```php
'movement_date'       => 'date',
'quantity'            => 'decimal:4',
'unit_cost'           => 'decimal:4',
'total_cost'          => 'decimal:2',
'average_cost_before' => 'decimal:6',
'average_cost_after'  => 'decimal:6',
'reference_id'        => 'integer',
```

Relationships: `warehouse()`, `item()`, `branch()`, `creator()`, `updater()`

Note: no soft-delete on movements. Cascade from warehouse/item ensures referential integrity is maintained by DB.

---

### StockBalance

Table: `stock_balances` (created by `2026_05_19_000008`)

```
id, item_id (FK→items, cascadeOnDelete), warehouse_id (FK→warehouses, cascadeOnDelete),
quantity (decimal 18,4 default 0), average_cost (decimal 18,6 default 0),
total_value (decimal 18,2 default 0), timestamps
```

Unique constraint: `(item_id, warehouse_id)`

Fillable: `item_id`, `warehouse_id`, `quantity`, `average_cost`, `total_value`

Casts: `quantity => 'decimal:4'`, `average_cost => 'decimal:6'`, `total_value => 'decimal:2'`

Relationships: `item()`, `warehouse()`

---

## Stock Balance Calculation — Detailed Walkthrough

The stock balance is a **stored running total**, not a view or computed query. It is maintained atomically in `StockBalanceService::applyMovement()` using database-level row locking (`lockForUpdate()`).

### On Receipt Posting (direction = 'in')

```
existing_row: quantity=Q, average_cost=C, total_value=V
incoming:     quantity=q, unit_cost=c

old_value  = Q * C          # existing inventory value
new_value  = V + (q * c)    # or (Q * C) + (q * c) - same since V = Q * C
new_qty    = Q + q
new_avg    = new_qty > 0
               ? round(new_value / new_qty, 6)
               : c           # edge case: first receipt into empty stock

UPDATE stock_balances SET
    quantity    = round(new_qty, 4),
    average_cost = new_avg,
    total_value  = round(new_qty * new_avg, 2)

UPDATE items SET average_cost = new_avg   (caller, not inside applyMovement)
```

### On Stock Issue (direction = 'out')

```
existing_row: quantity=Q, average_cost=C
outgoing:     quantity=q

if not item.allow_negative_stock and Q < q:
    throw RuntimeException

cogs_amount = round(q * C, 2)   # returned to caller for GL posting
new_qty     = round(Q - q, 4)

UPDATE stock_balances SET
    quantity    = new_qty,
    total_value = round(new_qty * C, 2)
# average_cost does NOT change on an 'out' movement
```

### On Adjustment (direction = 'adjustment')

```
# The caller passes the new absolute quantity
UPDATE stock_balances SET
    quantity    = round(q, 4),
    total_value = round(q * C, 2)
# average_cost unchanged; only quantity is reset
```

---

## Purchase Order Status Transitions in Code

The `PurchaseOrder` model does not use a state-machine library. Transitions are enforced manually in `PurchaseOrderService`:

```
draft ──────────────────────────────────────────────────────────── cancelled
  │                                                                    ↑
  │ service->confirm()                                   service->cancel()
  ↓                                                                    │
confirmed ──────────────────────────────────────────────────────────> │
  │                                                                    │
  │ (first GRN posted via PurchaseReceiptService)
  ↓
partially_received ─────────────────────────────── cancelled (via service->cancel())
  │
  │ (all lines fully received)
  ↓
received
```

Key rules enforced in service methods:

- `confirm()`: guard `isDraft()` → sets `status = 'confirmed'`
- `cancel()`: guard `status in ['draft', 'confirmed']` → sets `status = 'cancelled'`
- `delete()`: guard `isDraft()` → hard deletes (PO lines cascade)
- `update()` / `edit route`: guarded by `isEditable()` (= `isDraft()`) — both in service and controller (`abort_unless`)
- `partially_received` / `received`: set automatically by `updatePoReceivedQuantities()` in `PurchaseReceiptService` after posting a receipt; never set directly by a controller action

The `received` and `partially_received` statuses have no explicit cancel guard — a business decision to prevent reversal of a PO that has already seen stock arrive must currently be handled at the application/UI level.
