# 11 — Inventory & Purchases: Manufacturing's Most-Coupled Neighbors

Technical mapping of `Modules\Inventory` and `Modules\Purchases` (Moon ERP backend at
`/home/moonui/moon-erp-be`) as the foundation a Manufacturing module must build on. All
citations are `file:line`. Inferences are marked **(inference)**.

---

## 1. Item / Product Master (lives in Core, NOT Inventory)

The product master is owned by `Modules\Core`, shared by every module. Inventory and
Purchases only reference `products` / `product_variants` by FK.

| Concern | Where | Citation |
|---|---|---|
| Product table | `products` | `Modules/Core/database/migrations/2026_02_16_300001_create_products_table.php:11` |
| Variants | `product_variants` | `Modules/Core/database/migrations/2026_02_16_300003_create_product_variants_table.php` |
| Variant flag | `has_variants` column | `Modules/Core/database/migrations/2026_02_19_000003_add_has_variants_to_products_table.php` |
| Tracking type cast | `Product::$casts['tracking_type'] => ProductTrackingType::class` | `Modules/Core/app/Models/Product.php:73` |

**Key product columns** (`...300001...products_table.php:14-40`): `code`, `sku`, `name`,
`name_ar`, `type` (default `product`), `status`, `product_category_id`, `barcode`, `brand`,
`base_unit_id` (→ `units`), `purchase_price`, `sale_price`, `cost_method` (per-product
costing override, nullable, line 30), `track_inventory` (line 32), and the
**reorder fields**: `min_stock_level`, `max_stock_level`, `reorder_point` (lines 33-35).

**`ProductType` enum has only `Product` and `Service`** — there is NO
raw-material / WIP / finished-good / by-product classification
(`Modules/Core/app/Enums/ProductType.php:7-8`). **Gap input:** Manufacturing's
RM/WIP/FG/co-product/by-product distinction, `procurement_type` (make vs buy), and
`material_ownership` (own vs customer/consignment) do not exist and must be added (likely as
new columns/enum on `products` or a Manufacturing-side item-settings table). **(inference)**

### UoM model + conversions — STRONG, reusable
- `unit_groups` → `units` (`Modules/Core/database/migrations/2026_02_16_100002...` and
  `...100003_create_units_table.php`). Each `units` row has `unit_group_id`, `symbol`,
  `conversion_factor` (decimal 15,6), `is_base` (`...units_table.php:14-22`).
- Per-product unit conversions: `product_units` with `conversion_factor` (decimal 15,6),
  `is_purchase`, `is_sale` flags, plus per-unit barcode/prices
  (`Modules/Core/database/migrations/2026_02_16_300002_create_product_units_table.php:16-21`).
- Product base unit: `products.base_unit_id` (`...products_table.php:26`).

Conclusion: full UoM + conversion infrastructure exists and Manufacturing BOM lines /
routing can reuse `units` + `product_units` directly. BOM/issue/receipt rows already carry
`unit_id` (see inventory item tables below).

### Batch / Lot / Serial support — PARTIAL (key gap input)
- `ProductTrackingType` enum: `None`, `Batch`, `Serial`
  (`Modules/Core/app/Enums/ProductTrackingType.php:7-9`).
- `tracking_type` column added at **app level**, not in the Core module:
  `database/migrations/2026_02_23_102407_add_tracking_type_to_products_table.php`.
- **Serial** is fully modelled: `product_serials` table
  (`database/migrations/2026_02_23_102436_create_product_serials_table.php:15`) with
  `serial_number`, `batch_number`, `expiry_date`, `status`, `cost`, `warehouse_id`,
  `reference_type/id`. Serial lifecycle (`SerialStatus`: available → sold) is enforced in
  `ApproveReceipt` (creates serials, `...ApproveReceipt.php:62-77`) and `ApproveIssue`
  (validates + marks Sold, `...ApproveIssue.php:41-87`).
- **Batch/lot is NOT a first-class entity.** There is no `batches` master table and no batch
  balance. `batch_number` / `expiry_date` are merely free-text string columns on
  receipt/issue items (`inventory_receipt_items.batch_number/expiry_date`,
  `...200002...:20-21`; `inventory_issue_items.batch_number`, `...200006...:20`) and on GRN
  items (`...300002_create_purchase_grn_items_table.php:31-33`). Stock balances and cost
  layers are keyed by `(product, variant, warehouse)` only — **NOT by batch**
  (`...200003_create_inventory_stock_balances_table.php:22`,
  `...400005_create_inventory_cost_layers_table.php:29`).

**Gap input:** Manufacturing needs true batch/lot genealogy (which RM lots were consumed into
which FG lot, expiry-driven FEFO issue). The current model cannot trace batch consumption or
hold batch-level quantities/costs. A batch-balance table + batch-aware StockService would be
required. **(inference)**

---

## 2. Warehouses / Locations

Single-level `warehouses` table (`Modules/Inventory/database/migrations/2026_02_22_100001_create_warehouses_table.php:11`):
- `company_id`, `branch_id` (branch scoping), `code`, `name`/`name_ar`, `type` (default
  `main`; see `WarehouseType` enum), `parent_warehouse_id` (self-reference, line 19),
  `manager_id`, `is_active`, **`allow_negative_stock`** (line 24), and
  **`account_id`** → Accounting `Account` (line 25; relation at
  `Modules/Inventory/app/Models/Warehouse.php:76-79`).

**No bin / location / sub-warehouse-slot model below the warehouse** (only
`parent_warehouse_id` hierarchy). **Gap input:** Manufacturing shop-floor staging
locations, WIP locations, and line-side supermarkets would map onto separate `warehouses`
rows (or need a new bin model). RM / WIP / FG buckets are most cheaply modelled as
distinct warehouses with `type`. **(inference)**

---

## 3. StockService — the central stock API

`Modules/Inventory/app/Services/StockService.php`. Constructor injects
`Core\Services\SettingsService` (`:13`). Public API:

| Method | Signature | Purpose | Citation |
|---|---|---|---|
| `increaseStock(array $data): StockBalance` | data: company_id, product_id, product_variant_id, warehouse_id, quantity, unit_cost, movement_type, reference_type, reference_id, date, notes | Adds qty, recomputes WAC, **always writes a FIFO cost layer**, writes an `inventory_movements` row | `:35-93` |
| `decreaseStock(array $data): StockBalance` | same shape | Removes qty; FIFO mode consumes oldest layers, WAC mode uses passed unit_cost; writes movement | `:113-169` |
| `getIssueCost(companyId, productId, variantId, warehouseId, quantity): {unit_cost,total_cost}` | — | Preview issue cost without consuming (FIFO peek or WAC avg) | `:177-196` |
| `getProductCost(companyId, productId, variantId=null, warehouseId=null): float` | — | Current unit cost (oldest FIFO layer or WAC avg) | `:201-232` |
| `getValuationMethod(companyId): string` | — | Reads setting `inventory.valuation_method`, default `weighted_avg` | `:237-240` |

Private: `consumeFifoLayers` (`:245`), `calculateFifoCost` (peek, `:279`),
`getOrCreateBalance` (`:308`).

### Movement types
`MovementType` enum (`Modules/Inventory/app/Enums/MovementType.php:7-13`):
`receipt`, `issue`, `transfer_in`, `transfer_out`, `adjustment`, `opening`, `return`.
**No manufacturing-specific types** (no `production_issue`, `production_receipt`,
`wip_in/out`, `scrap`, `rework`). **Gap input:** Manufacturing must add movement types (or
reuse `issue`/`receipt` with distinct `reference_type` strings). **(inference)**

### Costing layers (FIFO + WAC)
- Cost layers: `inventory_cost_layers` (`...400005...:15`) — `original_quantity`,
  `remaining_quantity`, `unit_cost`, `date`, keyed by `(product, variant, warehouse)`.
  `increaseStock` ALWAYS creates a layer regardless of method (`StockService.php:60-72`), so
  switching FIFO↔WAC is data-safe.
- WAC: maintained on `inventory_stock_balances.average_cost` / `.total_value`
  (`StockService.php:50-58`).
- Per-company method via `inventory.valuation_method` setting (`:239`). Per-product override
  column `products.cost_method` exists but **StockService does not read it** — only the
  company-level setting drives behaviour. **(inference)** This is a gap for Manufacturing
  standard-costing (a common mfg requirement: standard cost + variance accounts), which is
  not supported at all today.

### Reservation support — COLUMN EXISTS, NO LOGIC (key gap input)
- `inventory_stock_balances.reserved_quantity` exists
  (`...500001_add_tracking_columns_to_stock_balances_table.php:16`), and
  `StockBalance::getAvailableQuantityAttribute()` returns `quantity - reserved_quantity`
  (`Modules/Inventory/app/Models/StockBalance.php:47-50`).
- **But NO service writes `reserved_quantity`.** Grep across Inventory + Sales finds only the
  model accessor, the fillable entry, and the API resource field — there is no
  `reserve()` / `release()` method anywhere
  (`Modules/Inventory/app/Models/StockBalance.php:25,49`;
  `.../Http/Resources/StockBalanceResource.php:26`). `ApproveIssue` checks
  `balance->quantity` (gross), NOT available qty (`...ApproveIssue.php:97`).

**Gap input:** the spec requires reservations created at order Release and consumed at
Material Issue (`06_integrations_and_data_model.md` §6.1). The `reserved_quantity` field is a
ready-made hook, but **Manufacturing must implement the reserve/release logic itself**
(new `StockService::reserve()/release()` or a Manufacturing Action that updates
`reserved_quantity` transactionally). **(inference)**

### Stock-transaction schema
- `inventory_movements` (immutable ledger): `movement_type`, `reference_type`,
  `reference_id`, `date`, `quantity_in`, `quantity_out`, `unit_cost`, `total_cost`,
  `balance_after`, `cost_after`, indexed by `(product, warehouse, date)` and
  `(reference_type, reference_id)`
  (`...200004_create_inventory_movements_table.php:11-32`).
- `inventory_stock_balances` (current state): `quantity`, `reserved_quantity`,
  `average_cost`, `total_value`, `last_receipt_date`, `last_issue_date`; unique on
  `(product, variant, warehouse)` (`...200003...:22`, `...500001...:16-18`).

### Inventory does NOT post to GL (important)
`Modules\Inventory` has **no** journal-entry integration: its `EventServiceProvider` listens
to nothing, there are no listeners, and no file references `CreateJournalEntry`
(the only `Accounting` reference in the whole module is the `Warehouse::account()` relation).
`ApproveReceipt` / `ApproveIssue` move stock only — they create **no** journal entries.
**The consuming module owns the GL posting** (Purchases posts DR Inventory on bill;
Sales posts DR COGS / CR Inventory on invoice — see §6). **Manufacturing must post its own
WIP / applied-cost / variance entries** via `CreateJournalEntry`, mirroring Purchases/Sales.

---

## 4. GRN / Purchase Receipt flow (the canonical "create a draft InventoryReceipt then call ApproveReceipt" pattern)

`Modules\Purchases\Http\Controllers\PurchaseGrnController::approve()`
(`.../PurchaseGrnController.php:335-433`) is the reference implementation Manufacturing
should copy for **finished-goods receipt**:

1. Generate receipt number via `SequenceService::generateNext($companyId,'inventory','receipt')` (`:359`).
2. `InventoryReceipt::create([... status => ReceiptStatus::Draft, reference_type => ReceiptReferenceType::Purchase, reference_id => $grn->id ...])` (`:365-376`).
3. Add receipt items (uses `accepted_quantity` in quality mode, else `quantity`); carries
   `batch_number`, `expiry_date`, `serial_numbers` through (`:379-400`).
4. `app(ApproveReceipt::class)->execute($inventoryReceipt, $userId)` — this is what actually
   increases stock (`:406`).
5. Update PO `received_quantity` per line and recompute PO receive status
   (`:410-411`, `updatePoReceivedQuantities` `:508-533`).

GRN modes (`purchases.grn_mode` setting): `direct` (no GRN; stock comes in at bill posting),
`grn`, `grn_quality` (`:490-506`). `ApproveReceipt::execute` validates serials and calls
`StockService::increaseStock` per item (`...ApproveReceipt.php:80-92`).

`PurchaseGrn` status machine: `PurchaseGrnStatus` (draft → pending_quality →
quality_approved/quality_rejected → approved/cancelled).

---

## 5. Purchase Requisition → PO flow (MRP entry point)

This is what MRP will drive to auto-create procurement.

- **Purchase Request (requisition):** `purchase_requests`
  (`Modules/Purchases/database/migrations/2026_02_25_100001...:11`) with `request_number`,
  `date`, `requested_by`, `department`, `needed_by`, `priority`, `cost_center_id`, `status`
  (default `draft`), `approved_by/at`, **`converted_to_order_id`** (line 33), `subtotal`.
  Lines in `purchase_request_items` (`...100002...`).
- Status machine: `PurchaseRequestStatus` (draft → pending/approved → converted / rejected /
  cancelled). Controller `PurchaseRequestController` has `submitApproval`, `approve`,
  `reject`, `cancel` (`.../PurchaseRequestController.php:202,229,254,280`). PR is created via
  `store()` with `SequenceService::generateNext($companyId,'purchases','request')` **(inference on key name)** (`:99-105`).
- **PR → PO conversion:** `PurchaseOrderController` declares a `convertFromRequest`
  permission/route (`.../PurchaseOrderController.php:40`); `StorePurchaseOrderRequest` accepts
  `purchase_request_id` (`.../StorePurchaseOrderRequest.php:23`); on store, if
  `purchase_request_id` is set the PR is marked `PurchaseRequestStatus::Converted`
  (`.../PurchaseOrderController.php:146-151`).

**For MRP, the cleanest integration is:** MRP creates `PurchaseRequest` + items
(`status = draft/approved`) tagged with the source demand, exactly the same way a user would,
then the normal Purchases approval/convert flow turns them into POs. There is **no
programmatic Action** (e.g. `CreatePurchaseRequest`) exposed today — PR creation lives in the
controller's `store()`. **Gap input:** Manufacturing/MRP should not call the HTTP controller;
a thin `Purchases\Actions\CreatePurchaseRequest` Action should be extracted so MRP can invoke
it in-process. **(inference)**

### Min/max / reorder rules
- Reorder thresholds live on the **product**: `reorder_point`, `min_stock_level`,
  `max_stock_level` (`...products_table.php:33-35`).
- Surfacing is read-only/reporting: `ReorderAlertController` queries products with
  `track_inventory = true`, `is_active = true`, `reorder_point > 0`, joins
  `StockBalance`, and emits alerts/notifications
  (`Modules/Inventory/app/Http/Controllers/ReorderAlertController.php:47-118`).
- **No automatic requisition generation** from reorder breaches — it is alert-only. MRP /
  reorder-point procurement must be built by Manufacturing (or a scheduler) on top of these
  fields. **(inference)**

---

## 6. How COGS / inventory value posts to Accounting (the pattern Manufacturing must mirror)

GL is always written through `Modules\Accounting\Actions\CreateJournalEntry` — never directly.

- **Inbound value (Purchases):** `PostPurchaseBill::execute` builds the purchase JE via
  `CreateJournalEntry` — **DR Inventory** (for `track_inventory` products) / DR Expense /
  DR Input-Tax / CR Discount / **CR Accounts Payable**
  (`Modules/Purchases/app/Actions/PostPurchaseBill.php:36-164`). In `direct` GRN mode it also
  creates+approves an `InventoryReceipt` to move stock (`handleDirectModeStock` `:182-249`).
  Account IDs come from settings: `purchases.inventory_account_id`,
  `purchases.expense_account_id`, `purchases.payable_account_id`,
  `purchases.tax_receivable_account_id` (`:65-69`).
- **Outbound COGS (Sales):** `PostSalesInvoice::handleCogsAndStock` computes cost via
  `StockService` and posts **DR COGS / CR Inventory** through `CreateJournalEntry`
  (`Modules/Sales/app/Actions/PostSalesInvoice.php:171-291`), then
  `StockService::decreaseStock` and creates an `InventoryIssue`
  (`:220`, `:352`). COGS account from `sales.cogs_account_id`; inventory credit account
  resolved from `sales.inventory_account_id` → `purchases.inventory_account_id`
  (`:299-307`).

So **stock movement and GL posting are decoupled**: StockService moves quantity/value;
the business module writes the journal. Manufacturing follows the same recipe.

---

## 7. Exact service / Action calls a Manufacturing module would use

All calls are in-process Action/Service invocations (Moon "Actions for cross-module calls"
recipe). Namespaces: `Modules\Inventory\Services\StockService`,
`Modules\Inventory\Actions\*`, `Modules\Inventory\Models\*`,
`Modules\Accounting\Actions\CreateJournalEntry`, `Modules\Core\Services\SequenceService`.

### (a) Reserve materials (at Production Order Release)
- **No ready API.** Manufacturing must transactionally increment
  `StockBalance::reserved_quantity` for each `Order_Component`
  (`StockBalance` keyed by product/variant/warehouse; field at
  `...500001...:16`). Recommended: add `StockService::reserve(array)` /
  `release(array)`, or a `Manufacturing\Actions\ReserveOrderComponents` Action.
  Availability check uses `StockBalance::available_quantity`
  (`Models/StockBalance.php:47`). **(inference / gap)**

### (b) Issue materials to production (RM → WIP)
1. Build a draft `InventoryIssue` (+ items with `unit_id`, `quantity`, optional
   `batch_number`, `serial_numbers`) — reference pattern in
   `PostSalesInvoice.php:352`. Set `reference_type = 'production_order'` (new),
   `reference_id = $order->id`.
2. `app(\Modules\Inventory\Actions\ApproveIssue::class)->execute($issue, $userId)` — this
   calls `StockService::getIssueCost` + `decreaseStock` with `MovementType::Issue`,
   relieving stock at FIFO/WAC cost (`...ApproveIssue.php:110-136`).
3. Capture returned issue cost (`$item->total_cost`) and post **DR WIP / CR Inventory** via
   `app(CreateJournalEntry::class)->execute([...], $lines)` — Manufacturing owns this JE
   (mirror `PostSalesInvoice.php:264-296`). **(inference)**
   - Release reservation as the issue is posted (decrement `reserved_quantity`).

### (c) Receive finished goods (WIP → FG)
1. `SequenceService::generateNext($companyId,'inventory','receipt')`
   (pattern `PurchaseGrnController.php:359`).
2. `InventoryReceipt::create([... status => Draft, reference_type => 'production_order',
   reference_id => $order->id, unit_cost => <computed FG cost> ...])` + items
   (`PurchaseGrnController.php:365-400`).
3. `app(\Modules\Inventory\Actions\ApproveReceipt::class)->execute($receipt, $userId)` —
   increases stock and creates a FIFO cost layer at the supplied FG unit cost
   (`...ApproveReceipt.php:80-92`). FG `unit_cost` must be the rolled-up WIP cost computed by
   Manufacturing (materials issued + applied labor/machine/OH).
4. Post **DR Finished Goods Inventory / CR WIP** via `CreateJournalEntry`. **(inference)**

### (d) Trigger purchase requisitions (MRP → Procurement)
- Create `Modules\Purchases\Models\PurchaseRequest` + `PurchaseRequestItem` rows
  (status `draft` or `approved`), populating `needed_by`, `priority`, `cost_center_id`, and
  linking back to the MRP demand. Today PR creation lives only in
  `PurchaseRequestController::store` — **recommended:** extract a
  `Purchases\Actions\CreatePurchaseRequest` so MRP can call it in-process; PR then flows
  through `convertFromRequest` to a PO (`PurchaseOrderController.php:40,146-151`).
  **(inference / gap)**

---

## 8. Summary of gap inputs for the board

1. **No batch/lot master or batch-level balances** — only serial is first-class;
   batch is free-text. Blocks lot genealogy / FEFO. (`inventory_stock_balances` keyed
   product/variant/warehouse, `...200003...:22`.)
2. **Reservation logic absent** — `reserved_quantity` column exists but nothing writes it
   (`StockBalance.php:25,49`).
3. **No manufacturing item classification** — `ProductType` is only Product/Service; no
   RM/WIP/FG, `procurement_type`, or `material_ownership`/consignment
   (`ProductType.php:7-8`).
4. **No standard costing / variance** — only FIFO + WAC; per-product `cost_method` unused by
   StockService (`StockService.php:239`).
5. **No production movement types** — `MovementType` lacks production issue/receipt/scrap/
   rework (`MovementType.php:7-13`).
6. **No GL in Inventory** — Manufacturing must author all WIP/applied/variance journals via
   `CreateJournalEntry`.
7. **No programmatic PR creation Action** — MRP needs a `CreatePurchaseRequest` Action
   (currently controller-only).
8. **No sub-warehouse bin/location model** — shop-floor/WIP locations must be modelled as
   warehouses or new bins.
9. **Reorder is alert-only** — no auto-requisition from reorder-point breach
   (`ReorderAlertController.php`).
</content>
</invoke>
