# Procurement Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Procurement module implements the **full purchase-to-pay (P2P) cycle** for the club. It manages:
- Purchase Requisitions (PR) with approval workflow
- PR-to-PO conversion
- Goods Received Notes (GRN) with quality inspection
- Vendor Invoices with 3-way matching (PO vs GRN vs Invoice)
- Vendor Payments with approval workflow
- Return to Vendor (RTV) with stock reversal
- Supplier Price Quotes (RFQ/evaluation/comparison)
- Delivery Tracking and Reports
- Item price history tracking (automatic on invoice approval)
- Procurement Dashboard with KPIs

It does **NOT** directly manage:
- Purchase Orders (owned by Inventory module — Procurement creates POs via conversion)
- Inventory items/stock levels (Inventory module)
- Suppliers (Inventory module — referenced via FK)
- Warehouses (Inventory module)
- Accounting journal entries (Accounting module listens to events)

---

## 2. Directory & File Structure

```
app/Modules/Procurement/
├── bootstrap.php                              # Event listeners, permissions, sidebar menu
├── Routes.php                                 # Web routes (46 routes)
├── Controllers/
│   ├── ProcurementDashboardController.php    # Dashboard with KPIs
│   ├── RequisitionController.php             # PR CRUD + workflow
│   ├── GoodsReceivedNoteController.php       # GRN creation + inspection
│   ├── VendorInvoiceController.php           # Invoice CRUD + 3-way match
│   ├── VendorPaymentController.php           # Payment CRUD + approval
│   ├── ReturnToVendorController.php          # RTV CRUD + ship/complete
│   ├── QuoteController.php                   # RFQ, response, compare, evaluate
│   ├── DeliveryTrackingController.php        # Overdue deliveries, PO balance, price deviation
│   └── ProcurementReportController.php       # Purchase volume, supplier performance, match status
├── Models/
│   ├── PurchaseRequisition.php               # PR header model
│   ├── PurchaseRequisitionItem.php           # PR line items
│   ├── GoodsReceivedNote.php                 # GRN header model
│   ├── GrnItem.php                           # GRN line items
│   ├── VendorInvoice.php                     # Invoice header model
│   ├── VendorInvoiceItem.php                 # Invoice line items
│   ├── VendorPayment.php                     # Payment model
│   ├── ReturnToVendor.php                    # RTV header model
│   └── RtvItem.php                           # RTV line items
├── Services/
│   ├── RequisitionService.php                # PR lifecycle (create/update/submit/approve/reject/convert/cancel)
│   ├── GoodsReceivingService.php             # GRN creation + inspection + stock movement
│   ├── VendorInvoiceService.php              # Invoice lifecycle + approval
│   ├── VendorPaymentService.php              # Payment create/approve/complete/void
│   ├── ReturnToVendorService.php             # RTV lifecycle + stock reversal
│   ├── ThreeWayMatchService.php              # PO vs GRN vs Invoice matching
│   ├── QuoteService.php                      # RFQ creation, response recording, evaluation
│   ├── ItemPriceTrackingService.php          # Auto-records prices on invoice approval
│   ├── ProcurementNumberGenerator.php        # Sequential number generation (PR, GRN, Invoice, Payment, RTV)
│   └── SupplierPaymentScheduleService.php    # Payment scheduling for suppliers
└── Views/
    ├── dashboard/index.php
    ├── requisitions/
    │   ├── index.php, form.php, show.php
    ├── grn/
    │   ├── index.php, form.php, show.php, inspect.php
    ├── invoices/
    │   ├── index.php, form.php, show.php, match.php
    ├── payments/
    │   ├── index.php, form.php, show.php
    ├── rtv/
    │   ├── index.php, form.php, show.php
    ├── quotes/
    │   ├── index.php, compare.php
    └── reports/
        ├── match_status.php, overdue_deliveries.php, overdue_invoices.php
        ├── po_balance.php, purchase_volume.php, supplier_performance.php
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `purchase_requisitions` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| pr_number | varchar(50) | NO | UNI | | Sequential PR number |
| requested_by | bigint unsigned | NO | MUL | | FK to employees |
| department | varchar(100) | YES | | | |
| status | varchar(20) | NO | MUL | draft | draft/submitted/approved/rejected/converted/cancelled |
| urgency | enum | NO | | normal | low/normal/high/critical |
| required_date | date | YES | | | |
| justification | text | YES | | | |
| estimated_total | decimal(18,2) | NO | | 0.00 | Sum of line items |
| approved_by | bigint unsigned | YES | | | FK to employees |
| approved_at | datetime | YES | | | |
| rejected_by | bigint unsigned | YES | | | FK to employees |
| rejected_at | datetime | YES | | | |
| rejection_reason | text | YES | | | |
| converted_po_id | bigint unsigned | YES | | | FK to purchase_orders |
| notes | text | YES | | | |
| branch_id | bigint unsigned | YES | MUL | | FK to branches |
| is_archived | tinyint(1) | NO | | 0 | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |
| updated_at | datetime | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |

### 3.2 `purchase_requisition_items` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| requisition_id | bigint unsigned | NO | MUL | | FK to purchase_requisitions |
| item_id | bigint unsigned | YES | MUL | | FK to inventory_items (nullable for free-text) |
| description_ar | varchar(300) | YES | | | |
| description_en | varchar(300) | YES | | | |
| quantity | decimal(15,3) | NO | | | |
| unit_of_measure | varchar(50) | YES | | | |
| estimated_unit_cost | decimal(18,2) | YES | | | |
| estimated_line_total | decimal(18,2) | YES | | | |
| specifications | text | YES | | | |
| preferred_supplier_id | bigint unsigned | YES | | | FK to suppliers |

### 3.3 `goods_received_notes` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| grn_number | varchar(50) | NO | UNI | | Sequential GRN number |
| purchase_order_id | bigint unsigned | NO | MUL | | FK to purchase_orders |
| supplier_id | bigint unsigned | NO | MUL | | FK to suppliers |
| warehouse_id | bigint unsigned | NO | MUL | | FK to warehouses |
| received_by | bigint unsigned | YES | | | FK to employees |
| received_date | date | NO | | | |
| status | varchar(20) | NO | MUL | draft | draft/inspecting/accepted/partial_accept/cancelled |
| total_received_value | decimal(18,2) | NO | | 0.00 | |
| total_accepted_value | decimal(18,2) | NO | | 0.00 | |
| inspection_notes | text | YES | | | |
| notes | text | YES | | | |
| branch_id | bigint unsigned | YES | | | |
| is_archived | tinyint(1) | NO | | 0 | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |
| updated_at | datetime | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |

### 3.4 `grn_items` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| grn_id | bigint unsigned | NO | MUL | | FK to goods_received_notes |
| po_item_id | bigint unsigned | NO | MUL | | FK to purchase_order_items |
| item_id | bigint unsigned | NO | MUL | | FK to inventory_items |
| quantity_received | decimal(15,3) | NO | | | |
| quantity_accepted | decimal(15,3) | NO | | 0.000 | Set during inspection |
| quantity_rejected | decimal(15,3) | NO | | 0.000 | Set during inspection |
| rejection_reason | text | YES | | | |
| batch_number | varchar(100) | YES | | | For expiry-tracked items |
| expiry_date | date | YES | | | |
| unit_cost | decimal(18,2) | NO | | | From PO item |
| line_total | decimal(18,2) | NO | | | qty_received * unit_cost |
| notes | text | YES | | | |

### 3.5 `vendor_invoices` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| invoice_number | varchar(100) | NO | | | Supplier's invoice number |
| internal_number | varchar(50) | NO | UNI | | System-generated number |
| supplier_id | bigint unsigned | NO | MUL | | FK to suppliers |
| purchase_order_id | bigint unsigned | YES | MUL | | FK to purchase_orders |
| grn_id | bigint unsigned | YES | MUL | | FK to goods_received_notes |
| invoice_date | date | NO | | | |
| due_date | date | YES | MUL | | Payment due date |
| subtotal | decimal(18,2) | NO | | 0.00 | |
| tax_amount | decimal(18,2) | NO | | 0.00 | |
| discount_amount | decimal(18,2) | NO | | 0.00 | |
| total_amount | decimal(18,2) | NO | | 0.00 | |
| currency | varchar(3) | NO | | EGP | |
| status | varchar(20) | NO | MUL | draft | draft/verified/approved/partial_paid/paid/cancelled |
| match_status | varchar(20) | YES | MUL | | matched/tolerance_pass/discrepancy/unmatched |
| match_notes | text | YES | | | |
| journal_entry_id | bigint unsigned | YES | | | FK to journal_entries |
| ap_record_id | bigint unsigned | YES | | | FK to accounts_payable |
| notes | text | YES | | | |
| branch_id | bigint unsigned | YES | | | |
| is_archived | tinyint(1) | NO | | 0 | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |
| updated_at | datetime | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |

### 3.6 `vendor_invoice_items` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| invoice_id | bigint unsigned | NO | MUL | | FK to vendor_invoices |
| po_item_id | bigint unsigned | YES | | | FK to purchase_order_items |
| item_id | bigint unsigned | YES | MUL | | FK to inventory_items |
| description | varchar(300) | YES | | | |
| quantity | decimal(15,3) | NO | | | |
| unit_cost | decimal(18,2) | NO | | | |
| tax_rate | decimal(5,2) | NO | | 0.00 | |
| tax_amount | decimal(18,2) | NO | | 0.00 | |
| discount_amount | decimal(18,2) | NO | | 0.00 | |
| line_total | decimal(18,2) | NO | | | |

### 3.7 `vendor_payments` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| payment_number | varchar(50) | NO | UNI | | Sequential payment number |
| supplier_id | bigint unsigned | NO | MUL | | FK to suppliers |
| invoice_id | bigint unsigned | YES | MUL | | FK to vendor_invoices |
| amount | decimal(18,2) | NO | | | |
| currency | varchar(3) | NO | | EGP | |
| payment_method | varchar(30) | NO | | | bank_transfer/cash/check |
| bank_account_id | bigint unsigned | YES | | | FK to bank_accounts |
| check_number | varchar(50) | YES | | | |
| reference_number | varchar(100) | YES | | | |
| payment_date | date | NO | MUL | | |
| status | varchar(20) | NO | MUL | draft | draft/approved/completed/voided |
| approved_by | bigint unsigned | YES | | | |
| approved_at | datetime | YES | | | |
| completed_by | bigint unsigned | YES | | | |
| completed_at | datetime | YES | | | |
| voided_by | bigint unsigned | YES | | | |
| voided_at | datetime | YES | | | |
| void_reason | text | YES | | | |
| journal_entry_id | bigint unsigned | YES | | | FK to journal_entries |
| notes | text | YES | | | |
| branch_id | bigint unsigned | YES | | | |
| is_archived | tinyint(1) | NO | | 0 | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |
| updated_at | datetime | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |

### 3.8 `return_to_vendor` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| rtv_number | varchar(50) | NO | UNI | | Sequential RTV number |
| supplier_id | bigint unsigned | NO | MUL | | FK to suppliers |
| grn_id | bigint unsigned | YES | MUL | | FK to goods_received_notes |
| purchase_order_id | bigint unsigned | YES | | | FK to purchase_orders |
| warehouse_id | bigint unsigned | NO | MUL | | FK to warehouses |
| reason | text | NO | | | |
| return_date | date | NO | | | |
| status | varchar(20) | NO | MUL | draft | draft/submitted/approved/shipped/completed/cancelled |
| total_amount | decimal(18,2) | NO | | 0.00 | |
| debit_note_number | varchar(100) | YES | | | |
| debit_note_amount | decimal(18,2) | YES | | | |
| debit_note_date | date | YES | | | |
| journal_entry_id | bigint unsigned | YES | | | FK to journal_entries |
| approved_by | bigint unsigned | YES | | | |
| approved_at | datetime | YES | | | |
| shipped_by | bigint unsigned | YES | | | |
| shipped_at | datetime | YES | | | |
| completed_by | bigint unsigned | YES | | | |
| completed_at | datetime | YES | | | |
| notes | text | YES | | | |
| branch_id | bigint unsigned | YES | | | |
| is_archived | tinyint(1) | NO | | 0 | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |
| updated_at | datetime | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |

### 3.9 `rtv_items` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| rtv_id | bigint unsigned | NO | MUL | | FK to return_to_vendor |
| item_id | bigint unsigned | NO | MUL | | FK to inventory_items |
| grn_item_id | bigint unsigned | YES | MUL | | FK to grn_items |
| quantity | decimal(15,3) | NO | | | |
| unit_cost | decimal(18,2) | NO | | | |
| line_total | decimal(18,2) | NO | | | |
| batch_number | varchar(100) | YES | | | |
| reason | text | YES | | | |

### 3.10 `item_price_history` Table

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | int unsigned | NO | PRI | auto_increment | |
| item_id | int unsigned | NO | MUL | | FK to inventory_items |
| supplier_id | int unsigned | YES | MUL | | FK to suppliers |
| price_type | enum | NO | | purchase | purchase/sale/standard |
| price | decimal(15,2) | NO | | | |
| effective_date | date | NO | MUL | | |
| document_type | varchar(50) | YES | | | Source reference type |
| document_id | int unsigned | YES | | | Source reference ID |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | |

---

## 4. Core Business Flows

### 4.1 Purchase Requisition Lifecycle

```
draft → submitted → approved → converted (to PO)
                  → rejected
draft/submitted → cancelled
```

**Key rules:**
- Only editable in `draft` status
- Approval requires `procurement.pr.approve` permission
- Conversion requires an approved PR + supplier + warehouse selection
- Conversion calls `PurchaseOrderService::createPO()` from Inventory module

### 4.2 Goods Receiving Lifecycle

```
PO (approved/partially_received) → Create GRN → draft/inspecting → accepted/partial_accept
                                                                   → cancelled
```

**On inspection (accept):**
- Sets `quantity_accepted` / `quantity_rejected` for each item
- Creates inventory batch (if expiry-tracked item)
- Calls `StockService::moveStock()` (movement_type='purchase_in')
- Updates PO item `quantity_received`
- Updates PO status (approved → partially_received → received)

### 4.3 Three-Way Match (PO vs GRN vs Invoice)

```
Invoice → ThreeWayMatchService::performMatch()
  - Compares: invoice qty/cost vs PO qty/cost vs GRN accepted qty
  - Uses tolerance percentage from system_config (default 2%)
  - Results: matched / tolerance_pass / discrepancy
  - Writes match_status + match_notes to vendor_invoices
```

### 4.4 Vendor Payment Lifecycle

```
draft → approved → completed → (done)
draft/approved/completed → voided
```

**On completion:**
- Updates linked invoice status (partial_paid / paid)
- Dispatches `procurement.payment_completed` (Accounting posts journal entry)

**On void:**
- Re-evaluates linked invoice status
- Dispatches `procurement.payment_voided` (Accounting reverses journal entry)

### 4.5 Return to Vendor Lifecycle

```
draft → submitted → approved → shipped → completed
draft/submitted → cancelled
```

**On completion:**
- Calls `StockService::moveStock()` for each item (movement_type='return_out', direction='out')
- Dispatches `procurement.rtv_completed` (Accounting posts journal entry)

### 4.6 Supplier Quote Flow

```
Create RFQ → Record supplier responses → Compare quotes → Evaluate → Convert winning quote to PO
```

---

## 5. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `procurement.requisition_submitted` | `{pr_id}` | RequisitionService::submitPR |
| `procurement.requisition_approved` | `{pr_id}` | RequisitionService::approvePR |
| `procurement.requisition_converted` | `{pr_id, po_id}` | RequisitionService::convertToPO |
| `procurement.grn_completed` | `{grn_id, po_id, supplier_id, warehouse_id, status}` | GoodsReceivingService::inspectGRN |
| `procurement.invoice_approved` | `{invoice_id, supplier_id, ...}` | VendorInvoiceService |
| `procurement.payment_completed` | `{payment_id, supplier_id, invoice_id, amount, payment_number, payment_method, bank_account_id}` | VendorPaymentService::completePayment |
| `procurement.payment_voided` | `{payment_id, supplier_id, invoice_id, amount, reason, journal_entry_id}` | VendorPaymentService::voidPayment |
| `procurement.rtv_completed` | `{rtv_id, supplier_id, total_amount, warehouse_id}` | ReturnToVendorService::completeRTV |
| `inventory.po_received` | `{po_id, warehouse_id, status}` | GoodsReceivingService::inspectGRN (legacy) |
| `quote.requested` | `{...}` | QuoteService |
| `quote.evaluated` | `{...}` | QuoteService |
| `purchase_order.created` | `{po_id, from_evaluation}` | QuoteService (via conversion) |
| `supplier_schedule.created` | `{...}` | SupplierPaymentScheduleService |

---

## 6. Events Consumed (self + other modules)

| Event | Listener | Action |
|-------|----------|--------|
| `procurement.invoice_approved` | ItemPriceTrackingService (self) | Records item prices to `item_price_history` |
| `procurement.invoice_approved` | Accounting | Auto-post AP journal entry |
| `procurement.payment_completed` | Accounting | Auto-post payment journal entry |
| `procurement.payment_voided` | Accounting | Reverse payment journal entry |
| `procurement.rtv_completed` | Accounting | Auto-post RTV journal entry |

---

## 7. Cross-Module Dependencies

### 7.1 Procurement module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Inventory | `InventoryItem::allActive()`, `Supplier::allActive()`, `Warehouse` model |
| Inventory | `StockService::moveStock()` (GRN acceptance + RTV completion) |
| Inventory | `BatchService::createBatch()` (expiry-tracked items on GRN) |
| Inventory | `PurchaseOrderService::createPO()` (PR-to-PO conversion) |
| Inventory | `CreditLimitService` (vendor invoice validation) |

### 7.2 Other modules that IMPORT FROM Procurement:

| Module | What it imports |
|--------|----------------|
| Accounting | Listens to 4 procurement events for journal entries |
| Procurement (self) | ItemPriceTrackingService listens to `procurement.invoice_approved` |

---

## 8. Permissions

| Key | Description |
|-----|-------------|
| procurement.dashboard | Access dashboard |
| procurement.pr.view | View purchase requisitions |
| procurement.pr.create | Create/edit/cancel PRs |
| procurement.pr.approve | Approve/reject PRs |
| procurement.pr.convert | Convert PR to PO |
| procurement.grn.view | View goods received notes |
| procurement.grn.create | Create/cancel GRNs |
| procurement.grn.inspect | Inspect and accept/reject GRN items |
| procurement.invoice.view | View vendor invoices |
| procurement.invoice.create | Create/edit/verify invoices |
| procurement.invoice.approve | Approve/cancel invoices |
| procurement.payment.view | View vendor payments |
| procurement.payment.create | Create payments |
| procurement.payment.approve | Approve/complete/void payments |
| procurement.rtv.view | View returns to vendor |
| procurement.rtv.create | Create/edit/submit/cancel RTVs |
| procurement.rtv.approve | Approve RTVs |
| procurement.rtv.manage | Ship and complete RTVs |
| procurement.report | Access procurement reports |
| procurement.quote.view | View supplier quotes |
| procurement.quote.create | Create RFQ and record responses |
| procurement.quote.evaluate | Evaluate and compare quotes |

---

## 9. Sidebar Menu

Registered with `order: 410`, icon `shopping-cart`:
- Dashboard (`/procurement`)
- Purchase Requisitions (`/procurement/requisitions`)
- Goods Received Notes (`/procurement/grn`)
- Vendor Invoices (`/procurement/invoices`)
- Vendor Payments (`/procurement/payments`)
- Returns to Vendor (`/procurement/rtv`)
- Supplier Quotes (`/procurement/quotes`)
- Delivery Tracking (`/procurement/reports/overdue-deliveries`)
- PO Balance (`/procurement/reports/po-balance`)
- Procurement Reports (`/procurement/reports/purchase-volume`)

---

## 10. High-Risk Areas

### 10.1 GoodsReceivingService::inspectGRN (CRITICAL)
- Creates stock movements (inventory increase) — irreversible without RTV
- Creates batches for expiry-tracked items
- Updates PO status based on overall item receipt
- Runs in a DB transaction — partial failure rolls back all stock movements
- If stock movement logic is wrong, inventory counts will be incorrect

### 10.2 Three-Way Match
- Tolerance percentage from system_config (`procurement.match_tolerance_pct`, default 2%)
- If tolerance is too high, discrepancies slip through
- If tolerance is too low, legitimate invoices get flagged
- Match result is written directly to `vendor_invoices` table

### 10.3 VendorPaymentService — Invoice Status Updates
- On payment completion, recalculates total paid against invoice
- On void, re-evaluates invoice status (could revert from 'paid' to 'partial_paid')
- Multiple payments can reference the same invoice — race condition possible

### 10.4 PR-to-PO Conversion
- Crosses module boundary: calls `Inventory\Services\PurchaseOrderService::createPO()`
- Skips free-text items (no `item_id`) during conversion
- If PurchaseOrderService changes its interface, conversion will break

### 10.5 ReturnToVendorService — Stock Reversal
- On RTV completion, moves stock OUT via StockService
- If stock is insufficient, StockService may throw (depends on implementation)
- No check that returned quantity <= originally received quantity at service level

### 10.6 Number Generation
- `ProcurementNumberGenerator` generates sequential numbers for PR, GRN, Invoice, Payment, RTV
- Based on MAX() queries — concurrent creation race conditions possible

---

## 11. Configuration Dependencies

| Config Key (system_config) | Purpose |
|---------------------------|---------|
| procurement.match_tolerance_pct | 3-way match tolerance percentage (default: 2.0%) |

---

## 12. Known Patterns & Gotchas

1. **Heavy use of DB transactions**: All multi-table operations (PR create, GRN inspect, RTV complete) use `beginTransaction/commit/rollBack`
2. **bcmath everywhere**: All money calculations use `bcadd`, `bcmul`, `bcsub`, `bcdiv`, `bccomp` with explicit precision
3. **Free-text items allowed in PR**: `item_id` is nullable — items without inventory mapping are skipped during PO conversion
4. **Legacy event compatibility**: `inventory.po_received` is fired alongside `procurement.grn_completed` for backward compat
5. **Self-listening pattern**: `ItemPriceTrackingService::registerListeners()` is called in bootstrap.php — the module listens to its own events
6. **No soft delete on line-item tables**: `grn_items`, `rtv_items`, `purchase_requisition_items`, `vendor_invoice_items` have no `is_archived`
7. **PR items are DELETE+INSERT on update**: When a PR is updated, all items are deleted and re-inserted (not individually updated)
8. **Invoice has dual number**: `invoice_number` (supplier's) + `internal_number` (system-generated, unique)
9. **journal_entry_id on invoices/payments/RTVs**: Stores the FK to the Accounting journal entry created by event listeners
10. **Debit note fields on RTV**: `debit_note_number/amount/date` are stored on the RTV for the credit/debit note issued to supplier
