# TradiXor — Phased Implementation Plan

**Audience:** Engineers | **Date:** May 2026

Each phase lists: prerequisites, backend deliverables, frontend deliverables, artisan commands, and test requirements. Run `vendor/bin/pint --dirty` and `php artisan test --compact` before closing any phase.

---

## Foundation ✅ Complete

Everything listed in Developer PRD §17 is done. Phases 1–4 are also complete — start planning at Phase 5.

---

## Phase 1 — Organization Onboarding & Configuration ✅ Complete

**Prerequisites:** None (foundation complete)

**Goal:** A new tenant org can register, complete setup, and configure all system settings before any operational module is used.

---

### Backend

**Controllers (`app/Http/Controllers/System/`) — all follow `index / store / update / toggleStatus / destroy` pattern:**

| Controller | Notes |
|---|---|
| `OrganizationController` | Admin org only; calls `OrganizationSeederService::seedForOrganization()` on creation |
| `CountryController` | Admin org only (mutations); index is open |
| `CurrencyController` | Admin org only; extra `toggleSystemDefault()` method |
| `UnitOfMeasureController` | Admin org only |
| `DesignationController` | Org-scoped |
| `DepartmentController` | Org-scoped |
| `ProductLocationController` | Org-scoped; type: warehouse or outlet |
| `ShippingAgentController` | Org-scoped |
| `ClearingForwardingAgentController` | Org-scoped |
| `HaulageCompanyController` | Org-scoped |
| `InsuranceCompanyController` | Org-scoped |
| `ShippingPortController` | Admin org only (mutations); index open |

**Auth pattern:** `authOwnsModel()` for org-scoped resources; `authIsAdminOrg()` for platform-wide resources (countries, currencies, UOMs, shipping ports, organizations).

**`OrganizationSeederService::seedForOrganization(Organization $org)`:**
- Copies all platform UOMs (`organization_id=1`) into the new org
- Copies platform COA accounts (`is_system=true`) into the new org
- Creates fiscal periods for the current fiscal year

**`SysGeneratorService`** (`app/Services/System/SysGeneratorService.php`) — 36 static methods covering all phases. Two reference-code patterns:
- **Microtime-based** for internal platform codes (ORG, DEPT, POS, USR, PROD, etc.) — fast, no DB round-trip
- **Timestamp + 4 random chars** for business documents (TRIP-260420051530K7XP format) — unguessable, collision-resistant

**Routes** (`routes/system.php`) — 13 resource groups:
countries, organizations, units-of-measure, currencies, designations, product-locations, departments, shipping-agents, clearing-forwarding-agents, haulage-companies, insurance-companies, shipping-ports, (product-categories and products in same file — see Phase 2)

---

### Frontend (`resources/js/pages/System/`)

24 Vue files across 12 subfolders, each following the same Page + FormDialog pattern:

| Subfolder | Files |
|---|---|
| `Organizations/` | OrganizationsPage.vue, OrganizationFormDialog.vue |
| `Countries/` | CountriesPage.vue, CountryFormDialog.vue, CreateCountry.vue |
| `Currencies/` | CurrenciesPage.vue, CurrencyFormDialog.vue |
| `Designations/` | DesignationsPage.vue, DesignationFormDialog.vue |
| `UnitsOfMeasure/` | UnitsOfMeasurePage.vue, UnitOfMeasureFormDialog.vue |
| `Departments/` | DepartmentsPage.vue, DepartmentFormDialog.vue |
| `ProductLocations/` | ProductLocationsPage.vue, ProductLocationFormDialog.vue |
| `ShippingAgents/` | ShippingAgentsPage.vue, ShippingAgentFormDialog.vue |
| `ClearingForwardingAgents/` | ClearingForwardingAgentsPage.vue, ClearingForwardingAgentFormDialog.vue |
| `HaulageCompanies/` | HaulageCompaniesPage.vue, HaulageCompanyFormDialog.vue |
| `InsuranceCompanies/` | InsuranceCompaniesPage.vue, InsuranceCompanyFormDialog.vue |
| `ShippingPorts/` | ShippingPortsPage.vue, ShippingPortFormDialog.vue |

---

### Tests

```bash
php artisan make:test Feature/System/ProductLocationTest
php artisan make:test Feature/System/DepartmentTest
php artisan make:test Feature/System/OrganizationSeederServiceTest
```

---

## Phase 2 — Product Catalogue ✅ Complete

**Prerequisites:** Phase 1

---

### Backend

**`ProductCategoryController`** (`app/Http/Controllers/System/ProductCategoryController.php`):
- `index` — lists categories with parent relationship and product count; analytics (total, active, inactive, root categories)
- `store` — auto-generates slug; validates parent belongs to same org
- `update` — regenerates slug dynamically
- `toggleStatus`, `destroy` — destroy guards against sub-categories and attached products

**`ProductController`** (`app/Http/Controllers/System/ProductController.php`) — 10 methods:
- `index` — paginated list with tile/table view; analytics (total, active, inactive, categories count, serialised count); filter by category, status, search
- `store` — auto-generates SKU via `SysGeneratorService::productCode()` if not provided; forces `is_serialised = true` for non-raw-material product types
- `show` — loads media (thumbnail + photos); passes `pricingHistory` and `has_serial_numbers` to Inertia
- `update` — SKU auto-generation fallback; forces `is_serialised = true` for non-raw-materials; blocks disabling serialisation if product has existing serial numbers; calls `ProductPricingService::setPricing()` only when pricing fields actually changed (`hasPricingChanged()`)
- `toggleStatus`, `destroy`
- `storePhoto` / `destroyPhoto` — Spatie `product_photos` collection (max 5 MB, jpg/png/webp)
- `storeThumbnail` / `destroyThumbnail` — Spatie `product_thumbnail` single-file collection

**`ProductPricingService` (`app/Services/System/ProductPricingService.php`):**
- `setPricing(Product, array, string $reason): Product` — computes `selling_price`, `outlet_price`, `discount_cap` from the chosen methods; nullifies unused method fields; writes a `product_pricing_history` record on every call
- `previewPricing(float $landedCost, array): array` — returns calculated prices for live UI preview without persisting

**`product_pricing_history` table + `ProductPricingHistory` model (`app/Models/System/ProductPricingHistory.php`):**
- Records every product pricing change: full snapshot of all pricing fields + `reason` (required; 19 predefined options matching the same list as lot pricing) + `changed_by` + `changed_at`
- Displayed in the "Pricing History" section of `ShowProduct.vue`

**`Product::pricingSnapshot(float $landedCost): array`** (`app/Models/System/Product.php`):
- Returns the product's current pricing fields as an array for freezing on a new `InventoryLot`
- Auto-corrects `discount_cap` to `$landedCost` when the product's cap is below the lot's actual unit cost
- Called by all five lot-creation paths (shipment, opening stock, GRN posting, production completion, transfer) to stamp pricing onto each lot at creation time

**`discount_cap`** is a REQUIRED field on products (validated in `StoreProductRequest` and `UpdateProductRequest`).

**`is_serialised` enforcement:**
- Non-raw-material products (`finished_good`, `both`) are always `is_serialised = true` — enforced server-side in `ProductController::store()`, `update()`, and `ProductCatalogueImport::createProduct()`
- Cannot be set to `false` on update if the product already has serial numbers assigned (`$product->serialNumbers()->exists()` guard)
- `ProductFormDialog.vue` disables the toggle with a descriptive message for non-raw-materials and products with existing serials

**`sold_price` + `sold_at`** added to `serial_numbers` table (prep for Phase 11 sale recording at the serial level).

**Product import template (`ProductCatalogueTemplate.php`)** — restructured with 4 colour-coded tiers:
- 🔴 Required (A–I): `name`, `category_name`, `product_type`, `uom`, `costing_method`, `landed_cost_per_unit`, `selling_price_method`, `outlet_price_method`, `discount_cap`
- 🟡 Conditional (J–N): pricing method inputs (`selling_price`, `margin_pct`, `markup_amount`, `outlet_price_fixed_above`, `outlet_price_pct`)
- 🟢 Optional (O–S): `sku`, `barcode`, `variant_label`, `description`, `is_serialised`
- ⚪ Optional (T–Y): physical dimensions + stock threshold fields
- **`category_name`** (not `category_code`) — product category lookup is now by case-insensitive name; `product_sku` available as optional disambiguation
- `ProductCatalogueImport.php` — category cache keyed by `strtolower(trim(name))`; validates `discount_cap` as required

Both controllers share the same `routes/system.php` file under `/product-categories` and `/products`.

---

### Frontend (`resources/js/pages/System/`)

**`ProductCategories/`:**
- `ProductCategoriesPage.vue` — filterable list with parent/child display, DataTable, analytics cards

**`Products/`:**
- `ProductsPage.vue` — **toggle between table view and tile view** (stored in localStorage); tile view groups products by category with debounced server-side search; 9 table columns including variant_label, minimum_stock_level, reorder_quantity, is_serialised; 4 analytics cards
- `ShowProduct.vue` — banner with thumbnail (click to replace), product info, edit modal; horizontal photo carousel with prev/next arrows; lightbox with full-size image, prev/next nav, thumbnail strip, keyboard navigation; **Pricing History** section (table: Date | Method | Selling Price | Outlet Price | Discount Cap | Reason | Changed By)
- `ProductFormDialog.vue` — create/edit modal; UoM dropdown; **reason selector** in edit mode (shown via `isPricingDirty` computed only when pricing fields actually changed); `is_serialised` toggle auto-locked to `true` for non-raw-materials with descriptive hint; further locked if product already has serial numbers
- `ProductCard.vue` — tile view card component

---

### Tests

```bash
php artisan make:test Feature/Catalogue/ProductCategoryTest
php artisan make:test Feature/Catalogue/ProductTest
```

---

## Phase 3 — Supplier & Customer Registry ✅ Complete

**Prerequisites:** Phase 1

---

### Backend

**`SupplierController`** (`app/Http/Controllers/Procurement/SupplierController.php`):
- `index` — paginated list; analytics (total, international, local, active); filters by is_local, country, status
- `store`, `update`, `toggleStatus`, `destroy`

**`CustomerController`** (`app/Http/Controllers/Sales/CustomerController.php`):
- `index` — paginated list; analytics (total, active, inactive, countries_represented, no_contact); filters by status, country
- `store`, `update`, `toggleStatus`, `destroy`

**Encrypted field:** `Customer::id_number` uses the `encrypted` cast — Eloquent handles encrypt/decrypt transparently; stored in a `TEXT` column (cipher output is ~4× plaintext length, exceeds VARCHAR(100)).

**Namespace note:** Both models live in `App\Models\System\` but controllers are domain-organized — `Procurement\SupplierController` and `Sales\CustomerController`.

---

### Frontend

**`resources/js/pages/Procurement/Suppliers/`:**
- `SuppliersPage.vue` — DataTable with filter by type (international/local), country, status; analytics cards; country filter uses searchable select
- `SupplierFormDialog.vue`

**`resources/js/pages/Sales/Customers/`:**
- `CustomersPage.vue` — DataTable with filter by status, country; analytics cards
- `CustomerFormDialog.vue`

---

### Tests

```bash
php artisan make:test Feature/Registry/SupplierTest
php artisan make:test Feature/Registry/CustomerTest
```

---

## Phase 4 — International Procurement ✅ Complete

**Prerequisites:** Phases 2 + 3

---

### Database (`0000_01_01_000003_create_international_procurement_tables.php`)

| Table | Key design notes |
|---|---|
| `sourcing_trips` | `source_currency_code` + `exchange_rate` **required**; defines single trading currency inherited by all items/PIs/payments. `estimated_total_expenditure` (user-entered budget in source CCY), `estimated_total_expenditure_base` (server-computed). Status enum: draft → in_progress → sourcing_complete → shipped → closed; sourcing_complete can revert to in_progress. |
| `sourcing_trip_items` | Four estimated-price columns: `estimated_unit_source_price` / `estimated_total_source_cost` (user-entered, source CCY) and `estimated_unit_price_base` / `estimated_total_cost_base` (server-computed, base CCY). `source_currency_code` and `exchange_rate` are **inherited from the trip** and enforced by the backend on every save. `unit_source_price` entered in source CCY; base equivalents server-computed. |
| `proforma_invoices` | `amount_paid` tracked in **PI's own currency** (not base currency). Status: draft → sent → confirmed → **paid** (auto-set when amount_paid ≥ total_amount) or → expired/cancelled. Confirmed and paid PIs cannot be edited. |
| `proforma_invoice_items` | `sourcing_trip_item_id` back-links to the sourcing trip item; auto-populated when generating from a trip. |
| `supplier_payments` | `amount` in `payment_currency_code`; `amount_base` (server-computed, accounting only). Payments on a `paid` PI cannot be deleted. |

---

### Backend

**Controllers (`app/Http/Controllers/Procurement/`):**

| Controller | Key methods |
|---|---|
| `ProcurementDashboardController` | `__invoke()` |
| `SourcingTripController` | `index`, `store`, `show`, `update`, `updateStatus`, `generatePI`, `destroy` |
| `SourcingTripItemController` | `store`, `update`, `bulkAssignSupplier`, `destroy` |
| `ProformaInvoiceController` | `index`, `store`, `show`, `update`, `updateStatus`, `destroy` |
| `ProformaInvoiceItemController` | `store`, `update`, `destroy` |
| `SupplierPaymentController` | `index`, `store`, `show`, `destroy` |
| `SupplierController` | `index`, `store`, `update`, `toggleStatus`, `destroy` (shared with Phase 3) |

**Services (`app/Services/Procurement/`):**

`SourcingTripService` (all static):
- `generateReference(): string` — uses `SysGeneratorService` (timestamp + random, not sequential)
- `canTransitionTo(SourcingTrip, string): bool`
- `allowedNextStatuses(SourcingTrip): array`
- `validateSourcingComplete(SourcingTrip): array` — blocks if items still 'planned' or missing supplier/price
- `transitionStatus(SourcingTrip, string $newStatus, array $extra): void` — accepts `actual_departure` (on → in_progress) and `return_date` (on → sourcing_complete)
- `totalSourceCostBase(SourcingTrip): float`

`SupplierPaymentService`:
- `recordPayment(array $data): SupplierPayment` — increments PI `amount_paid` by `payment.amount` (not `amount_base`); auto-transitions PI to `paid` when `amount_paid >= total_amount`

**Form Requests (`app/Http/Requests/Procurement/`):**
`StoreSourcingTripRequest`, `UpdateSourcingTripRequest`, `StoreSourcingTripItemRequest`, `UpdateSourcingTripItemRequest`, `StoreProformaInvoiceRequest`, `UpdateProformaInvoiceRequest`, `StoreProformaInvoiceItemRequest`, `StoreSupplierPaymentRequest`, `GenerateTripPIRequest`

**Status transitions:**
```
draft ──────────────→ in_progress ──→ sourcing_complete ──→ shipped ──→ closed
                                           │
                                           └──→ in_progress  (revert allowed)
```
Transition to `in_progress` captures `actual_departure`; to `sourcing_complete` captures `return_date`.
Cannot mark sourcing_complete if any item is 'planned' or missing supplier + source price.

**PI item auto-population:** When `ProformaInvoiceController::store()` receives both `sourcing_trip_id` and `supplier_id`, it auto-imports matching trip items (quantities, prices, UOM, notes) into `proforma_invoice_items`.

---

### Frontend (`resources/js/pages/Procurement/`)

**Sourcing Trips (`SourcingTrips/`):**
- `TripsPage.vue` — server-paginated table; 10 columns including type badge, Currency/Rate, Est. Expenditure (amber), Actual Expenditure (computed from items aggregate ÷ exchange rate); 5 summary cards: Total, Draft, In Progress (blue), Sourcing Complete (purple), Shipped (amber)
- `ShowTrip.vue` — trip banner with cost summary (budget vs. item estimates vs. actual, all in source CCY); DataTable for items (search, sort, pagination, export, column visibility); selectable rows when in_progress for bulk supplier assignment; transition dialogs capturing actual_departure / return_date; Supplier Summary section (post-sourcing) showing amount due per supplier with Generate PI button or existing PI link
- `SourcingTripFormDialog.vue` — create/edit; `source_currency_code` + `exchange_rate` required; `estimated_total_expenditure` optional budget; planning fields locked once trip leaves draft
- `SourcingTripItemDialog.vue` — currency/rate display inherited from trip (read-only); `estimated_unit_source_price` in source CCY with live total preview; `unit_source_price` in source CCY; sourced quantity auto-sets status
- `BulkSupplierDialog.vue` — assigns a supplier to multiple selected items in one action
- `GenerateTripPIDialog.vue` — pre-fills supplier, currency (locked to trip CCY), exchange rate, and items from trip; shows read-only items table with grand total; optional payment section (amount auto-fills with group total; bank account, method, reference); submits to `generatePI` endpoint

**Proforma Invoices (`ProformaInvoices/`):**
- `ProformaInvoicesPage.vue` — server-paginated; analytics cards (total, draft, confirmed, pending payment); filter by status (including 'paid'), supplier, sourcing trip
- `ShowProformaInvoice.vue` — DataTable for line items; payment history with view links; Edit button hidden for confirmed/paid/expired/cancelled; Record Payment button only on confirmed; payments non-deletable when PI is 'paid'
- `ProformaInvoiceFormDialog.vue`, `ProformaInvoiceItemDialog.vue`
- `RecordPaymentDialog.vue` — payment amount in PI currency (labeled); exchange rate editable; bank account selector

**Supplier Payments (`SupplierPayments/`):**
- `SupplierPaymentsPage.vue` — server-paginated; View + Delete actions; bank account on record payment dialog; filter by supplier, payment type
- `ShowSupplierPayment.vue` — full details: amount, exchange rate, base equivalent, method, bank account, linked PI link, external reference, notes
- `RecordSupplierPaymentDialog.vue` — direct/unlinked payments; bank account selector; exchange rate labeled as overridable

---

### Key Business Rules Enforced

| Rule | Where |
|---|---|
| Single source currency per trip — all items/PIs/payments inherit it | `SourcingTripItemController` overrides currency on every save |
| Cannot mark sourcing complete without all items assigned a supplier + source price | `SourcingTripService::validateSourcingComplete()` |
| Remove action hidden for sourced/partially_sourced items | DataTable `condition` on Remove action |
| PI editing blocked once confirmed or paid | `canEdit` computed + controller guard |
| PI auto-transitions to 'paid' when fully settled | `SupplierPaymentService::recordPayment()` |
| Payment deletion blocked on a 'paid' PI | `SupplierPaymentController::destroy()` |
| `amount_paid` tracked in PI currency, not base currency | Fixed bug; `recordPayment` increments by `amount`, not `amount_base` |

---

### Additional Phase 4 Deliverables (added post-initial completion)

| Feature | Files |
|---|---|
| **Trip Members** | `sourcing_trip_members` migration; `SourcingTripMember` model; `SourcingTripMemberController` (14 predefined roles); `AddTripMemberDialog.vue`; team chips in ShowTrip.vue banner |
| **Bulk Sourcing** | `SourcingTripItemController::bulkSource()`; `BulkSourceDialog.vue` — enter sourced qty + price for N items in one modal; per-item "use estimate" checkbox; "apply estimates to all" shortcut |
| **Item Guards** | `update()` and `destroy()` blocked when item has `shipmentItems`; `bulkAssignSupplier()` blocked when items have `proformaInvoiceItems`; `update()` blocks supplier change if item has PI |
| **Country-of-Origin Filter** | `destination_countries` passed to `CreateShipmentDialog`; `selectedOriginCountry` filters origin port dropdown |
| **Sourcing Trip Import** | `SourcingTripItemController::previewImport()` + `processImport()`; `SourcingTripItemTemplate` Excel export; `SourcingTripItemImport`; `SourcingTripItemImportDialog.vue` |
| **Procurement Documents** | `SourcingTripDocumentController` (T-1 Overview, T-2 Sourcing Report); `ProformaInvoiceDocumentController` (T-3 PI); Blade views under `resources/views/documents/procurement/`; `DocumentsDropdown.vue` in ShowTrip |
| **Procurement Dashboard** | `ProcurementDashboardController` fully rewritten with Inertia v3 deferred props; 8 immediate KPIs + 5 deferred groups (`tripPipeline`, `shipmentPipeline`, `monthlyTrends`, `topSuppliers`, `recentActivity`); `Procurement/Dashboard.vue` with 5 tabs, KPI rows, Chart.js charts, deferred skeletons |

### Tests Written (Phase 4 additions)

```bash
tests/Feature/Procurement/SourcingTripMemberTest.php     # 7 tests
tests/Feature/Procurement/SourcingTripItemShippingGuardTest.php  # 5 tests
tests/Feature/Procurement/SourcingTripItemPIGuardTest.php        # 5 tests
tests/Feature/Procurement/SourcingTripItemBulkSourceTest.php     # 6 tests
```

---

### Tests (original)

```bash
php artisan make:test Feature/Procurement/SourcingTripTest
php artisan make:test Feature/Procurement/ProformaInvoiceTest
php artisan make:test Feature/Procurement/SupplierPaymentTest
```

---

## Phase 5 — Logistics & Shipping ✅ Complete

**Prerequisites:** Phase 4

---

### Database (`0000_01_01_000004_create_logistics_and_shipping_tables.php`)

| Table | Key design notes |
|---|---|
| `shipments` | `shipment_reference` auto-generated; `mode` enum: sea/air/road/rail/multimodal; freight + insurance costs in source currency with base equivalents; status: pending → in_transit → arrived → cleared → delivered; `cost_allocation_locked` boolean prevents re-allocation after locking |
| `shipment_items` | Links `sourcing_trip_items` to a shipment; unique per (shipment, trip_item); `quantity_shipped` integer |
| `shipment_events` | Immutable event log; `event_type` enum; `event_date` datetime; `port_code` string; auto-updates `shipment.actual_departure` on vessel_departed, `shipment.actual_arrival` on destination_arrived |
| `shipping_documents` | Metadata per document type (bill_of_lading, packing_list, commercial_invoice, certificate_of_origin, insurance_certificate, customs_declaration, other); files via Spatie `shipping_documents` collection |
| `tax_clearance_records` | Auto-created on shipment creation; tracks tax assessed/paid and CFA service fee; status: pending → assessed → paid → cleared |
| `haulage_records` | One or more per shipment; records driver, vehicle, route (ICD → warehouse), departure/arrival dates, haulage cost; status: pending → in_transit → delivered |

---

### Backend

**Controllers (`app/Http/Controllers/Logistics/`):**

| Controller | Methods |
|---|---|
| `ShipmentController` | `index`, `store`, `show`, `update`, `updateStatus`, `destroy` |
| `ShipmentEventController` | `store` |
| `ShipmentItemController` | `store`, `update`, `destroy` |
| `ShippingDocumentController` | `store`, `destroy` |
| `TaxClearanceRecordController` | `show`, `store`, `update` |
| `HaulageRecordController` | `store`, `update`, `destroy`, `markDelivered` |

**`ShipmentService` (`app/Services/Logistics/ShipmentService.php`):**
- `createShipment(SourcingTrip, array $data): Shipment` — creates shipment, auto-creates `TaxClearanceRecord`, advances sourcing trip to `shipped`
- `addItem(Shipment, SourcingTripItem, int $qty): ShipmentItem` — validates qty ≤ `shippable_quantity`; decrements `shippable_quantity` on trip item
- `removeItem(Shipment, ShipmentItem): void` — increments `shippable_quantity` back; reverts trip to `in_progress` if no items remain
- `recordEvent(Shipment, string $eventType, array $data): ShipmentEvent` — captures milestone dates; `vessel_departed` → sets `actual_departure`; `destination_arrived` → sets `actual_arrival`
- `canTransitionTo(Shipment, string $status): bool` — validates transition rules
- `transitionStatus(Shipment, string $newStatus): void` — enforces: cannot deliver without cleared tax clearance; haulage is optional

**Key business rules enforced:**

| Rule | Where |
|---|---|
| Cannot deliver without `taxClearanceRecord.status = 'cleared'` | `ShipmentService::transitionStatus()` |
| `shippable_quantity` decremented/incremented on item add/remove | `ShipmentService::addItem()` / `removeItem()` |
| Haulage optional — delivery proceeds without haulage records | `ShipmentService::transitionStatus()` |
| Sourcing trip items with shipment items locked from edit/delete | `SourcingTripItemController::update()` / `destroy()` |
| `cost_allocation_locked` prevents re-allocation | `ShipmentController::show()` passes flag to frontend |
| Shipments can be created while trip is `in_progress` (multi-country) | `StoreShipmentRequest` allows in_progress status |
| Trip auto-advances to `shipped` when first shipment created | `ShipmentService::createShipment()` |
| Trip auto-advances to `shipped` when marking sourcing_complete with existing shipments | `SourcingTripService::transitionStatus()` |

---

### Frontend (`resources/js/pages/Logistics/Shipments/`)

| File | Purpose |
|---|---|
| `ShipmentsPage.vue` | Server-paginated list; analytics cards (total, pending, in_transit, delivered); status + mode filters |
| `ShowShipment.vue` | Tabs: Overview (freight/insurance details + inline edit form), Items, Events, Tax Clearance, Haulage, Cost Allocation, Cost Summary |
| `CreateShipmentDialog.vue` | Create dialog; country-of-origin selector (filtered to trip's destination_countries); origin port filtered by selected country |
| `AddShipmentItemDialog.vue` | Add individual trip item; quantity validated against `shippable_quantity` |
| `BulkAddItemsDialog.vue` | Multi-select trip items with quantity entry per row |
| `RecordEventDialog.vue` | Event type select with auto-suggestion of next logical event |
| `TaxClearanceForm.vue` | Inline form for tax assessed/paid amounts, CFA fees, clearance date |
| `HaulageRecordDialog.vue` | Create/edit haulage record; haulage company, driver, vehicle, route, dates |
| `AllocationDialog.vue` | Cost allocation per type: method selector (equal/by_value/by_weight/by_volume/manual), preview table, confirm |

**`DocumentsDropdown.vue`** — added to `ShowShipment.vue` action bar; provides View + PDF download links for all 6 shipment documents.

---

### Document Suite (`resources/views/documents/procurement/shipments/`)

| Document | Trigger | PDF? |
|---|---|---|
| S-1 Shipment Manifest | Any status | ✓ |
| S-2 Packing / Loading List | Any status | ✓ (includes signature block) |
| S-3 Shipment Event Log | Any status | — |
| S-4 Customs Clearance Summary | arrived+ | ✓ |
| S-5 Haulage / Delivery Note | Haulage exists | ✓ (includes dual signature block) |
| S-6 Landed Cost Report | delivered | — |

All served via `ShipmentDocumentController` (`app/Http/Controllers/Procurement/Documents/`). Both browser-view and DomPDF download routes from the same Blade template.

---

### Tests

No dedicated Logistics test files written yet. The following tests cover cross-phase behaviour:

```
tests/Feature/Procurement/SourcingTripItemShippingGuardTest.php — 5 tests (items locked when shipped)
```

---

## Phase 6 — Costing & Landed Cost Engine ✅ Complete

**Prerequisites:** Phase 5

---

### Database (added to `0000_01_01_000004`)

| Table | Key design notes |
|---|---|
| `cost_allocations` | One row per (shipment_item, allocation_type); `allocation_type` enum: freight/insurance/clearing_fee/haulage/tax/other_charge; `method_used` enum: equal/by_value/by_weight/by_volume/manual; `allocated_amount`/`allocated_amount_base`/`allocated_per_unit`/`allocated_per_unit_base` all decimal(15,4) |

---

### Backend

**Services:**

**`CostAllocationService` (`app/Services/Costing/CostAllocationService.php`):**
- `allocate(Shipment, string $allocationType, string $method, array $options): void` — creates/replaces allocations for one cost type across all shipment items
- Allocation methods:
  ```
  equal (by_quantity): allocated_amount = total_cost / COUNT(shipment_items)
  by_value:  allocated_amount = (item.total_source_cost_base / SUM) × total_cost
  by_weight: allocated_amount = (item.product.weight_kg × qty / total_weight) × total_cost
  by_volume: allocated_amount = (item.product.cbm × qty / total_cbm) × total_cost
  manual:    user-provided per-item; validated: SUM == total_cost
  ```
- `getAllocationStatus(Shipment): array` — returns per-type status (allocated_amount, method, is_allocated)
- Weight/volume methods disabled if any product in the shipment has null `weight_kg` / dimensions

**`LandedCostCalculatorService` (`app/Services/Costing/LandedCostCalculatorService.php`):**
- `computeForItem(ShipmentItem): array` — returns `{ source_cost_per_unit, allocations_per_unit[], total_landed_cost_per_unit, total_landed_cost }`
- `computeForShipment(Shipment): array` — indexed by `shipment_item_id`
- `isFullyAllocated(Shipment): bool` — true when all applicable cost types have at least one allocation

**Model:** `app/Models/Inventory/CostAllocation.php` — relationships to `Shipment`, `ShipmentItem`, `SourcingTripItem`

**Integration in `ShipmentController::show()`:**
```php
'allocationStatus' => CostAllocationService::getAllocationStatus($shipment),
'landedCosts'      => LandedCostCalculatorService::computeForShipment($shipment),
'isFullyAllocated' => LandedCostCalculatorService::isFullyAllocated($shipment),
```

---

### Frontend

Cost allocation UI is integrated directly into `ShowShipment.vue` via:

- **Cost Allocation tab** — `AllocationDialog.vue`: per-type allocation; method selector; preview table showing per-line allocated amounts before confirming; confirms in one action; `cost_allocation_locked` disables re-allocation
- **Cost Summary tab** — inline in `ShowShipment.vue`; shows source cost + each allocation type + total landed cost per unit; reads from `landedCosts` Inertia prop
- **S-6 Landed Cost Report** — `resources/views/documents/procurement/shipments/landed-cost.blade.php`; printable breakdown per item with all cost components and landed cost per unit

---

### Landed Cost Formula (as implemented)

```
Landed Cost per Unit (base currency) =
    sourcing_trip_item.unit_source_price_base          ← source price × trip exchange rate
  + cost_allocations.allocated_per_unit_base (freight)
  + cost_allocations.allocated_per_unit_base (insurance)
  + cost_allocations.allocated_per_unit_base (clearing_fee)
  + cost_allocations.allocated_per_unit_base (haulage)
  + cost_allocations.allocated_per_unit_base (tax)
  + cost_allocations.allocated_per_unit_base (other_charge)   ← optional
```

---

### Tests

No dedicated Costing test files written yet. Integration tested via manual verification.

---

## Phase 7 — Inventory: Lots, Stock Levels, Movements ✅ Complete

**Prerequisites:** Phase 6 (for imports), Phase 3 (for opening stock)

This is the most critical phase. All inventory writes must go through `StockMovementService`.

### Backend

```bash
php artisan make:controller Inventory/InventoryLotController --resource
php artisan make:controller Inventory/StockLevelController
php artisan make:controller Inventory/StockMovementController
php artisan make:controller Inventory/OpeningStockController
php artisan make:request Inventory/CreateInventoryLotRequest
php artisan make:request Inventory/CreateOpeningStockRequest
php artisan make:policy Inventory/InventoryLotPolicy --model=InventoryLot
php artisan make:service Inventory/StockMovementService
php artisan make:service Inventory/InventoryLotService
php artisan make:service System/ProductPricingService
php artisan make:factory InventoryLotFactory
```

**`StockMovementService` (transaction-safe, the only class that writes to both tables):**
```php
public function receive(InventoryLot $lot, ProductLocation $location): void
    // INSERT stock_movements (movement_type='receipt')
    // UPSERT stock_levels (quantity_on_hand += lot.quantity_received)

public function issue(Product $product, InventoryLot $lot, int $qty, ProductLocation $from, string $movementType, string $referenceType, int $referenceId): void
    // INSERT stock_movements
    // UPDATE stock_levels (quantity_on_hand -= qty)
    // If WAC: update stock_levels.average_cost

public function adjust(Product $product, ProductLocation $location, int $qty, string $reason, ?InventoryLot $lot = null): void
    // qty can be positive (surplus) or negative (shortage); store abs() in stock_movements.quantity
    // INSERT stock_movements (adjustment_reason = $reason)
    // UPDATE stock_levels
```

**`InventoryLotService::createFromShipment(Shipment): Collection`:**
1. Assert shipment.status = 'delivered'
2. For each ShipmentItem → compute landed cost via LandedCostCalculatorService
3. Create InventoryLot (source_type='import', source_id=shipment.id); call `$product->pricingSnapshot($landedCost)` to stamp frozen pricing
4. Call StockMovementService::receive()
5. Post journal entry (Inventory — Warehouses DR / Accounts Payable CR)

**Lot pricing is handled entirely by `Product::pricingSnapshot(float $landedCost): array`** (not a separate service):
- Returns frozen pricing array from the product's current pricing; called at lot creation time only
- Auto-corrects `discount_cap` to `$landedCost` when the product's cap is below the lot's unit cost
- `LotPricingService` has been **removed** — product pricing is set via `ProductPricingService` at the product level; lots are read-only snapshots

**Opening stock** (`source_type='opening_stock'`, `source_id=null`): user manually enters `landed_cost_per_unit`; costing_method and pricing snapshot are inherited from the product automatically. No costing calculation needed.

### Frontend

Pages in `resources/js/pages/Inventory/`:
- `Dashboard.vue` — total stock value, lot count, items below minimum, expiring lots
- `Lots/LotsPage.vue` — filterable by product, location, source_type, expiry
- `Lots/ShowLot.vue` — tabs: **Pricing Snapshot** (read-only; frozen at lot creation; amber notice if discount_cap was auto-corrected), Movements, Serial Numbers
- `StockLevels/StockLevelsPage.vue` — per location, per product availability

### Tests

```bash
php artisan make:test Feature/Inventory/StockMovementServiceTest
php artisan make:test Feature/Inventory/InventoryLotServiceTest
php artisan make:test Feature/Inventory/OpeningStockTest
```

Test: StockMovementService is transactional — if movement insert fails, stock_levels not updated.
Test: stock cannot go negative (service throws `InsufficientStockException`).
Test: WAC average_cost recalculated correctly on receipt.
Test: landed cost lot created from shipment contains correct allocated costs.
Test: `pricingSnapshot()` called on all five lot-creation paths; `discount_cap` auto-corrected when below `landed_cost_per_unit`; transfer lot inherits source lot's snapshot unchanged.

---

### Additional Phase 7 Deliverables (added during implementation)

| Feature | Files |
|---|---|
| **Inventory Dashboard** | `InventoryDashboardController` fully rewritten with Inertia v3 deferred props; period filter presets; 2 KPI rows (counts + valuation); 3 tabs (Overview, Valuation, Movements) with deferred skeletons; `Inventory/Dashboard.vue` |
| **Opening Stock Modal** | `OpeningStockDialog.vue` — dialog opened from LotsPage; `batch_number` and `expiry_date` fields; `costing_method` and UoM **inherited from product** (not user input); `landed_cost_per_unit` auto-populated from product's standard cost (user can override for the specific batch) |
| **Opening Stock Bulk Import** | `OpeningStockImport.php`, `OpeningStockTemplate.php`, `OpeningStockImportDialog.vue` (4-step modal: upload → preview → process → results); **8-column template A-H** (`product_name` primary lookup, `product_sku` conditional disambiguation, `location_name`, `quantity`, `landed_cost_per_unit`, `received_date`, `batch_number`, `expiry_date`); no pricing/UoM/costing columns — all inherited from product; `parseDate()` handles Excel serial dates; `cleanNumeric()` strips comma thousand-separators (e.g. `420,000.00`) |
| **Edit Lot Metadata** | `PATCH /inventory/lots/{lot}/meta`; `UpdateLotMetaRequest`; `EditLotMetaDialog.vue`; allows updating `expiry_date`, `batch_number`, `costing_method` (blocked if movements exist) |
| **Product Pricing History** | `product_pricing_history` migration + `ProductPricingHistory` model (`app/Models/System/`); records every **product-level** pricing change with full pricing snapshot, `reason` (required; 19 predefined options), `changed_by`, `changed_at`; "Pricing History" section in `ShowProduct.vue`; product is the **single source of pricing truth** — lots receive an immutable snapshot at creation |
| **Reason for Price Change (Product)** | `ProductFormDialog.vue` — reason selector shown in edit mode only when pricing fields actually changed (`isPricingDirty` computed); `UpdateProductRequest` validates `reason` as nullable but enforced by controller when `hasPricingChanged()` returns true; new products use "Initial pricing" automatically |
| **Stock Valuation Statistics** | 4-value valuation row (At Cost, At Selling Price, At Outlet Price, Discount Floor) on both Inventory Dashboard and Stock Levels page |
| **Organisation Currency Formatting** | `HandleInertiaRequests` shares `orgCurrency` globally; `useCurrency.ts` composable provides `fmtMoney()` used across all inventory and procurement dashboard pages |
| **ServerPaginatedDataTable** | All three inventory list pages (Lots, StockLevels, Movements) use `ServerPaginatedDataTable` with `route-name`, `storage-key`, `inertia-reload-only`, correct sort/search/pagination wiring |

### Tests Written (Phase 7)

```
tests/Feature/Inventory/StockMovementServiceTest.php
tests/Feature/Inventory/InventoryLotServiceTest.php   ← includes pricingSnapshot + discount_cap auto-correction tests
tests/Feature/Inventory/OpeningStockTest.php
```

Note: `LotPricingTest.php` was removed when `LotPricingService` was deleted (lot pricing is now an immutable product snapshot, not a separate service).

---

## Phase 8 — Inventory: Transfers, Stock Takes, Serial Numbers ✅ Complete

**Prerequisites:** Phase 7

> **Note:** Phase 8 also delivered the lot-based stock level architecture (adding `lot_id` to `stock_levels`), which logically belongs to Phase 7 but was implemented here. See §8.5 below.

---

### 8.1 Transfer Orders

**Status flow:**
```
draft → pending_approval → approved → in_transit → received
                                                  ↘ cancelled (before dispatch only)
```

**Key design decisions implemented:**
- **Explicit approver selection**: requester selects an eligible approver at submission time; `pending_approver_id` stored on order; only the designated approver can approve
- **Serial-based dispatch/receive**: serials are DB-backed staged (`pending_dispatch` / `pending_receipt` status + `pending_transfer_order_id` FK); survive page refresh; prevent double-staging
- **Destination lots**: `receive()` calls `InventoryLotService::createFromTransfer()` — creates a new `InventoryLot` at the destination (`source_type='transfer'`) inheriting cost/pricing from the source lot; serial numbers reassigned to the new lot
- **Cap on dispatch**: cannot stage more than `requested_quantity` serials per product
- **Variance tracking**: `dispatch_variance_reason` and `reception_variance_reason` per `TransferOrderItem`; posting blocked until all shortfall items have reasons

**`TransferOrderService`:**
- `submit(TransferOrder, int $approverId, ?string $comment)` — transitions draft → pending_approval; notifies designated approver
- `approve(TransferOrder, User)` — validates designated approver; transitions → approved; notifies requester
- `scanDispatch(TransferOrder, string $serialNumber, ?int $productId)` — idempotent; validates serial at source location; cap check; sets `pending_dispatch` + `pending_transfer_order_id`
- `unscanDispatch(TransferOrder, string $serialNumber)` — reverts to `in_stock`; clears link
- `dispatch(TransferOrder, ?string $dispatcherComment)` — validates variance reasons for all shortfall items; calls `StockMovementService::issue()` per lot group; sets `pending_transfer_order_id` stays (cleared only at receipt)
- `scanReceipt(TransferOrder, string $serialNumber)` — validates `transferred` status + order link; sets `pending_receipt`
- `unscanReceipt(TransferOrder, string $serialNumber)` — reverts to `transferred`; keeps order link
- `receive(TransferOrder, ?string $receiverComment)` — validates variance reasons; calls `createFromTransfer()` per lot; reassigns serial `lot_id`; clears `pending_transfer_order_id` on received serials; orphaned `transferred` serials (dispatched but not received) get link cleared
- `cancel(TransferOrder)` — resets `pending_dispatch`/`pending_receipt` serials to `in_stock`

**`InventoryLotService::createFromTransfer(TransferOrderItem, TransferOrder, int $qty, ?InventoryLot $sourceLot)`:**
- Creates destination lot (`source_type='transfer'`); inherits cost/pricing from `$sourceLot` (passed from serial records, not item's `lot_id` which may be null)
- Calls `StockMovementService::receive()` for the new destination lot

**Controllers:**
- `TransferOrderController` — index, store, show, submit, approve, cancel, showDispatch, scanDispatch (JSON), unscanDispatch (JSON), finalizeDispatch, showReceive, scanReceive (JSON), unscanReceive (JSON), finalizeReceive
- `TransferOrderItemController` — store (uses `updateOrCreate` to prevent duplicate rows), update, destroy, updateVariance

**Additional columns on `transfer_orders`:** `pending_approver_id`, `dispatched_by`, `received_by`, `requester_comment`, `approver_comment`, `dispatcher_comment`, `receiver_comment`
**Additional columns on `transfer_order_items`:** `dispatched_quantity`, `received_quantity`, `dispatch_variance_reason`, `reception_variance_reason`
**`TransferOrderItem::VARIANCE_REASONS`** — 12 predefined reasons (damaged in transit, short-delivered, lost, quality rejected, wrong item, admin error, theft, natural wastage, force majeure, supplier short, other)

**Frontend:**
- `Transfers/TransferOrdersPage.vue` — analytics, server-paginated DataTable
- `Transfers/ShowTransferOrder.vue` — banner + 5 tabs (Overview, Available Stock, Items, Stock Preview, Dispatch Variance, Receipt Variance); "Submit for Approval" → `SubmitTransferDialog`; "Approve" → `ApproveTransferDialog` (designated approver only)
- `Transfers/DispatchTransfer.vue` — scan card (product filter + serial input); two DataTable tabs (Order Items with progress, Staged for Dispatch); Confirm Dispatch modal (dispatcher notes + type `DISPATCH`)
- `Transfers/ReceiveTransfer.vue` — mirror of dispatch; type `RECEIPT` to confirm
- `Transfers/SubmitTransferDialog.vue`, `ApproveTransferDialog.vue`, `VarianceReasonDialog.vue`
- Documents: Dispatch Note (HTML + PDF), Receipt Confirmation (HTML + PDF) via `TransferOrderDocumentController`

---

### 8.2 Lot-Based Stock Levels (architectural enhancement)

- `stock_levels` gains `lot_id` FK (nullable); unique constraint changed from `(product_id, location_id)` to `(lot_id, location_id)` — one row per lot per location
- Existing aggregate stock level data truncated on migration; rebuilt from receipts going forward
- `StockMovementService::upsertStockLevel()` keys on `lot_id`; `issue()` queries by `lot_id + location_id`
- `StockMovementService::adjust()` uses `lot_id` when lot is provided (for stock take adjustments); falls back to `product_id + location_id` when no lot
- `InventoryLot` gains `stockLevel()` HasOne relationship
- **Stock Levels pages**: "By Product" (aggregate SUM via `selectRaw + groupBy`) and "By Lot" (per-lot rows with serial count subquery)
- `active_serial_count` correlated subquery added to both views; amber "⚠ Reconcile" badge on By Lot page when count ≠ `quantity_on_hand`

---

### 8.3 Stock Takes

**Two counting modes:** `serial` (scan barcodes, DB-backed) and `manual` (enter counts per item).

**`StockTakeService`:**
- `initiate(StockTake)` — snapshots `stock_levels` (now lot-based) into `stock_take_items`; sets `lot_id` on each item
- `scanSerial(StockTake, string $serialNumber)` — idempotent; validates `in_stock/reserved` at take's location; validates lot is in take's items; sets `stock_take_id` on serial
- `unscanSerial(StockTake, string $serialNumber)` — clears `stock_take_id`
- `finalizeCount(StockTake)` — serial mode: computes `counted_quantity` from scanned serials per lot; manual mode: validates all items have counts; both transition → completed
- `updateItems(StockTake, array $rows)` — manual mode: saves `counted_quantity`, `variance`, `variance_reason`; allowed in `in_progress` or `completed` status
- `post(StockTake, User)` — validates all non-zero variances have reasons; calls `StockMovementService::adjust()` per item using lot; clears `stock_take_id` from serials
- `cancel(StockTake)` — clears `stock_take_id` from all counted serials

**`StockTakeController`** — index, store, show, update (draft only), initiate, scanSerial (JSON), unscanSerial (JSON), finalizeCount, updateItems, post, cancel, destroy (draft only)

**Additional columns:** `stock_takes.counting_mode` (serial/manual); `serial_numbers.stock_take_id` FK

**`StockTakeItem::VARIANCE_REASONS`** — 16 predefined reasons (counting error, damage, theft, expiry, system error, goods not received, customer return, goods in transit, natural wastage, wrong location, sample usage, staff consumption, breakage, supplier return, write-off, other) — aligned with `stock_movements.adjustment_reason` column values

**Frontend:**
- `StockTakes/StockTakesPage.vue` — analytics, server-paginated; "New Stock Take" dialog with counting mode selector (serial/manual, defaults to serial)
- `StockTakes/ShowStockTake.vue` — redesigned; scan form card (serial mode) + two tabs (Stock Items, Counted Serials); Finalize Count modal (type `COMPLETE`); Post Variances modal (type `POST`); "Set Count" action (manual mode); "Set Reason" action (completed status, for variance items without a reason)
- Documents: Count Sheet (HTML + print-only), Variance Report (HTML + print-only)

---

### 8.4 Serial Numbers

- **Universal serialization**: `SerialNumberService::generate()` creates serials for ALL lots on receipt — `is_serialised` flag no longer required; all lots get serials
- **Format**: `{lot_number}-{zero_padded_sequence}` e.g. `INV-260512-0001`
- **Statuses**: `in_stock`, `reserved`, `sold`, `transferred`, `written_off`, `pending_dispatch`, `pending_receipt`
- **Additional FKs**: `pending_transfer_order_id` (tracks serial through transit; cleared at receipt), `stock_take_id` (set during stock take counting; cleared on post/cancel)

**`SerialNumberService`:**
- `generate(InventoryLot): Collection` — idempotent; creates N serials (`quantity_received`); returns existing if already generated
- `updateStatus()` — reserved for Phase 11 (Sales)

**`SerialNumberController`** — index (org-wide paginated), generate, writeOff (status-change only, no stock movement), printLabels (in_stock + reserved only)

**Write-off design decision:** Write-off from the Lot Details page is a **serial status change only** — no stock movement, no stock level update. Stock level reconciliation is handled via stock takes. This prevents double-counting when a stock take has already posted the adjustment movement.

**Frontend:**
- `Serials/SerialNumbersPage.vue` — analytics cards (all 6 statuses); single/bulk write-off (Swal confirmation); selectable DataTable
- `Lots/ShowLot.vue` serials tab — client-side `DataTable.vue` with `selectable=true`; "Write Off" action (in_stock/reserved only); "Write Off Selected" bulk button; "Print Labels" (in_stock + reserved count only)
- `Serials/PrintLabels.vue` — standalone print page; auto-triggers `window.print()`; JsBarcode Code 128

---

### 8.5 Direct Stock Adjustment (Decrease Only)

- `InventoryLotController::adjust()` — reduces stock for a specific lot without a full stock take
- **Increases excluded by design**: reactivating existing serials would corrupt history; new inventory intake uses Opening Stock (creates a new lot with its own serials)
- Calls `StockMovementService::adjust()` with negative quantity and lot reference
- `StockAdjustmentDialog.vue` — reason dropdown (reuses `StockTakeItem::VARIANCE_REASONS`); current on-hand display; after-adjustment preview; submit disabled when qty > current stock; serial reconciliation warning
- Available from Lot Details page ("Reduce Stock" button) and Stock Levels By Lot page ("Adjust" row action)

---

### 8.6 Inventory Documents & Reports

**Document controllers** (`app/Http/Controllers/Inventory/Documents/`):
- `TransferOrderDocumentController` — dispatchNote (HTML + PDF), receiptNote (HTML + PDF)
- `InventoryLotDocumentController` — lotDetail (HTML + PDF)
- `StockTakeDocumentController` — countSheet (HTML + print-only), varianceReport (HTML + print-only)
- `StockReportDocumentController` — stockPositionByLot (HTML + Excel), stockPositionByProduct (HTML + Excel), stockValuation (HTML + Excel), reportsPage (Inertia)

**Excel exports** (`app/Exports/Inventory/`): `StockPositionExport`, `StockPositionByProductExport`, `StockValuationExport` — all use `WithColumnWidths` (not `ShouldAutoSize`); pre-compute row statuses/margins in `map()` to avoid sheet reads in `styles()`; colour-coded (red = below minimum / negative margin, amber = serial mismatch, green = high margin)

**Reports page** (`/inventory/reports`):
- Stock Reports section: product + location filters → open reports in new tab or download Excel
- Lot Reports section: location → lot selector (client-side filtered) → Lot Detail Sheet or PDF download
- Navigation: single "Reports" link (replaces submenu)

**Shared layout**: `resources/views/documents/inventory/_layout.blade.php` — mirrors procurement layout; A4, auto-print, colour helpers (bg-green, bg-amber, bg-red, bg-header)

---

### 8.7 Tests Written

```
tests/Feature/Inventory/TransferOrderServiceTest.php  — 15 tests
tests/Feature/Inventory/StockTakeServiceTest.php      — 4 tests
tests/Feature/Inventory/SerialNumberServiceTest.php   — 5 tests
tests/Feature/Inventory/InventoryLotServiceTest.php   — 5 tests (updated)
tests/Feature/Inventory/StockMovementServiceTest.php  — 10 tests (updated)
tests/Feature/Inventory/LotPricingTest.php            — 5 tests (confirmed)
tests/Feature/Inventory/OpeningStockTest.php          — 4 tests (confirmed)
```

All 47 inventory tests pass as of Phase 8 completion.

---

## Phase 9 — Local Procurement ✅ Complete

**Prerequisites:** Phases 2 + 3 + 7

---

### Database

| Migration | Purpose |
|---|---|
| `create_local_procurement_tables` | LPOs, LPO items, GRNs, GRN items, supplier invoices, invoice items |
| `add_charges_to_lpos` | `tax_rate`, `tax_amount`, `tax_amount_source`, freight/insurance/other charges (source + base) |
| `add_invoice_type_and_currency_to_supplier_invoices` | `invoice_type` (advance/delivery/direct), `source_currency_code`, `exchange_rate`, charge fields |
| `add_approval_to_supplier_invoices` | Full approval workflow columns + status expansion |
| `add_location_to_supplier_invoices` | `location_id` FK |
| `add_cancelled_to_supplier_invoices` | Status enum expansion |
| `add_amount_paid_source_to_supplier_invoices` | Dual-currency `amount_paid_base` + `amount_paid_source` (replaces single `amount_paid`) |
| `create_supplier_returns_tables` | `supplier_returns` + `supplier_return_items` |
| `add_approval_workflow_to_supplier_returns` | Full workflow columns + status expansion |
| `add_charges_to_grn_tables` | GRN-level and item-level charge totals (tax/freight/insurance/other + landed_total, source + base) |
| `add_grn_id_to_inventory_lots` | `grn_id` FK on `inventory_lots` — enables correct lot lookup when multiple GRNs exist on same LPO |
| `create_supplier_credit_notes_table` | `supplier_credit_notes` with full schema |
| `redesign_supplier_credit_notes_workflow` | Invoice linkage, approval workflow, partial application tracking, status expansion |
| `add_address_to_suppliers_table` | `address` field on `suppliers` |

---

### Backend

**Controllers (`app/Http/Controllers/LocalProcurement/`):**

| Controller | Key methods |
|---|---|
| `LocalProcurementDashboardController` | `__invoke()` with period + location filters; KPI queries; Inertia-deferred `awaitingPaymentInvoices` |
| `LocalPurchaseOrderController` | index, store, show, update, submit, approve, reject, returnToDraft, placeOrder, cancel, close, destroy |
| `LocalPurchaseOrderItemController` | store, update, destroy |
| `GoodsReceiptNoteController` | index, store, show, update, post, destroy |
| `GoodsReceiptNoteItemController` | update (receive item with cost snapshot) |
| `SupplierInvoiceController` | index, create, store, show, update, submit, approve, reject, returnToDraft, markDisputed, resolveDispute, recordPayment, destroy |
| `SupplierInvoiceItemController` | store, update, destroy (direct invoices only) |
| `SupplierReturnController` | index, store, show, update, returnAll, submit, approve, reject, returnToDraft, post, destroy |
| `SupplierReturnItemController` | store, update, destroy |
| `SupplierCreditNoteController` | index, store, show, update, submit, approve, reject, returnToDraft, apply, unapply, destroy |
| `SupplierPaymentController` | index (local invoice payments consolidated list) |
| `LocalProcurementReportController` | reportsPage, agedCreditors, spendBySupplier, outstandingLpos, matchExceptions, purchaseHistory |

**Document Controllers (`app/Http/Controllers/LocalProcurement/Documents/`):**

| Controller | Documents |
|---|---|
| `LpoDocumentController` | Purchase Order (HTML + PDF) |
| `GrnDocumentController` | Goods Receipt Note (HTML + PDF) |
| `InvoiceDocumentController` | Supplier Invoice (HTML + PDF) |
| `PaymentReceiptDocumentController` | Payment Receipt (HTML + PDF) |
| `SupplierStatementDocumentController` | Supplier Statement of Account (HTML + PDF) |
| `ReturnNoteDocumentController` | Supplier Return Note (HTML + PDF) |
| `CreditNoteDocumentController` | Credit Note / Debit Note (HTML + PDF) |

**Models (`app/Models/LocalProcurement/`):**
`LocalPurchaseOrder`, `LocalPurchaseOrderItem`, `GoodsReceiptNote`, `GoodsReceiptNoteItem`, `SupplierInvoice`, `SupplierInvoiceItem`, `SupplierReturn`, `SupplierReturnItem`, `SupplierCreditNote`

**Services (`app/Services/LocalProcurement/`):**

| Service | Purpose |
|---|---|
| `GrnPostingService` | Post GRN: creates inventory lots with `grn_id`, stock movements, charge apportionment (tax/freight/insurance/other stored on GRN and GRN items), updates LPO status |
| `ThreeWayMatchService` | Advance/delivery invoice comparison against LPO quantities and GRN received quantities; informational only |
| `SupplierReturnPostingService` | Post return: updates document status only (stock reduction deferred to serial reconciliation on ShowLot) |
| `LpoNotificationService` | Submit/approve/reject/placeOrder/cancel/close notifications |
| `InvoiceNotificationService` | Submit/approve/reject notifications |
| `ReturnNotificationService` | Submit/approve/reject/post notifications |
| `CreditNoteNotificationService` | Submit/approve/reject notifications |

**Consistent approval workflow (LPO / Invoice / Return / Credit Note):**
```
draft → pending_approval → approved → [domain action]
                       ↘ rejected → draft (returnToDraft)
```
Approver: location manager or assistant manager of delivery location; submitter cannot self-approve.

**Three invoice types:**
- `advance` — created before GRN; items from LPO at agreed prices
- `delivery` — created against a posted GRN; charges apportioned proportionally from LPO
- `direct` — standalone; manual line items; no LPO/GRN

**GRN charge apportionment (computed at GRN posting time):**
`proportion = grn_subtotal_source / lpo.subtotal_source`
Stored on GRN and GRN items; delivery invoice charges pre-fill directly from stored values.

**Dual-currency payment tracking:**
`amount_paid_base` + `amount_paid_source` on supplier invoices.
Payments recorded in invoice's source currency; base computed via invoice exchange rate.
`payment_method = 'credit_note'` allows credit note applications to appear in payment history.

**Credit note partial application:**
`apply()` caps at `min(remaining_credit, invoice_balance)`; tracks `amount_applied_base/source`; `unapply()` fully reverses.

---

### Frontend (`resources/js/pages/LocalProcurement/`)

| Page / File | Purpose |
|---|---|
| `Dashboard.vue` | KPIs (open LPOs, pending GRNs, balance due, invoiced in period) with period preset + location filters |
| `Lpos/LposPage.vue` | Server-paginated; filters (status, supplier, delivery location) |
| `Lpos/ShowLpo.vue` | Tabs: Overview, Items, GRN tab (Create Invoice + Create Return per GRN row, GRN value columns), Invoices; full approval workflow; charges section |
| `Grns/GrnsPage.vue` | Server-paginated; filters (status, supplier) |
| `Grns/ShowGrn.vue` | Items receiving, totals breakdown (subtotal + charges + grand total); Print GRN |
| `Invoices/SupplierInvoicesPage.vue` | Server-paginated; filters (status, supplier, type, location) |
| `Invoices/ShowSupplierInvoice.vue` | Three-way match tab; Payments tab (history + per-payment receipt); approval timeline |
| `Invoices/CreateInvoiceModal.vue` | 3-step modal (kind → type → details); charges pre-fill from stored GRN values |
| `Invoices/CreateInvoiceFromGrnDialog.vue` | Focused delivery invoice from LPO GRN tab |
| `Returns/SupplierReturnsPage.vue` | List with analytics (draft/pending/posted) |
| `Returns/ShowSupplierReturn.vue` | Items table + Add Item + Return All; approval workflow; serial reconciliation guidance post-posting |
| `CreditNotes/SupplierCreditNotesPage.vue` | List with analytics (draft/pending/approved/applied) |
| `CreditNotes/ShowSupplierCreditNote.vue` | Setup (invoice selector → currency auto-fills → amount → base preview → return link); approval workflow; apply/unapply |
| `Payments/SupplierPaymentsPage.vue` | All local invoice payments; filters (supplier, method, date range) |
| `Reports/ReportsPage.vue` | 5 reports (Aged Creditors, Spend by Supplier, Outstanding LPOs, Match Exceptions, Purchase History) — print-optimised Blade views |

**Navigation additions:** Supplier Returns, Credit Notes, Supplier Payments, Reports entries in `localProcurement.ts`.

---

### Document Blade Views (`resources/views/documents/local-procurement/`)

LPO purchase order, GRN receipt note (with charges totals), supplier invoice, payment receipt, supplier statement of account, supplier return note, credit / debit note. All extend `documents.procurement._layout` with `noAutoPrint = true`.

---

### Key Design Decisions

| Decision | Rationale |
|---|---|
| GRN charge columns stored at posting time | Avoid re-computing proportions; delivery invoice charges fill directly from GRN |
| `grn_id` on inventory lots | Correct lot selection when same LPO has multiple GRNs |
| Supplier return posting = document-only (no stock movement) | Serial reconciliation on ShowLot already creates the movement; posting would double-reduce |
| Credit note amount in invoice source currency | Eliminates cross-currency ambiguity in `amount_paid_source` |
| `payment_method = 'credit_note'` on SupplierPayment | Credit applications visible in invoice Payments tab without schema changes |

---

### Tests Written

```
tests/Feature/LocalProcurement/ThreeWayMatchTest.php — 4 tests
```

### Items Deferred

See `docs/deferred-items.md` for all GL/journal entries and serialised-return automation deferred to Finance phases.

---

## Phase 10 — Production ✅ Complete

**Prerequisites:** Phases 2 + 7

---

### Database (`0000_01_01_000007_create_local_production_tables.php`)

| Table | Key design notes |
|---|---|
| `bill_of_materials` | `bom_reference` auto-generated; `version` integer; `is_active`; soft-deleted; unique `(organization_id, bom_reference)` |
| `bom_items` | `component_product_id` FK → products; `quantity_per_unit decimal(12,4)`; `wastage_pct decimal(5,2)`; `unit_cost_snapshot_base decimal(15,4)` — auto-filled from component's `landed_cost_per_unit` on item creation |
| `production_orders` | `production_reference` auto-generated; status enum (draft/pending_approval/approved/rejected/in_production/completed/cancelled); `source_currency_code` + `exchange_rate`; `planned_quantity`/`produced_quantity`/`rejected_quantity`; `total_production_cost_*` + `unit_production_cost_*` (source + base); `output_lot_id` FK → inventory_lots; `submitted_by/at`, `approved_by/at`, `rejected_by/at` |
| `production_cost_lines` | `cost_type` enum (material/labour/overhead/other); `estimated_amount_source/base` — frozen at order creation from BOM/dialog; `amount_source/base` — updated as actuals confirmed; enables est vs actual variance display |
| `production_material_consumptions` | `consumed_at IS NULL` = BOM-estimated row; `IS NOT NULL` = actual confirmed; unique key `(production_order_id, product_id, lot_id)` — one NULL-lot row per product (estimate baseline) + one row per specific lot; `estimated_quantity`, `quantity_consumed`, dual-currency unit/total cost snapshots |

---

### Backend

**Controllers (`app/Http/Controllers/Production/`):**

| Controller | Key methods |
|---|---|
| `ProductionDashboardController` | `__invoke()` — period + location + product filters; 12 KPI fields + 5 deferred props (`recentOrders`, `productionTrend`, `plannedVsProduced`, `productionByProduct`, `topBoms`) |
| `BillOfMaterialsController` | `index`, `store`, `show`, `update`, `activate`, `deactivate`, `duplicate`, `destroy`; `lookup` (JSON for order creation dialog); `storeItem`, `updateItem`, `destroyItem` |
| `ProductionOrderController` | `index`, `store`, `show`, `update`, `destroy`; workflow: `submit`, `approve`, `reject`, `returnToDraft`, `start`, `complete`, `cancel`; `populateFromBom` |
| `ProductionCostLineController` | `store` (upserts by cost type), `update`, `destroy` |
| `ProductionMaterialConsumptionController` | `store` (lot-aware upsert), `update`, `destroy`; stock validation against `StockLevel.quantity_on_hand`; calls `recalculateMaterialCostLine()` after every change |
| `Documents/ProductionOrderDocumentController` | `workOrder` + `workOrderPdf`; `pickingList` + `pickingListPdf`; `completionCertificate` + `completionCertificatePdf` |
| `Reports/ProductionReportController` | `reportsPage`; `productionSummary` + `productionSummaryExcel`; `costAnalysis` + `costAnalysisExcel`; `materialConsumption` + `materialConsumptionExcel` |

**Services (`app/Services/Production/`):**

`ProductionOrderService`:
- `populateFromBom(ProductionOrder)` — creates `consumed_at=NULL` consumption rows from BOM; creates/updates material cost line with `estimated_amount_* = amount_*`
- `complete(ProductionOrder, array $data)` — DB transaction: sets produced/rejected qty; writes off confirmed lot-linked consumptions via `StockMovementService::issue()`; creates output `InventoryLot` (`source_type='production'`); calls `StockMovementService::receive()`; sets status = 'completed'

`ProductionNotificationService`:
- `notifySubmit`, `notifyApprove`, `notifyReject`, `notifyStart`, `notifyReturnToDraft`, `notifyCancel`, `notifyComplete` — all use `GeneralNotification` with action URL pointing to order show page

**Form Requests:** `StoreBillOfMaterialsRequest`, `StoreBomItemRequest`, `StoreProductionOrderRequest`, `StoreCostLineRequest`, `StoreMaterialConsumptionRequest`, `CompleteProductionOrderRequest`

**Excel Exports (`app/Exports/Production/`):** `ProductionSummaryExport`, `ProductionCostAnalysisExport`, `MaterialConsumptionExport` — all implement `FromCollection`, `WithHeadings`, `WithMapping`, `WithStyles`, `WithColumnWidths`

**Status transitions:**
```
draft → pending_approval → approved → in_production → completed
     ↘                  ↘                           ↘ cancelled
      rejected ←────────┘
        ↓
     return_to_draft
```

**Key business rules enforced:**

| Rule | Where |
|---|---|
| Material cost line recalculated on every consumption change | `recalculateMaterialCostLine()` called in store/update/destroy |
| `consumed_at IS NULL` = BOM estimate; `IS NOT NULL` = actual | Design convention; consumption store/update sets `consumed_at = now()` |
| Stock validation — qty consumed cannot exceed lot's `quantity_on_hand` | `ProductionMaterialConsumptionController` + Vue frontend |
| Lot-aware upsert: `(product_id, lot_id)` unique per order | Allows multiple lot rows per product; separate NULL-lot estimate row |
| Over-production allowed (>planned); shows amber warning | `CompleteOrderDialog.vue` `isOverProduction` computed |
| `estimated_amount_*` frozen on cost lines | Only `amount_*` updated as actuals; variance = actual − estimated |
| Write-off only for confirmed (`consumed_at IS NOT NULL`) + lot-linked consumptions | `ProductionOrderService::complete()` |
| Notifications at every workflow stage | `ProductionNotificationService` called from all transition actions |

---

### Frontend (`resources/js/pages/Production/`)

| File | Purpose |
|---|---|
| `Dashboard.vue` | Inline filters (period + location + product); Pipeline Status card (7 statuses); 3 metric cards; deferred panels (Recent Orders, Needs Attention); 3 chart rows: Production Volume Trend (monthly line), Production by Product Over Time (top-8 multi-line monthly), Planned vs Produced + Most-Used BOMs (side-by-side horizontal bars) |
| `Boms/BillsOfMaterialsPage.vue` | Merged BOMs analytics card + Avg Components + Total Production Runs + Avg Material Cost; server-paginated DataTable |
| `Boms/ShowBom.vue` | Components table; standard material cost per unit in tfoot; Duplicate BOM; Edit Item; Swal delete/deactivate |
| `Boms/CreateBomDialog.vue` | Create BOM with product selector |
| `Orders/ProductionOrdersPage.vue` | Merged Order Status card (8 columns: Total + 7 statuses); Volume + Cost analytics row; server-paginated DataTable |
| `Orders/ShowProductionOrder.vue` | 3 tabs (Overview, Cost Lines, Material Consumptions); document buttons (Work Order/Picking List/Certificate based on status); full workflow buttons |
| `Orders/CreateProductionOrderDialog.vue` | BOM selector; currency SearchableSelect; estimated labour/overhead/other cost inputs |
| `Orders/CompleteOrderDialog.vue` | Produced qty + rejected qty; net qty preview; over-production amber warning |
| `Orders/AddConsumptionDialog.vue` | Lot-aware upsert; stock availability indicator; auto-compute base cost from source cost |
| `Orders/AddCostLineDialog.vue` | Upserts by cost type; pre-fills from existing lines |
| `Orders/ConfirmActualsDialog.vue` | Confirms actual qty; lot selector with available stock display |
| `Reports/ReportsPage.vue` | 3 report sections (Production Summary, Cost Analysis, Material Consumption) — HTML open + Excel download |

---

### Document Suite (`resources/views/documents/production/`)

| Document | Available when | PDF? |
|---|---|---|
| Work Order | `approved`, `in_production`, `completed` | ✓ |
| Material Picking List | `approved`, `in_production` | ✓ |
| Completion Certificate | `completed` only | ✓ |

All extend `documents.procurement._layout` with `noAutoPrint = true`.

---

### Report Suite (`resources/views/documents/production/reports/`)

| Report | Filters | Output |
|---|---|---|
| Production Summary | date range, status, product, location | HTML + Excel |
| Cost Analysis | completion date range, product, location (completed only) | HTML + Excel |
| Material Consumption | consumed date range, raw material, location (confirmed only) | HTML + Excel |

---

### Navigation (`resources/js/data/navigation/production.ts`)

5 links: Dashboard, Bills of Materials, Production Orders, Reports, Back to Modules

---

### Tests Written (Phase 10)

```
tests/Feature/Production/ProductionOrderTest.php   — 18 tests
tests/Feature/Production/BillOfMaterialsTest.php   — 10 tests
```

All 28 tests pass.

---

## Phase 11 — Sales: Full Payment & POS ✅ Complete

**Prerequisites:** Phases 3 + 7 + 1 (tax rates, till sessions)

---

### Database (`0000_01_01_000008_create_sales_tables.php`)

| Table | Key design notes |
|---|---|
| `tax_rates` | `code`, `name`, `rate`, `tax_type`, `applies_to`, `is_inclusive`, `is_default`, `is_active` |
| `till_sessions` | `session_reference`, `opened_by/at`, `closed_by/at`, `opening_float`, `closing_float`, `expected_cash`, `cash_variance`, `variance_reason`, `notes`, `status` (open/closed) |
| `instalment_configurations` | Org-level instalment plan tiers — `name`, `is_default`, `min/max_amount_base` tier bounds, `clearance_days`, `grace_period_days`, `max_holding_days`, `storage_fee_type` (none/fixed/daily_accrual), `storage_fee_amount_base`, `require_deposit`, `min_deposit_pct`, `allow_delivery_before_payment`, `is_active` |
| `sales` | `sale_reference`, `customer_id`, `location_id`, `till_session_id`, `tax_rate_id`, `discount_configuration_id`, `applicable_tax_rate`, `applicable_tax_is_inclusive`, `source_currency_code`, `exchange_rate`, `subtotal_*`, `discount_amount_*`, `tax_amount_*`, `total_amount_*`, `amount_paid_*`, `delivery_type` (self_collect/free_delivery/paid_delivery), `delivery_fee_*`, `delivery_address`, `status` |
| `sale_line_items` | `sale_id`, `product_id`, `lot_id`, `quantity`, `outlet_price_*`, `negotiated_price_*`, `discount_amount_*`, `landed_cost_per_unit_*`, `line_total_*` |
| `instalment_plans` | Phase 12 — table exists with full schema; config snapshot, clearance deadline, fee accrual, approval workflow, `delivered`/`delivered_at`/`delivered_by` for credit model |
| `instalment_payments` | Phase 12 — table exists |
| `receipts` | Sequential `receipt_number` per org; `receipt_type` enum; source + base amounts |
| `sale_payments` | `payment_method`, `amount_source/base`, `amount_tendered_source`, `change_given_source`, `reference`, `bank_account_id` |
| `sales_returns` | Phase 12 — table exists with full schema |
| `sales_return_items` | Phase 12 — table exists |

**Additional migration files created in Phase 11:**
- `2026_06_01_..._create_discount_configurations_table.php` — `discount_configurations` table + deferred FK on `sales.discount_configuration_id`
- `2026_05_30_..._create_serial_reconciliations_table.php` — records serialized products sold via quantity mode requiring post-sale serial assignment

---

### Backend

**Controllers (`app/Http/Controllers/Sales/`):**

| Controller | Key methods |
|---|---|
| `SalesDashboardController` | `__invoke()` |
| `TaxRateController` | `index`, `store`, `update`, `toggleStatus`, `destroy` |
| `TillSessionController` | `index`, `store`, `show`, `close` |
| `SaleController` | `index`, `store` (pending header), `addItems` (POS page), `updateDelivery`, `finalize`, `show`, `cancel`, `productLookup` (JSON: serial/SKU/product) |
| `SaleLineItemController` | `store`, `update`, `destroy` — DB-backed draft items with immediate stock reservation |
| `DiscountConfigurationController` | `index`, `store`, `update`, `toggleStatus`, `destroy` |
| `Documents/SaleDocumentController` | `receipt` (HTML browser view A4 + thermal), `receiptPdf` (DomPDF A4 download) |

**Services (`app/Services/Sales/`):**

`SaleService`:
- `createHeader(array $data, User): Sale` — creates pending sale; snapshots tax rate + stores discount configuration FK
- `finalize(Sale, array $data, User): Sale` — confirms draft line items via `StockMovementService::confirm()`; marks serials sold; creates `SerialReconciliation` records for serialized quantity-mode items; records payments; issues receipt
- `cancel(Sale, string $reason, User): void` — pending: releases reservations + deletes items; fully_paid: reverses stock movements
- `fulfil(Sale, User): void` — Phase 12 stub; issues stock for fully-paid instalment plans

`TillSessionService`:
- `open(User, float $openingFloat): TillSession`
- `close(TillSession, array $data, User): TillSession` — blocked when pending sales exist; expected_cash = `opening_float + SUM(cash amount_base)` (base currency; change_given NOT subtracted separately)
- `computeExpectedCash(TillSession): float`

`ReceiptService::issue(Sale, string $type, float $amountSource, float $amountBase, string $currency, float $rate, User): Receipt` — sequential receipt_number per org

**`StockMovementService` additions (Phase 11):**
- `reserve(lot, location, qty)` — decrements `quantity_on_hand` + increments `quantity_reserved`; called when draft item added
- `release(lot, location, qty)` — reverse of reserve; item delete or sale cancel
- `confirm(lot, location, qty, referenceType, referenceId)` — writes `StockMovement` ledger record + decrements `quantity_reserved`; called at finalize

**`Sale::recalculateTotals()`** — called after every item change and after delivery save; `subtotal → (−discount) → taxable → (+tax) → total + delivery`; total is always server-authoritative; delivery fee included

**Form Requests:** `StoreTaxRateRequest`, `OpenTillSessionRequest`, `CloseTillSessionRequest`, `StoreSaleRequest`, `FinalizeSaleRequest`

---

### Frontend (`resources/js/pages/Sales/`)

| File | Purpose |
|---|---|
| `Dashboard.vue` | Sales dashboard |
| `TaxRates/TaxRatesPage.vue` | Full CRUD (same pattern as CustomersPage) |
| `Discounts/DiscountConfigurationsPage.vue` | Full CRUD; percentage or fixed-amount; per-outlet or global; validity date range |
| `Till/TillSessionsPage.vue` | Session list + active session cards with expected cash |
| `Till/ShowTillSession.vue` | Overview (metrics + payment breakdown by currency); Sales tab (Resume link + print icons per row) |
| `Till/CloseTillDialog.vue` | Closing float entry; variance reason (required for negative variance) |
| `Sales/SalesPage.vue` | Paginated list; row actions: View, Resume (pending), Print A4, POS Receipt, PDF, Cancel |
| `Sales/CreateSaleModal.vue` | New sale header: customer, currency/rate, tax rate (required conscious choice), sale discount (optional), notes |
| `Sales/CreateSale.vue` | POS add-items: 3 tabs (Scan Serial / Scan SKU / Select Product); DB-backed items; `EditSaleItemDialog`; delivery card (Save persists); payment section (auto-fill, split, references); Swal finalize confirmation |
| `Sales/EditSaleItemDialog.vue` | Edit qty + negotiated price in modal; shows outlet price, floor, base amounts for multi-currency |
| `Sales/ShowSale.vue` | Sale detail; print buttons (A4, POS, PDF); New Sale button; Cancel; Till Session link; discount + delivery + after-discount transparency in summary |

**Receipt document** (`resources/views/documents/sales/receipt.blade.php`):
- Dual-format: `?paper=a4` (default) and `?paper=thermal` (80mm POS printers — Xprinter, Epson)
- QR code via `bacon/bacon-qr-code` — encodes sale reference
- Shows: receipt number (primary) + sale reference (secondary), combined items (lots merged by product), discount with after-discount base, tax, delivery, totals, payment details, serial numbers (A4 only)

**Navigation** (`resources/js/data/navigation/sales.ts`): Dashboard, Till Sessions, Sales, Customers, Tax Rates, Discount Configurations, Product Catalogue, Back to Modules

---

### Key Design Decisions (Phase 11)

| Decision | Rationale |
|---|---|
| DB-backed draft items | Page-refresh safe; allows multi-lot spanning and real-time stock reservation |
| Immediate stock reservation on item add | Ensures available stock is accurate across concurrent cashiers |
| Multi-lot FIFO spanning | `selectFifoLots()` loops across lots until requested qty fulfilled |
| Tax snapshotted at header creation | `applicable_tax_rate` + `applicable_tax_is_inclusive` frozen on sale record |
| Discount applied before tax | Standard retail practice; taxable base = subtotal − discount |
| Delivery persisted before finalize | `updateDelivery()` saves + calls `recalculateTotals()`; finalize reads from sale |
| Expected cash uses `amount_base` | Prevents multi-currency mixing; change_given NOT subtracted (already excluded from amount_source) |
| Serial reconciliation table | Serialized products sold by quantity create records for post-sale serial assignment |
| Till close blocked on pending sales | Guard in `CloseTillSessionRequest` and `TillSessionService::close()` |

---

### Tests Written (Phase 11)

```
tests/Feature/Sales/TillSessionServiceTest.php  — 5 tests
tests/Feature/Sales/SaleServiceTest.php         — 6 tests
```

---

## Phase 12 — Sales: Instalment Plans & Returns

**Prerequisites:** Phase 11

### Backend

```bash
php artisan make:controller Sales/InstalmentPlanController
php artisan make:controller Sales/InstalmentPaymentController
php artisan make:controller Sales/SalesReturnController --resource
php artisan make:controller Sales/SalesReturnItemController
php artisan make:request Sales/StoreInstalmentSaleRequest
php artisan make:request Sales/ApproveInstalmentSaleRequest
php artisan make:request Sales/StoreInstalmentPaymentRequest
php artisan make:request Sales/StoreSalesReturnRequest
php artisan make:policy Sales/InstalmentPlanPolicy --model=InstalmentPlan
php artisan make:policy Sales/SalesReturnPolicy --model=SalesReturn
php artisan make:service Sales/InstalmentService
php artisan make:service Sales/SalesReturnService
php artisan make:command ProcessInstalmentForfeiture
php artisan make:command AccrueStorageFees
```

**`InstalmentService`:**
- `createPlan(Sale, array $planData): InstalmentPlan` — computes `clearance_deadline` from org instalment schedule config; reserves stock (`stock_levels.quantity_reserved++`)
- `approve(Sale): void` — sets `sale.status='active'`; only manager/owner role can call
- `recordPayment(InstalmentPlan, array $paymentData): InstalmentPayment` — updates `sale.amount_paid`; if fully paid: unreserves stock, sets status='fully_paid', posts journal (Customer Deposits DR / Sales Revenue CR; COGS DR / Inventory — Outlets CR)
- `forfeit(InstalmentPlan): void` — unreserves stock; computes refund; sets sale.status='forfeited'; posts journal

**`SalesReturnService::process(SalesReturn): void`** (DB transaction):
1. Assert status = 'approved'
2. For each return item:
   - If condition='resaleable' or 'damaged': `StockMovementService::receive()` back to restocking_location
   - If condition='write_off': `StockMovementService::adjust()` with reason='write_off'
   - If product is_serialised: `SerialNumberService::markReturned()`
3. Compute refund_amount
4. Set status='processed'
5. Post journals: reverse relevant portion of original sale

**`ProcessInstalmentForfeiture` command** — runs daily; queries overdue plans and calls `InstalmentService::forfeit()`.

**`AccrueStorageFees` command** — for daily_accrual plans past clearance_deadline: `storage_fees_accrued += storage_fee_amount`; post Storage Fees Payable CR / Storage Fee Income DR journal.

### Frontend

Pages in `resources/js/pages/Sales/`:
- `Instalments/PendingApprovals.vue` — manager view: approve/reject instalment requests
- `Instalments/ShowInstalmentPlan.vue` — payment history, days remaining, storage fees
- `Instalments/RecordPayment.vue`
- `Returns/SalesReturnsPage.vue`
- `Returns/CreateReturn.vue` — select original sale, select items to return, condition per item
- `Returns/ShowReturn.vue`

### Tests

```bash
php artisan make:test Feature/Sales/InstalmentTest
php artisan make:test Feature/Sales/InstalmentForfeitureTest
php artisan make:test Feature/Sales/SalesReturnTest
```

Test: instalment reserves stock on plan creation; unreserves on forfeiture.
Test: forfeiture computes `refund = amount_paid - storage_fees_accrued`.
Test: partial instalment payment updates `amount_paid` and does not set 'fully_paid' prematurely.
Test: return of resaleable item reinstates stock at correct location.
Test: return journal reverses the original sale's COGS entry.

---

## Phase 13 — Finance: COA, Journals, Fiscal Periods, Expenses

**Prerequisites:** Phase 1 (fiscal periods created on org setup)

### Backend

```bash
php artisan make:controller Finance/FiscalPeriodController --resource
php artisan make:controller Finance/ChartOfAccountController --resource
php artisan make:controller Finance/JournalEntryController --resource
php artisan make:controller Finance/JournalEntryLineController
php artisan make:controller Finance/ExpenseController --resource
php artisan make:request Finance/StoreFiscalPeriodRequest
php artisan make:request Finance/StoreChartOfAccountRequest
php artisan make:request Finance/StoreManualJournalRequest
php artisan make:request Finance/PostJournalRequest
php artisan make:request Finance/ReverseJournalRequest
php artisan make:request Finance/StoreExpenseRequest
php artisan make:request Finance/ApproveExpenseRequest
php artisan make:policy Finance/FiscalPeriodPolicy --model=FiscalPeriod
php artisan make:policy Finance/JournalEntryPolicy --model=JournalEntry
php artisan make:policy Finance/ExpensePolicy --model=Expense
php artisan make:service Finance/JournalPostingService
php artisan make:service Finance/JournalReversalService
php artisan make:service Finance/ExpenseService
php artisan make:command CloseFiscalPeriods
```

**`JournalPostingService::post(JournalEntry): void`:**
1. Assert entry is draft (posted_at = null)
2. Assert fiscal_period.status = 'open' (if fiscal_period_id set)
3. Assert `SUM(debit lines) === SUM(credit lines)` — throw `UnbalancedJournalException` if not
4. Set `posted_at = now()`
5. Update `bank_accounts.current_balance` for any journal line touching a bank account

**`JournalReversalService::reverse(JournalEntry, string $reason): JournalEntry`:**
1. Assert original entry is posted and not already reversed
2. Create new JournalEntry with swapped debits/credits, `reversal_of_id = original.id`
3. Set `original.is_reversed = true`, `original.reversed_at = now()`
4. Post the new reversal entry

**`ExpenseService::approve(Expense): void` + `::post(Expense): void`:**
Posting creates a journal: Expense Account DR / Cash & Bank (or Accrued Liabilities) CR.

### Frontend

Pages in `resources/js/pages/Finance/`:
- `Dashboard.vue` — total posted entries, open fiscal periods, pending approvals
- `FiscalPeriods/FiscalPeriodsPage.vue` — month/quarter/year view; open/close actions
- `Accounts/ChartOfAccountsPage.vue` — tree view of account hierarchy
- `Journals/JournalEntriesPage.vue` — date-filterable ledger
- `Journals/CreateJournalEntry.vue` — manual entry: add debit/credit lines, running balance shown
- `Journals/ShowJournalEntry.vue` — detail with source document link, reversal history
- `Expenses/ExpensesPage.vue`
- `Expenses/CreateExpense.vue`
- `Expenses/ShowExpense.vue`

### Tests

```bash
php artisan make:test Feature/Finance/JournalPostingTest
php artisan make:test Feature/Finance/JournalReversalTest
php artisan make:test Feature/Finance/ExpenseTest
php artisan make:test Feature/Finance/FiscalPeriodTest
```

Test: unbalanced journal cannot be posted.
Test: posting to a closed fiscal period rejected.
Test: reversal creates equal-and-opposite entry; original marked is_reversed.
Test: expense posting creates correct journal (DR expense account, CR bank).
Test: bank_accounts.current_balance updates on post of journal line touching that account.

---

## Phase 14 — Finance: Bank Reconciliation & Reporting

**Prerequisites:** Phase 13

### Backend

```bash
php artisan make:controller Finance/BankAccountController --resource
php artisan make:controller Finance/BankReconciliationController --resource
php artisan make:controller Finance/ReportController
php artisan make:request Finance/StoreBankAccountRequest
php artisan make:request Finance/StoreBankReconciliationRequest
php artisan make:service Finance/BankReconciliationService
php artisan make:service Finance/ReportingService
```

**`BankReconciliationService::reconcile(BankReconciliation): void`:**
- Compute `difference = statement_balance - book_balance`
- If difference = 0: set status='reconciled'
- If difference ≠ 0: leave as 'draft'; surface uncleared items list (journal lines in the period not yet cleared)

**`ReportingService`** generates:
- `trialBalance(int $orgId, string $asAt): array`
- `profitAndLoss(int $orgId, Carbon $from, Carbon $to): array`
- `balanceSheet(int $orgId, string $asAt): array`
- `cashFlowStatement(int $orgId, Carbon $from, Carbon $to): array`

All reports aggregate from posted, non-voided `journal_entry_lines` joined to `chart_of_accounts`.

### Frontend

Pages in `resources/js/pages/Finance/`:
- `Banks/BankAccountsPage.vue`
- `Banks/BankAccountForm.vue`
- `Reconciliation/ReconciliationsPage.vue`
- `Reconciliation/CreateReconciliation.vue` — statement balance entry + uncleared items table
- `Reports/TrialBalance.vue`
- `Reports/ProfitAndLoss.vue`
- `Reports/BalanceSheet.vue`

Each report page has: date range picker, export to PDF/Excel/CSV buttons.

### Tests

```bash
php artisan make:test Feature/Finance/BankReconciliationTest
php artisan make:test Feature/Finance/TrialBalanceTest
php artisan make:test Feature/Finance/ProfitAndLossTest
```

Test: trial balance debits = credits after any posted journal.
Test: bank reconciliation marks as reconciled only when difference = 0.

---

## Phase 15 — HR

**Prerequisites:** Phase 1

### Backend

```bash
php artisan make:controller Hr/EmployeeController --resource
php artisan make:request Hr/StoreEmployeeRequest
php artisan make:policy Hr/EmployeePolicy --model=Employee
php artisan make:factory EmployeeFactory
```

Salary stored encrypted. Never log or expose raw salary values in error messages or API responses.

### Frontend

Pages in `resources/js/pages/Hr/`:
- `Employees/EmployeesPage.vue`
- `Employees/CreateEmployee.vue`
- `Employees/ShowEmployee.vue`

---

## Phase 16 — Notifications & Alerts

**Prerequisites:** Phases 11 + 12 + 7 + 9

### Backend

```bash
php artisan make:notification Sales/InstalmentDeadlineReminder
php artisan make:notification Sales/InstalmentOverdueAlert
php artisan make:notification Sales/NearingForfeitureAlert
php artisan make:notification Inventory/LowStockAlert
php artisan make:notification Inventory/TransferOrderApprovalRequired
php artisan make:notification Sales/InstalmentApprovalRequired
php artisan make:notification Logistics/ShipmentStatusChanged
php artisan make:notification Logistics/TaxClearanceCompleted
php artisan make:notification LocalProcurement/LpoDeliveryOverdue
```

All notifications extend `Illuminate\Notifications\Notification` and implement `via()` returning `['database']` (plus `'mail'` / `'vonage'` for SMS as configured).

**Customer-facing notifications** (SMS + email): payment reminders, confirmations, collection-ready alerts.

### Frontend

The notification system UI already exists (`resources/js/pages/Notifications/`). Extend by ensuring each new notification type has a readable title/body in the existing `NotificationDetail.vue`.

---

## Phase 17 — Reporting & Analytics

**Prerequisites:** All operational phases complete

### Backend

```bash
php artisan make:controller Reports/ProcurementReportController
php artisan make:controller Reports/InventoryReportController
php artisan make:controller Reports/SalesReportController
php artisan make:controller Reports/FinanceReportController
```

Each report controller returns Inertia responses with pre-aggregated data. Heavy reports use `Queue::push()` to compute async and return a download link.

**Key reports per domain:**

| Domain | Report |
|---|---|
| Procurement | Sourcing trip summary, supplier spend by currency, unfulfilled items |
| Logistics | Shipment tracker, freight cost history, CFA turnaround time |
| Costing | Landed cost breakdown per shipment, margin analysis per lot |
| Inventory | Stock valuation at cost (FIFO / WAC), movement ledger, dead stock, reorder list |
| Local Procurement | LPO status, supplier invoice ageing (AP ageing) |
| Production | Cost breakdown by type, unit cost trend |
| Sales | Revenue by period/location, instalment ageing, gross margin, top customers |
| Finance | P&L, balance sheet, trial balance, cash flow, VAT report |

**Export format:** Use `maatwebsite/excel` (or equivalent) for Excel; `barryvdh/laravel-dompdf` for PDF.

---

## Phase 18 — Subscriptions, Billing & Multi-Org Admin

**Prerequisites:** All previous phases

This phase is optional for internal/beta deployments. Adds Flutterwave + Stripe billing, org plan enforcement (location limits, user limits), and the super-admin cross-org management interface.

```bash
php artisan make:controller Admin/SubscriptionController
php artisan make:controller Admin/PlatformAdminController
php artisan make:service Billing/FlutterwaveService
php artisan make:service Billing/StripeService
php artisan make:service Billing/SubscriptionService
```

---

## Cross-Phase Standards

### Every controller must

- Use a Form Request for validation (no inline `$request->validate()`)
- Authorize via a Policy (`$this->authorize()` or `Gate::authorize()`)
- Return an Inertia response or a redirect with flash
- Never call `Model::all()` — always paginate or scope

### Every service must

- Be injected via the constructor (never `new Service()` in controllers)
- Wrap multi-step writes in `DB::transaction()`
- Throw domain-specific exceptions (not generic `\Exception`)
- Have no knowledge of HTTP request/response objects

### Every feature test must

```php
// Structure
public function test_it_does_x(): void
{
    // Arrange
    $org  = Organization::factory()->create();
    $user = User::factory()->for($org)->create();
    $this->actingAs($user);

    // Act
    $response = $this->post(route('sales.store'), [...]);

    // Assert
    $response->assertRedirect();
    $this->assertDatabaseHas('sales', [...]);
}
```

- Use `actingAs($user)` — never bypass auth
- Assert DB state, not just HTTP status
- Never use `RefreshDatabase` alone on integration tests that require seeded data — use `RefreshDatabase` + a `setUpOnce` seeder call

### Wayfinder

After adding new controller routes, run:
```bash
php artisan wayfinder:generate
```
Or let the Vite plugin pick it up in `npm run dev`. Import generated functions in Vue:
```ts
import { store } from '@/actions/Sales/SaleController'
// then: router.post(store(), payload)
```

### Pint (run before every PR)

```bash
vendor/bin/pint --dirty --format agent
```

---

## Dependency Map

```
Phase 1  (Org Config)
├── Phase 2  (Product Catalogue)
│   ├── Phase 4  (Int'l Procurement) ──→ Phase 5 (Logistics) ──→ Phase 6 (Costing)
│   └── Phase 10 (Production)                                        │
├── Phase 3  (Suppliers/Customers)   ──→ Phase 4               ──────┤
│   └── Phase 9  (Local Procurement)                                  │
└── Phase 7  (Inventory: Lots) ←─────────────────────────────────────┘
    └── Phase 8  (Transfers/Stock Takes)
        └── Phase 11 (Sales: Full Payment)
            └── Phase 12 (Instalments & Returns)
                └── Phase 13 (Finance: COA/Journals)
                    └── Phase 14 (Banks/Reports)
Phase 15 (HR) — independent after Phase 1
Phase 16 (Notifications) — after Phases 11 + 12 + 7 + 9
Phase 17 (Reporting) — after all operational phases
Phase 18 (Billing) — last
```
