# Tradixor — Product Requirements Document

**Version:** 2.0.0
**Date:** June 2026
**Classification:** Internal — Engineering & Product
**Tagline:** *From source to sale.*

---

## Revision History

| Version | Date | Author | Summary |
|---------|------|--------|---------|
| 1.0.0–1.5.0 | Mar 2026 | Product | Initial PRD (pre-implementation, speculative) |
| 2.0.0 | Jun 2026 | Engineering | Full rewrite — documents implemented system; Phases 1–6 live |
| 2.1.0 | May 2026 | Engineering | Phase 7 complete — Inventory: Lots, Stock Levels, Movements, Opening Stock, Pricing History, Bulk Import |
| 2.2.0 | May 2026 | Engineering | Phase 8 complete — Inventory: Transfers (serial-based dispatch/receive), Stock Takes (serial + manual counting modes), Serial Numbers (universal), Lot-based Stock Levels, Direct Adjustment, Inventory Documents & Reports |
| 2.3.0 | May 2026 | Engineering | Phase 9 complete — Local Procurement: LPOs, GRNs, three-way match, supplier invoices (advance/delivery/direct), returns, credit notes, payments, documents & reports |
| 2.4.0 | May 2026 | Engineering | Phase 10 complete — Production: BOMs, production orders with full approval workflow, estimated vs actual cost lines, lot-level material consumptions, completion with inventory lot creation, dashboard with charts, documents & reports |

---

## Table of Contents

1. [Executive Summary](#1-executive-summary)
2. [Product Vision & Strategic Goals](#2-product-vision--strategic-goals)
3. [System Architecture](#3-system-architecture)
4. [Technology Stack](#4-technology-stack)
5. [Core Platform](#5-core-platform)
6. [Phase 1 — Organization & Configuration ✅ Live](#6-phase-1--organization--configuration)
7. [Phase 2 — Product Catalogue ✅ Live](#7-phase-2--product-catalogue)
8. [Phase 3 — Supplier & Customer Registry ✅ Live](#8-phase-3--supplier--customer-registry)
9. [Phase 4 — International Procurement ✅ Live](#9-phase-4--international-procurement)
10. [Phase 5 — Logistics & Shipping ✅ Live](#10-phase-5--logistics--shipping)
11. [Phase 6 — Costing & Landed Cost ✅ Live](#11-phase-6--costing--landed-cost)
12. [Phase 7 — Inventory: Lots, Stock Levels, Movements ✅ Live](#12-phase-7--inventory-lots-stock-levels-movements)
13. [Phase 8 — Inventory: Transfers, Stock Takes, Serial Numbers ✅ Live](#13-phase-8--inventory-transfers-stock-takes-serial-numbers)
14. [Phase 9 — Local Procurement ✅ Live](#14-phase-9--local-procurement)
15. [Phase 10 — Production ✅ Live](#15-phase-10--production)
16. [Roadmap — Phases 11–18](#16-roadmap--phases-1118)
17. [Engineering Standards](#17-engineering-standards)
18. [Brand Identity & Design System](#18-brand-identity--design-system)
19. [Roles & Permissions Matrix](#19-roles--permissions-matrix)
20. [Non-Functional Requirements](#20-non-functional-requirements)
21. [Glossary](#21-glossary)

---

## 1. Executive Summary

Tradixor is a cloud-based, multi-tenant SaaS ERP for importers, distributors, retailers, and producers who manage goods across international and domestic supply chains. The platform unifies the full product lifecycle: from sourcing trips abroad or local purchases, through shipping and customs clearance, into warehoused inventory, and on to point-of-sale — all with integrated double-entry accounting.

Each subscribing business operates as a fully independent **Organization** (tenant) with its own users, data, subscription, and configuration. As of May 2026, Phases 1–10 are live and production-ready: organization setup, product catalogue, supplier/customer registry, international procurement, logistics & shipping, costing/landed-cost engine, inventory management (lots, serial numbers, stock levels, movements, opening stock), the full inventory operations layer (transfers, stock takes, serial write-offs, direct stock adjustment, inventory documents & reports), local procurement (LPOs, GRNs, three-way match, supplier invoices, returns, credit notes, payments, documents & reports), and production (BOMs, production orders with full approval workflow, estimated vs actual cost tracking, lot-level material consumptions, completion with inventory lot creation, dashboard with analytics charts, and document/report generation).

---

## 2. Product Vision & Strategic Goals

> *Enable any importer, distributor, retailer, or producer to run a clean, insight-rich, paperless business — from the market floor in Guangzhou to the outlet shelf in Kampala.*

| Goal | Metric |
|---|---|
| Eliminate manual errors in landed cost calculation | < 0.5% allocation discrepancy per stock entry |
| Real-time inventory visibility across locations | < 30-second sync latency |
| Zero unbalanced journal entries | Enforced at service + DB constraint level |
| Support annual tax filing compliance | Exportable trial balance and P&L on demand |
| Scale to 500+ organizations without architecture changes | P95 API response < 500ms |
| Unified source for all inventory regardless of origin | Import, local purchase, production, and opening stock all produce `InventoryLot` records with landed costs |

---

## 3. System Architecture

Tradixor is a **modular monolith**. Domain boundaries are enforced through dedicated service classes; no domain directly joins another domain's tables. Inter-domain communication uses service contracts and shared surrogate IDs.

```
┌──────────────────────────────────────────────────────────────┐
│                      TRADIXOR PLATFORM                       │
│                                                              │
│  Organization (independent tenant, scoped globally)          │
│                                                              │
│  ┌──────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │ Int'l Procur.│  │  Logistics  │  │   Costing   │         │
│  └──────┬───────┘  └──────┬──────┘  └──────┬──────┘         │
│         │                 │                │                 │
│  ┌──────▼───────┐  ┌──────▼──────┐         │                 │
│  │ Local Procur.│  │  Production │─────────┘                 │
│  └──────┬───────┘  └──────┬──────┘                           │
│         │                 │                                  │
│  ┌──────▼─────────────────▼──────┐                           │
│  │         Inventory             │                           │
│  └──────────────┬────────────────┘                           │
│                 │                                            │
│  ┌──────────────▼────────────────┐                           │
│  │        Sales & Finance        │                           │
│  └───────────────────────────────┘                           │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Platform: Auth · Billing · Notifications ·            │  │
│  │  Reference Data · Media · Audit Log · Documents        │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘
```

### The Inventory Lot as Universal Cost Unit

Every product entering the system — however it arrives — creates an **InventoryLot** with a known `landed_cost_per_unit`:

| `source_type` | Source document | Cost set by |
|---|---|---|
| `import` | `shipments` | Costing domain allocation engine |
| `local_purchase` | `local_purchase_orders` | LPO item unit cost |
| `production` | `production_orders` | Total production cost ÷ qty produced |
| `opening_stock` | None (null) | Manual entry by user |

---

## 4. Technology Stack

| Layer | Technology | Version |
|---|---|---|
| Backend framework | Laravel | 13 |
| PHP | PHP | 8.5 |
| Frontend framework | Vue 3 (Composition API + TypeScript) | 3.x |
| Frontend routing | Inertia.js v3 | 3.x |
| UI components | shadcn-vue + Tailwind CSS v4 | 4.x |
| Database | PostgreSQL (primary) | 15+ |
| Cache & queues | Redis + Laravel Horizon | — |
| File storage | Spatie Media Library + S3-compatible | 11.x |
| Charts | Chart.js (via BarChart/LineChart/DoughnutChart.vue) | 4.x |
| PDF generation | barryvdh/laravel-dompdf | 3.x |
| Excel import/export | maatwebsite/excel | — |
| Code quality | Laravel Pint + ESLint + Prettier | — |
| Named routes (TypeScript) | laravel/wayfinder | 0.x |
| Deployment | Laravel Forge + Hetzner / DigitalOcean | — |

---

## 5. Core Platform

### 5.1 Tenancy & Multi-Tenancy

Every record in every business table carries `organization_id`. A global Eloquent scope (`BelongsToOrganization` trait) automatically filters all queries to the authenticated user's organization. A user from Org A can never retrieve Org B's data — even with a guessable ID — because the global scope returns 404 before any authorization check runs.

```php
// BelongsToOrganization trait — applied to all business models
static::addGlobalScope('organization', function (Builder $q) {
    $q->where('organization_id', auth()->user()->organization_id);
});
```

**Escape hatches:** `withoutGlobalScope('organization')` for admin queries; `scopeAllOrganizations()` for super-admin.

### 5.2 ID Strategy

All tables use `bigint` auto-increment primary keys. Security through ID opacity is explicitly rejected — tenant scoping is the security mechanism, not unguessable IDs.

**Business document references** (trip_reference, shipment_reference, pi_reference, etc.) are generated by `SysGeneratorService` using timestamp + random characters (e.g., `TRIP-2026001530K7XP`) — unguessable, collision-resistant, human-readable.

### 5.3 Universal Audit Columns

Every table carries three audit columns in addition to `created_at` / `updated_at`:

| Column | Type | Purpose |
|---|---|---|
| `created_by` | bigint, nullable, FK → users | User who created the record |
| `updated_by` | bigint, nullable, FK → users | User who last modified |
| `creator_organization_id` | bigint, nullable, FK → organizations | Org context at creation |

Auto-populated by the `HasAuditColumns` trait. Nullable to accommodate system-generated records, scheduled jobs, and seeded data.

### 5.4 Reference Data

**Platform-managed (mutations: admin org only; reads: all orgs):**
- **Countries** — 90 countries (full Africa + major trading nations); natural FK `country_code` (ISO 3166-1 alpha-2)
- **Currencies** — 35 currencies (all major + African + key import-source currencies); natural FK `currency_code` (ISO 4217)
- **Shipping Ports** — 33 ports (Chinese sea/air, UAE hubs, Singapore, Indian, East African, inland ICDs); `sys_code` + UN/LOCODE `code` (unique); seeded idempotently via `upsert` on `code`

**Organization-scoped:**
- **Units of Measure** — 18 UOMs seeded platform-wide and copied into every new org at signup; org can add custom UOMs
- **Product Categories** — unlimited depth via self-referencing parent; unique slug per org; soft-delete only

### 5.5 Users & Authentication

| Field | Notes |
|---|---|
| `email` | Unique platform-wide |
| `organization_id` | Tenant scope anchor |
| `is_super_admin` | Platform-level; bypasses org scope |
| `is_organization_admin` | Org-level admin |
| `two_factor_auth_enabled` | TOTP / SMS / email 2FA via Laravel Fortify |
| `password_expires_at` | Optional password expiry policy |
| `is_active` | Soft disable without deleting the user |
| `avatar` / `signature` | Via Spatie Media Library |

Authentication via Laravel Fortify. Email verification enforced. Fortify handles 2FA challenge flow.

### 5.6 Signed URLs

Unauthenticated public contexts use Laravel signed URLs:

| Use Case | Mechanism |
|---|---|
| Customer receipt share | `URL::signedRoute()` with expiry |
| Private file downloads | Spatie temporary signed URL |
| Email verification | Laravel built-in signed URL |
| Payment gateway callback | `URL::signedRoute()` |

---

## 6. Phase 1 — Organization & Configuration

**Status:** ✅ Live | **Prerequisites:** None

Covers all configuration a new tenant completes before operating any module.

### 6.1 Organization Onboarding Flow

1. Owner registers: name, email, password, organization name, country, base currency
2. System creates Organization + User (owner), generates URL slug, calls `OrganizationSeederService::seedForOrganization()`
3. Seeder copies platform UOMs, seeds default COA, creates fiscal periods for current year
4. Owner configures settings, invites staff, assigns designations

### 6.2 Configuration Entities

All follow the `index / store / update / toggleStatus / destroy` controller pattern with org scoping.

| Entity | Controller | Notes |
|---|---|---|
| Designations | `DesignationController` | Job titles; 42 platform-seeded across 8 departments |
| Departments | `DepartmentController` | Org-scoped organizational units |
| Product Locations | `ProductLocationController` | Type: `warehouse` or `outlet`; `outlet` type used for sales |
| Shipping Agents | `ShippingAgentController` | International freight companies |
| Clearing & Forwarding Agents | `ClearingForwardingAgentController` | Customs clearance agents |
| Haulage Companies | `HaulageCompanyController` | Last-mile delivery companies |
| Insurance Companies | `InsuranceCompanyController` | Cargo insurance providers |
| Shipping Ports | `ShippingPortController` | Admin org only for mutations; readable by all |

### 6.3 `SysGeneratorService`

36 static methods generating reference codes for all entities:
- **Microtime-based** (internal): ORG, DEPT, USR, PROD, PCAT, SUP, etc. — fast, no DB round-trip
- **Timestamp + random** (business documents): TRIP, SHP, PI, LPO, PRD, etc. — unguessable, collision-resistant

---

## 7. Phase 2 — Product Catalogue

**Status:** ✅ Live | **Prerequisites:** Phase 1

### 7.1 Product Category

Unlimited hierarchy via self-referencing `parent_id`. Auto-generated URL-safe `slug` per org. Destroy guarded against categories with children or attached products.

### 7.2 Product

| Field | Notes |
|---|---|
| `sku` | Auto-generated if not provided; unique per org |
| `name` + `variant_label` | Each variant is its own product record |
| `uom` | String; inherited from org's UOM list |
| `is_serialised` | Required. Non-raw-material products (`finished_good`, `both`) are automatically locked to `true` — enforced at create, update, and import. Cannot be set to `false` if the product already has serial numbers assigned. |
| `weight_kg`, `length_cm`, `width_cm`, `height_cm` | Required for freight weight/volume allocation |
| `minimum_stock_level` / `reorder_quantity` | Alert thresholds for inventory management |
| `product_photos` | Spatie gallery collection; responsive conversions (thumb 200×200, preview 800×800) |
| `product_thumbnail` | Spatie single hero image |
| `costing_method` | FIFO or WAC; required; inherited by all lots of this product at creation time |
| `landed_cost_per_unit` | Standard/reference cost; auto-populated in opening stock dialog; inherited as baseline for lot creation |
| `selling_price`, `selling_price_method`, `outlet_price`, `outlet_price_method`, `discount_cap` | Pricing fields managed by `ProductPricingService`; `discount_cap` is required |

### 7.3 UI Highlights

- **Tile view / table view toggle** — stored in localStorage per user
- **Tile view** groups products by category with debounced server-side search
- **ShowProduct.vue** — horizontal photo carousel with lightbox (keyboard navigation, thumbnail strip); **Pricing History** section (table: Date | Method | Selling Price | Outlet Price | Discount Cap | Reason | Changed By)
- **ProductFormDialog.vue** — reason selector in edit mode (shown via `isPricingDirty` computed only when pricing fields changed); `is_serialised` toggle disabled for non-raw-materials and products with existing serials
- **Import** — bulk product import via Excel template; `category_name` lookup (case-insensitive); preview before processing

### 7.4 Product Pricing Service

`ProductPricingService` (`app/Services/System/ProductPricingService.php`) owns all product pricing computation and history:
- `setPricing(Product, array, string $reason): Product` — calculates `selling_price`, `outlet_price`, `discount_cap` from the chosen method; nullifies unused method fields; writes a `product_pricing_history` record on every call
- `previewPricing(float $landedCost, array): array` — live UI preview without persisting

Reason is required when pricing actually changes; the controller uses `hasPricingChanged()` to gate the requirement; new products use "Initial pricing" automatically.

**Product pricing is the single source of truth for all inventory lots.** Lots receive an immutable snapshot at creation time via `Product::pricingSnapshot(float $landedCost)`, which also auto-corrects `discount_cap` to `$landedCost` when the product's cap is below the lot's actual cost.

---

## 8. Phase 3 — Supplier & Customer Registry

**Status:** ✅ Live | **Prerequisites:** Phase 1

### 8.1 Supplier

Unified table for international and domestic suppliers:

| Field | Notes |
|---|---|
| `is_local` | `false` = international (sourcing trips), `true` = domestic (LPOs) |
| `country_code` | FK → countries |
| `contact_name`, `contact_phone`, `contact_email` | Three separate contact fields |
| `currency_code` | Preferred trading currency |
| `payment_terms` | Free text |
| `sys_code` | Auto-generated by `SysGeneratorService::supplierCode()` |

### 8.2 Customer

| Field | Notes |
|---|---|
| `id_number` | Encrypted at rest via Laravel `encrypted` cast; stored in TEXT column (cipher > varchar(100)) |
| `country_code` | FK → countries |

---

## 9. Phase 4 — International Procurement

**Status:** ✅ Live | **Prerequisites:** Phases 2 + 3

### 9.1 Entities & Status Flows

**Sourcing Trip:**
```
draft → in_progress → sourcing_complete → shipped → closed
                           ↑                ↓
                           └── (revert allowed) ──┘
```

Key fields: `source_currency_code` + `exchange_rate` (required; defines trading currency inherited by all items/PIs/payments); `destination_countries` (JSON array of ISO codes e.g. `["CN","AE"]`); `estimated_total_expenditure` (user budget in source CCY); team members via `sourcing_trip_members`.

**Sourcing Trip Item:** Four estimated-price columns (user-entered + server-computed) plus four actual-price columns. `source_currency_code` and `exchange_rate` inherited from trip and enforced on every save regardless of submitted values. `shippable_quantity` denormalized column: decremented when added to shipment, incremented when removed.

**Proforma Invoice:**
```
draft → sent → confirmed → paid (auto when amount_paid ≥ total_amount)
                        ↘ expired / cancelled
```

`amount_paid` tracked in PI's own currency (not base currency). Confirmed and paid PIs cannot be edited.

**Supplier Payment:** `amount` in `payment_currency_code`; `amount_base` server-computed. Payments on a `paid` PI cannot be deleted.

### 9.2 Business Rules Enforced

| Rule | Enforcement point |
|---|---|
| Single source currency per trip — all items/PIs inherit it | `SourcingTripItemController` overrides currency on every save |
| Cannot mark sourcing_complete if any item is 'planned' or missing supplier+price | `SourcingTripService::validateSourcingComplete()` |
| Items with shipment items cannot be edited or deleted | `SourcingTripItemController::update()` / `destroy()` |
| Items on a proforma invoice cannot have supplier changed | `SourcingTripItemController::update()` |
| Items on a proforma invoice cannot be bulk-reassigned | `SourcingTripItemController::bulkAssignSupplier()` |
| PI auto-transitions to 'paid' when fully settled | `SupplierPaymentService::recordPayment()` |
| Payment deletion blocked on a 'paid' PI | `SupplierPaymentController::destroy()` |

### 9.3 Key Controllers & Services

| Component | Purpose |
|---|---|
| `SourcingTripController` | CRUD + `updateStatus` + `generatePI` |
| `SourcingTripItemController` | CRUD + `bulkAssignSupplier` + `bulkSource` + `downloadTemplate` + `previewImport` + `processImport` |
| `SourcingTripMemberController` | `store` + `destroy`; 14 predefined roles (Lead Buyer, QC Inspector, Logistics Coordinator, etc.) |
| `SourcingTripService` | `generateReference()`, `allowedNextStatuses()`, `validateSourcingComplete()`, `transitionStatus()` |
| `SupplierPaymentService` | `recordPayment()` — increments PI `amount_paid`; auto-transitions to 'paid' |

### 9.4 Frontend — ShowTrip.vue Highlights

- **Banner**: trip ref, status, dates, destination countries, cost summary (budget vs. estimated vs. actual, all in source CCY), team member chips
- **Country-of-origin filter**: when creating a shipment from ShowTrip, destination countries populate a selector; selected country filters the origin port dropdown
- **Supplier Summary section**: post-sourcing, groups items by supplier with amount due; Generate PI button or link to existing PI
- **BulkSourceDialog**: select N items → set `sourced_quantity` + `unit_source_price` per row; per-item "Use estimate" checkbox; "Apply estimates to all" shortcut; native checkboxes
- **Item guards**: shipped items show "Shipped" amber badge; edit/remove buttons hidden; PI items show disabled supplier field with note

### 9.5 Document Suite

| Document | Type | Available |
|---|---|---|
| T-1 Sourcing Trip Overview | Blade + DomPDF | Any status |
| T-2 Item Sourcing Report | Blade + DomPDF | `sourcing_complete`+ |
| T-3 Proforma Invoice | Blade + DomPDF | Per PI |

### 9.6 Procurement Dashboard (`/procurement/dashboard`)

Inertia v3 deferred props architecture:
- **Immediate** (blocking): 8 KPI COUNT/SUM queries rendered on first paint
- **Deferred** (background): `tripPipeline`, `shipmentPipeline`, `monthlyTrends` (12-month), `topSuppliers` (top 8 by PI value), `recentActivity` — each shows a pulsing skeleton until loaded
- **Period filter**: 30 / 90 / 180 / 365 days
- **Tabs**: Overview, Trips, Shipments, Financials, Suppliers

---

## 10. Phase 5 — Logistics & Shipping

**Status:** ✅ Live | **Prerequisites:** Phase 4

### 10.1 Entities & Status Flows

**Shipment:**
```
pending → in_transit → arrived → cleared → delivered
```
Cannot advance to `delivered` without `taxClearanceRecord.status = 'cleared'`. One sourcing trip can produce multiple shipments (e.g., multi-country trips, sea + air split). Shipments can be created while trip is still `in_progress`.

**Shipment Item:** Links `sourcing_trip_items` to a shipment; unique per (shipment, trip_item); `quantity_shipped` integer.

**Shipment Event:** Immutable audit log; `vessel_departed` event updates `shipment.actual_departure`; `destination_arrived` updates `shipment.actual_arrival`.

**Tax Clearance Record:**
```
pending → assessed → paid → cleared
```
Auto-created when shipment is created. Tracks government tax (assessed vs. paid) and CFA service fee separately.

**Haulage Record:** Optional; one or more per shipment. Tracks haulage company, driver, vehicle, route (ICD/port → warehouse), departure/arrival dates, cost. `markDelivered()` on the last haulage record finalizes last-mile delivery.

### 10.2 Key Business Rules

| Rule | Enforcement |
|---|---|
| Cannot deliver without cleared tax clearance | `ShipmentService::transitionStatus()` |
| Haulage is optional — delivery proceeds without it | `ShipmentService::transitionStatus()` |
| Trip advances to `shipped` when first shipment created | `ShipmentService::createShipment()` |
| Trip advances to `shipped` when marked sourcing_complete with shipments | `SourcingTripService::transitionStatus()` |
| `shippable_quantity` decremented on item add, incremented on item remove | `ShipmentService` |
| Sourcing trip items with any shipment items are locked from edit/delete | `SourcingTripItemController` |
| `cost_allocation_locked` prevents re-allocation after locking | Controller + frontend |

### 10.3 ShowShipment.vue — Tab Structure

| Tab | Content |
|---|---|
| Overview | Freight/insurance details; inline edit (pending/in_transit only) |
| Items | DataTable of shipment items; add/remove/bulk-add |
| Events | Chronological event log; add event dialog with suggested next event |
| Tax Clearance | Inline form for tax + CFA fee amounts, dates, clearance status |
| Haulage | List of haulage records; create/edit dialog |
| Cost Allocation | Per-type allocation: method selector + preview + confirm |
| Cost Summary | Landed cost per item: source price + each allocation type + total |

### 10.4 Document Suite

| Document | PDF? | Notes |
|---|---|---|
| S-1 Shipment Manifest | ✓ | Container, BoL, agent, route, items, insurance |
| S-2 Packing / Loading List | ✓ | Line items + physical count column + dual signature block |
| S-3 Shipment Event Log | — | Chronological audit trail |
| S-4 Customs Clearance Summary | ✓ | Tax authority ref, tax amounts, CFA fees |
| S-5 Haulage / Delivery Note | ✓ | Driver details, route, items, dual signature block |
| S-6 Landed Cost Report | — | Per-item cost breakdown: all allocation types + landed cost/unit |

All documents served via `ShipmentDocumentController`; same Blade template for browser-preview (`return view(...)`) and DomPDF download (`Pdf::loadView()->download()`).

### 10.5 Shared Document Layout (`_layout.blade.php`)

Blade layout under `resources/views/documents/procurement/` shared by all procurement + logistics documents:
- A4 page setup with `@page` CSS margins
- `@bottom-left` copyright + `@bottom-right` page numbers via CSS Paged Media
- Running footer via `position: running(footer)` with `position: fixed` fallback (DomPDF compatible)
- Page break utilities: `.page-break-before`, `.page-break-after`, `.avoid-break-inside`
- `table { table-layout: fixed; word-break: break-word }` — prevents table overflow
- `.table-compact` helper class for wide/dense tables (8pt font, tighter padding)
- Auto-print script (omitted for DomPDF `$isPdf = true` routes)

---

## 11. Phase 6 — Costing & Landed Cost

**Status:** ✅ Live | **Prerequisites:** Phase 5

### 11.1 Cost Allocation

`CostAllocationService::allocate(Shipment, $allocationType, $method, $options)` distributes one cost type across all shipment items:

| Method | Formula |
|---|---|
| `equal` (by quantity) | `total_cost / COUNT(items)` |
| `by_value` | `(item.total_source_cost_base / SUM) × total_cost` |
| `by_weight` | `(item.product.weight_kg × qty / total_weight) × total_cost` |
| `by_volume` | `(item.product.cbm × qty / total_cbm) × total_cost` |
| `manual` | User-provided per item; validated: `SUM == total_cost` |

Weight and volume methods are disabled if any product in the shipment has null `weight_kg` or dimensions respectively.

**Allocation types:** `freight`, `insurance`, `clearing_fee`, `haulage`, `tax`, `other_charge`

### 11.2 Landed Cost Formula

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

`LandedCostCalculatorService::computeForShipment(Shipment)` returns this breakdown for every shipment item, ready for the Cost Summary tab and the Landed Cost Report.

### 11.3 Integration

`ShipmentController::show()` calls both services and passes `allocationStatus`, `landedCosts`, and `isFullyAllocated` to Inertia. The frontend Cost Allocation tab uses `AllocationDialog.vue` (method selector + preview table) and the Cost Summary tab renders the per-item breakdown inline.

---

## 12. Phase 7 — Inventory: Lots, Stock Levels, Movements

**Status:** ✅ Live | **Prerequisites:** Phase 6 (imports), Phase 3 (opening stock)

### 12.1 The InventoryLot Record

Every product entering inventory — from any source — produces an **InventoryLot** with a known `landed_cost_per_unit`. All inventory writes go exclusively through `StockMovementService`.

| Field group | Fields |
|---|---|
| Identity | `lot_number` (auto-generated), `source_type` (import / local_purchase / production / opening_stock), `source_id` (polymorphic) |
| Cost | `landed_cost_per_unit`, `costing_method` (FIFO / WAC — inherited from product at lot creation) |
| Pricing (snapshot) | `selling_price`, `selling_price_method`, `margin_pct`, `markup_amount`, `outlet_price`, `outlet_price_method`, `outlet_price_pct`, `outlet_price_fixed_above`, `discount_cap` — frozen from product at lot creation via `Product::pricingSnapshot()`; read-only after creation |
| Metadata | `batch_number`, `quantity_uom`, `received_date`, `expiry_date`, `received_location_id` |

### 12.2 Services

**`StockMovementService`** — the only class that writes to `stock_movements` and `stock_levels`:
- `receive(InventoryLot, ProductLocation)` — creates receipt movement, upserts stock level (WAC recalculates `average_cost`)
- `issue(Product, InventoryLot, qty, from, movementType, ...)` — debits stock level; raises `InsufficientStockException` if qty > on_hand
- `adjust(Product, ProductLocation, qty, reason, ?lot)` — positive (surplus) or negative (shortage); records adjustment movement

**`InventoryLotService`:**
- `createFromShipment(Shipment)` — asserts delivered + cost finalized; creates one lot per shipment item using pre-computed `landed_cost_per_unit_base`; calls `$product->pricingSnapshot($landedCost)` to stamp frozen pricing; calls `receive()`
- `createOpeningStock(array, orgId)` — manual entry; `costing_method` and pricing snapshot inherited from product (not user input)
- `createFromTransfer(...)` — inherits source lot's pricing snapshot (not re-querying the product, to preserve the pricing context of that specific stock)

**`Product::pricingSnapshot(float $landedCost): array`** (`app/Models/System/Product.php`):
- Returns the product's current pricing as a frozen array for stamping onto a new lot
- Auto-corrects `discount_cap` to `$landedCost` when the product's cap is below the lot's unit cost
- Called by all five lot-creation paths: `createFromShipment()`, `createOpeningStock()`, `GrnPostingService::post()`, `ProductionOrderService::complete()`, and `createFromTransfer()` (transfer uses source lot's snapshot)

### 12.3 Pricing Architecture

**Product is the single source of pricing truth.** All lot pricing is a frozen snapshot captured at creation time from the product's current pricing.

**Product pricing history** (`product_pricing_history` table):

| Column | Notes |
|---|---|
| `product_id` | FK → products |
| Full pricing snapshot | All pricing columns mirroring `products` table pricing fields |
| `reason` | Required when pricing actually changes; 19 predefined options (e.g., "Market price adjustment", "Supplier cost revision", "Promotional discount") |
| `changed_by` | FK → users |
| `changed_at` | Timestamp of the change |

Displayed in `ShowProduct.vue` "Pricing History" section. New products write "Initial pricing" automatically.

**Lot pricing (frozen snapshot):**
- Set once at lot creation via `Product::pricingSnapshot()`; never editable after that
- `discount_cap` auto-corrected to `landed_cost_per_unit` if the product's cap is below the lot's actual cost
- Displayed as read-only "Pricing Snapshot" tab in `ShowLot.vue` (amber notice if `discount_cap` was auto-corrected)
- No `SetPricingDialog`; no `lot_pricing_history` table; no `effective_from`/`effective_to` on lots

### 12.4 Opening Stock

Two entry methods:
1. **Form modal** (`OpeningStockDialog.vue`) — single lot; product + location + qty + `landed_cost_per_unit` + dates + `batch_number`; UoM displayed as read-only (inherited from product); `landed_cost_per_unit` auto-populated from product's standard cost (user can override for the specific batch); costing method inherited from product automatically
2. **Excel bulk import** (`OpeningStockImportDialog.vue`) — 4-step flow (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 auto-inherited from product; flexible date parsing including Excel serial numbers; comma-formatted numbers (e.g. `420,000.00`) accepted

### 12.5 Frontend Pages

| Page | Key features |
|---|---|
| `Inventory/Dashboard.vue` | Period filter presets; KPI Row 1 (counts); KPI Row 2 (valuation: at cost, at selling, at outlet, discount floor); 3 tabs (Overview, Valuation, Movements) with deferred loading |
| `Lots/LotsPage.vue` | `ServerPaginatedDataTable`; analytics cards; SearchableSelect filters (product, source type, location, expiry); "Opening Stock" modal button; "Import Stock" bulk import button |
| `Lots/ShowLot.vue` | Tabs: **Pricing Snapshot** (read-only; pricing frozen at lot creation; amber notice if `discount_cap` was auto-corrected), Movements, Serial Numbers; `EditLotMetaDialog` for updating expiry/batch/costing method |
| `StockLevels/StockLevelsPage.vue` | `ServerPaginatedDataTable`; analytics row (Products in Stock, Below Minimum, Reserved, Stock Value); valuation row (4 currency-formatted cards) |
| `Movements/MovementsPage.vue` | `ServerPaginatedDataTable`; analytics cards responding to active filters; from→to display uses movement type label when one side is null (e.g., "Receipt → Nakawa Store") |

### 12.6 Business Rules Enforced

| Rule | Enforcement |
|---|---|
| All inventory writes go through `StockMovementService` | Service is the only class that writes to `stock_movements` / `stock_levels` |
| `costing_method` cannot be changed after movements exist | `InventoryLotController::updateMeta()` + `EditLotMetaDialog` disabled state |
| Lot pricing is immutable after creation | Frozen snapshot from product at lot creation; no UI or endpoint allows post-creation changes to lot pricing |
| `discount_cap` auto-corrected on lots | `Product::pricingSnapshot()` sets `discount_cap = max(product.discount_cap, landed_cost_per_unit)` |
| Product pricing reason is mandatory on change | `UpdateProductRequest` requires `reason` when `hasPricingChanged()` is true; written to `product_pricing_history` |
| Cost allocation must be finalized before receiving into inventory | `InventoryLotService::createFromShipment()` checks `cost_allocation_finalized_at` |
| Stock cannot go negative | `StockMovementService::issue()` raises `InsufficientStockException` |
| Non-raw-material products are always serialised | `ProductController` + `ProductCatalogueImport` enforce `is_serialised = true`; cannot be disabled if serials exist |

### 12.7 Organisation Currency

`HandleInertiaRequests` shares `orgCurrency: { code, symbol, decimal_places }` globally on every Inertia response. The `useCurrency.ts` composable (`fmtMoney()`) applies the org's currency symbol to all monetary values across inventory and procurement dashboard pages.

---

## 13. Phase 8 — Inventory: Transfers, Stock Takes, Serial Numbers

**Status:** ✅ Live | **Prerequisites:** Phase 7

### 13.1 Transfer Orders

Serial-based inter-location inventory transfers with a full approval workflow.

**Status flow:** `draft → pending_approval → approved → in_transit → received | cancelled`

**Key features:**
- **Explicit approver selection** — requester chooses the approver at submission; only the designated approver can approve
- **Serial-based dispatch/receive** — scanner UX on dedicated pages; serials staged in DB (`pending_dispatch`/`pending_receipt`); `pending_transfer_order_id` FK tracks serials through transit and is cleared only at receipt
- **Destination lot creation** — `receive()` creates a new `InventoryLot` at the destination (`source_type='transfer'`), inheriting cost and pricing from the source lot; serial numbers are reassigned to the new lot
- **Variance tracking** — `dispatch_variance_reason` / `reception_variance_reason` per item; finalization blocked until all shortfall items have a reason
- **Full audit trail** — `requested_by`, `approved_by`, `dispatched_by`, `received_by` + four comment fields
- **Orphaned serial cleanup** — serials dispatched but not scanned for receipt have `pending_transfer_order_id` cleared on receipt confirmation; they remain `transferred` (lost in transit, documented via variance reason)

**Services:** `TransferOrderService` — submit(), approve(), scanDispatch(), unscanDispatch(), dispatch(), scanReceipt(), unscanReceipt(), receive(), cancel()

**Frontend:** ShowTransferOrder.vue (5 tabs), DispatchTransfer.vue (scanner + DataTable tabs), ReceiveTransfer.vue (scanner), SubmitTransferDialog.vue, ApproveTransferDialog.vue, VarianceReasonDialog.vue

**Documents:** Dispatch Note (HTML + PDF), Receipt Confirmation (HTML + PDF)

### 13.2 Lot-Based Stock Levels

`stock_levels` now carries `lot_id` (one row per lot per location). The unique constraint changed from `(product_id, location_id)` to `(lot_id, location_id)`. Two views:
- **By Product** — aggregate SUM across lots per product × location
- **By Lot** — individual lot rows with `active_serial_count` (in_stock + reserved serials); amber "⚠ Reconcile" badge when serial count ≠ `quantity_on_hand`

### 13.3 Stock Takes

Physical inventory counting with two modes:

- **Serial mode** — scan barcodes; each scanned serial sets `stock_take_id`; `finalizeCount()` computes `counted_quantity` from the scan count; DB-backed, survives refresh
- **Manual mode** — "Set Count" action per line item; explicit "Finalize Count" button

Both modes: posting blocked until all non-zero variances have a reason; reasons drawn from `StockTakeItem::VARIANCE_REASONS` (16 options aligned with the `adjustment_reason` enum).

**Services:** `StockTakeService` — initiate(), scanSerial(), unscanSerial(), finalizeCount(), updateItems(), post(), cancel()

**Frontend:** ShowStockTake.vue (scan form, two tabs: Stock Items / Counted Serials), counting mode selector at creation

**Documents:** Count Sheet (HTML + print), Variance Report (HTML + print)

### 13.4 Serial Numbers

- **Universal serialization** — all lots generate serial numbers on creation regardless of product `is_serialised` flag
- **Format** — `{lot_number}-{zero_padded_sequence}` e.g. `INV-260512-0001`
- **Statuses** — `in_stock`, `reserved`, `sold`, `transferred`, `written_off`, `pending_dispatch`, `pending_receipt`
- **Write-off design** — write-off from the Lot Details page is a serial status change only (no stock movement); stock level reconciliation happens via stock takes; this prevents double-counting when a stock take has already posted the adjustment
- **Print labels** — filtered to `in_stock + reserved` only; thermal-label-compatible (58mm × 40mm)

**Frontend:** SerialNumbersPage.vue (analytics with all 6 statuses, bulk write-off), Lot Details serials tab (DataTable with selectable rows, bulk write-off)

### 13.5 Direct Stock Adjustment (Decrease Only)

Users can reduce stock for a specific lot without a full stock take (e.g., disposing of a few damaged units). Increases must go through Opening Stock to ensure proper serial tracking. Stock level reconciliation is expected via the next stock take.

### 13.6 Inventory Documents & Reports

| Document / Report | Type | Route |
|---|---|---|
| Dispatch Note | HTML + PDF | `/inventory/transfers/{order}/documents/dispatch-note` |
| Receipt Confirmation | HTML + PDF | `/inventory/transfers/{order}/documents/receipt-note` |
| Lot Detail Sheet | HTML + PDF | `/inventory/lots/{lot}/documents/lot-detail` |
| Count Sheet | HTML + print | `/inventory/stock-takes/{take}/documents/count-sheet` |
| Variance Report | HTML + print | `/inventory/stock-takes/{take}/documents/variance-report` |
| Stock Position (By Lot) | HTML + Excel | `/inventory/reports/stock-position/by-lot` |
| Stock Position (By Product) | HTML + Excel | `/inventory/reports/stock-position/by-product` |
| Stock Valuation | HTML + Excel | `/inventory/reports/stock-valuation` |

All accessible from a unified **Reports page** (`/inventory/reports`) with filter sections and lot selector.

### 13.7 Business Rules Enforced (Phase 8)

| Rule | Enforcement |
|---|---|
| Only designated approver can approve a transfer order | `TransferOrderService::approve()` checks `pending_approver_id` |
| Cannot stage more serials than `requested_quantity` per product | `TransferOrderService::scanDispatch()` cap check |
| Cannot dispatch / confirm receipt with unexplained shortfall items | Service validates `dispatch_variance_reason` / `reception_variance_reason` before finalization |
| Stock takes: cannot post with unexplained non-zero variances | `StockTakeService::post()` guard |
| Write-off from lot page is status change only (no stock movement) | By design; prevents double-counting with stock take adjustments |
| Direct adjustment is decrease-only | `InventoryLotController::adjust()` always passes negative qty |
| Lot-based stock levels: one row per lot per location | Unique constraint `(lot_id, location_id)` |

---

## 14. Phase 9 — Local Procurement

**Status:** ✅ Live | **Prerequisites:** Phases 2 + 3 + 7

### 14.1 Entities & Status Flows

**Local Purchase Order (LPO):**
```
draft → pending_approval → approved → ordered → partially_received → received → closed
      ↘                 ↘ rejected → draft (returnToDraft)
```
Approval: location manager or assistant manager of delivery location; submitter cannot self-approve.

**Goods Receipt Note (GRN):** Created against a placed LPO. `post()` creates inventory lots via `GrnPostingService` with proportional charge apportionment (tax/freight/insurance/other), updates LPO received quantities, and transitions LPO status.

**Supplier Invoice (3 types):**
- `advance` — created before GRN; items from LPO at agreed prices
- `delivery` — created against a posted GRN; charges pre-fill from stored GRN apportionment
- `direct` — standalone; manual line items

Status: `draft → pending_approval → approved → partially_paid → paid | disputed | cancelled`

**Supplier Return:** `draft → pending_approval → approved → posted`. Posting is document-status only; stock reduction requires serial reconciliation on the ShowLot page.

**Supplier Credit Note:** `draft → pending_approval → approved`. `apply()` reduces `amount_paid_*` on the linked invoice (partial or full); `unapply()` fully reverses.

### 14.2 Key Business Rules

| Rule | Enforcement |
|---|---|
| GRN charge apportionment stored at posting time | `GrnPostingService`; delivery invoice pre-fills from stored values |
| `grn_id` on inventory lots | Correct lot selection when LPO has multiple GRNs |
| `amount_paid` tracked in invoice source currency | Eliminates cross-currency ambiguity in `amount_paid_source` |
| `payment_method = 'credit_note'` on SupplierPayment | Credit applications visible in Payments tab without schema changes |
| Three-way match is informational only | `ThreeWayMatchService` surfaces variances; does not block |
| Supplier return posting = document-only | Serial reconciliation on ShowLot already creates the stock movement; posting would double-reduce |

### 14.3 Key Controllers & Services

| Component | Purpose |
|---|---|
| `LocalProcurementDashboardController` | Period + location KPIs; Inertia deferred `awaitingPaymentInvoices` |
| `LocalPurchaseOrderController` | CRUD + approval workflow + `placeOrder` + `close` |
| `GoodsReceiptNoteController` | `post()` triggers `GrnPostingService` |
| `SupplierInvoiceController` | CRUD + approval workflow + `recordPayment` + dispute/resolve |
| `SupplierReturnController` | CRUD + approval workflow + `post()` |
| `SupplierCreditNoteController` | CRUD + approval workflow + `apply` + `unapply` |
| `GrnPostingService` | Creates inventory lots with `grn_id`; apportions charges proportionally from LPO subtotal ratio |
| `ThreeWayMatchService` | Compares invoice vs LPO quantities and GRN received quantities; informational |

### 14.4 Document Suite (`resources/views/documents/local-procurement/`)

LPO purchase order, GRN receipt note (with charge totals), supplier invoice, payment receipt, supplier statement of account, supplier return note, credit/debit note — all HTML + PDF, extending `documents.procurement._layout`.

### 14.5 Reports (`/local-procurement/reports`)

5 reports: Aged Creditors (AP aging by bucket), Spend by Supplier, Outstanding LPOs, Three-Way Match Exceptions, Purchase History by Product. All rendered as print-optimised Blade views.

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

Dashboard (with inline period + location filters), LPOs (list + show with tabs: Overview/Items/GRNs/Invoices), GRNs (list + show), Supplier Invoices (list + show with 3-way match tab + payments tab + create modal), Supplier Returns (list + show), Credit Notes (list + show), Supplier Payments (list), Reports.

---

## 15. Phase 10 — Production

**Status:** ✅ Live | **Prerequisites:** Phases 2 + 7

### 15.1 Entities & Status Flows

**Bill of Materials (BOM):** Recipe template for a finished good. Each BOM has a `version` integer; multiple versions per product are allowed. Components (`bom_items`) carry `quantity_per_unit`, `wastage_pct`, and `unit_cost_snapshot_base` (auto-filled from component's latest `landed_cost_per_unit`). Standard material cost per unit = `SUM(qty_per_unit × (1 + wastage/100) × unit_cost_snapshot_base)`.

**Production Order:**
```
draft → pending_approval → approved → in_production → completed
      ↘                 ↘ rejected ← returnToDraft  ↘ cancelled
```
Approval: location manager or assistant manager; submitter cannot self-approve. Completing an order creates an output `InventoryLot` (`source_type='production'`); `unit_production_cost = total_production_cost / net_produced`.

**Production Cost Lines:** One row per cost type (material/labour/overhead/other). `estimated_amount_*` frozen at order creation (from BOM computation + user-entered estimates); `amount_*` updated as actuals accumulate. Variance = actual − estimated is displayed throughout.

**Material Consumptions (two tiers):**
- **Estimated** (`consumed_at IS NULL`) — auto-created from BOM on `populateFromBom`; one NULL-lot row per component
- **Actual** (`consumed_at IS NOT NULL`) — user confirms quantities and selects specific lots; multiple lot-specific rows per component allowed

Unique key `(production_order_id, product_id, lot_id)` enables per-lot tracking while keeping one estimate row (`lot_id IS NULL`) as baseline.

### 15.2 Key Business Rules

| Rule | Enforcement |
|---|---|
| Material cost line recalculated after every consumption change | `recalculateMaterialCostLine()` called after every consumption store/update/destroy |
| `estimated_amount_*` frozen; only `amount_*` updated | Design convention in `ProductionCostLineController::store()` |
| Stock validation — cannot consume more than lot's `quantity_on_hand` | `ProductionMaterialConsumptionController` + Vue `exceedsStock` computed |
| Write-off on completion: only confirmed + lot-linked consumptions | `ProductionOrderService::complete()` |
| Over-production allowed (> planned qty); amber warning shown | `CompleteOrderDialog.vue` `isOverProduction` computed |
| Notifications at every workflow stage | `ProductionNotificationService` |

### 15.3 `ProductionOrderService::complete()` (DB transaction)

1. Assert status = 'in_production'
2. Set `produced_quantity`, `rejected_quantity`, `completion_date`, `rejection_notes`
3. For each confirmed, lot-linked consumption: call `StockMovementService::issue()` (movement_type='write_off')
4. Create output `InventoryLot` (`source_type='production'`, `landed_cost_per_unit = total_cost / net_produced`)
5. Call `StockMovementService::receive()` for the output lot
6. Update `total_production_cost_*` and `unit_production_cost_*` on the order
7. Set status = 'completed'
8. ⚠ GL journal posting — deferred to Phase 13

### 15.4 Dashboard

The production dashboard uses Inertia v3 deferred props with inline period + location + product filters. All 7 status counts are immediate KPIs. Five deferred props drive the chart section:

| Deferred Prop | Chart type | Scope |
|---|---|---|
| `productionTrend` | Monthly line | All completed — location + product filtered, no date limit |
| `productionByProduct` | Multi-line monthly (top 8) | Same scope |
| `plannedVsProduced` | Horizontal grouped bar | Completed in period — all 3 filters |
| `topBoms` | Horizontal bar | All orders — location + product filtered |
| `recentOrders` | List panel | All orders — location + product filtered |

### 15.5 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 | ✓ |

### 15.6 Reports (`/production/reports`)

| Report | Filters | Output |
|---|---|---|
| Production Summary | date, status, product, location | HTML + Excel |
| Cost Analysis | completion date, product, location | HTML + Excel |
| Material Consumption | consumed date, raw material, location | HTML + Excel |

---

## 16. Roadmap — Phases 11–18

Phases 11–18 are planned and architecturally designed but not yet implemented. Full specifications are in `docs/implementation-plan.md`.

| Phase | Scope | Prerequisite |
|---|---|---|
| **7** ✅ | Inventory: Lots, Stock Levels, Movements, Opening Stock, Pricing History | Phase 6 |
| **8** ✅ | Inventory: Transfers, Stock Takes, Serial Numbers, Reports | Phase 7 |
| **9** ✅ | Local Procurement: LPOs, GRNs, three-way match, supplier invoices, returns, credit notes, reports | Phases 2 + 3 + 7 |
| **10** ✅ | Production: BOMs, production orders, cost lines, material consumptions, documents & reports | Phases 2 + 7 |
| **11** | Sales: Full Payment POS, Till Sessions, FIFO lot selection | Phases 3 + 7 |
| **12** | Sales: Instalment Plans, Returns, Forfeiture | Phase 11 |
| **13** | Finance: COA, Journal Entries, Fiscal Periods, Expenses | Phase 1 |
| **14** | Finance: Bank Reconciliation, P&L, Balance Sheet | Phase 13 |
| **15** | HR: Employees (salary encrypted at rest) | Phase 1 |
| **16** | Notifications & Alerts (database + email + SMS) | Phases 11 + 12 + 7 + 9 |
| **17** | Reporting & Analytics (cross-domain, export to PDF/Excel) | All operational |
| **18** | Subscriptions & Billing (Flutterwave + Stripe, plan enforcement) | All |

---

## 17. Engineering Standards

### Controller Pattern

Every controller:
- Uses a **Form Request** for all validation — no inline `$request->validate()`
- Returns an Inertia response or a redirect with flash
- Never calls `Model::all()` — always paginate or scope
- Checks org ownership via `authOwnsModel()` helper or global scope

### Service Pattern

Every service:
- Wraps multi-step writes in `DB::transaction()`
- Throws domain-specific exceptions (e.g., `AllocationMethodUnavailableException`)
- Has no knowledge of HTTP request/response objects
- Is stateless — instantiated per request, not shared

### Test Pattern

```php
public function test_it_does_x(): void
{
    // Arrange
    $org  = Organization::factory()->create(['is_administrative' => false]);
    $user = User::factory()->create(['organization_id' => $org->id]);

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

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

- Always `actingAs($user)` — never bypass auth
- Always assert DB state, not just HTTP status
- Factories must include `sys_code` where the column is NOT NULL (SupplierFactory, ProductFactory, ProductCategoryFactory, ShippingAgentFactory)
- SQLite compatibility: avoid PostgreSQL-only DDL (`DROP CONSTRAINT IF EXISTS`) in migrations; use driver guards

### Inertia v3 Deferred Props

For dashboard-style controllers with heavy aggregations, use:
```php
return Inertia::render('Module/Dashboard', [
    'kpis' => $this->buildKpis(),                          // Immediate — fast COUNT queries
    'charts' => Inertia::defer(fn () => $this->charts()),  // Deferred — heavy aggregations
]);
```
Each deferred group loads independently after the shell page renders. Frontend uses `<Deferred data="charts">` with a `<template #fallback>` skeleton.

### Document Generation

Printable documents use Blade templates under `resources/views/documents/procurement/`. Same template serves:
- `return view(...)` → browser preview with auto-print JS
- `Pdf::loadView(...)->download(filename)` → DomPDF attachment download

All document controllers return `Symfony\Component\HttpFoundation\Response` (base class, not `StreamedResponse`).

### Code Formatting

Before every commit or PR:
```bash
vendor/bin/pint --dirty --format agent
```

After adding controllers or routes:
```bash
php artisan wayfinder:generate
# or let npm run dev pick it up automatically
```

---

## 18. Brand Identity & Design System

### Colour Palette

| Role | Name | Hex | Usage |
|---|---|---|---|
| Primary dark | Navy Depth | `#0F1E45` | Sidebar background, logo mark |
| Primary action | Trade Blue | `#136396` | Buttons, links, active states |
| Highlight | Ocean Blue | `#2799D0` | Info badges, hover tints, dark mode primary |
| Accent / Warning | Trade Amber | `#F49A0E` | Active nav indicator, warnings |
| Success | Clearance Green | `#28A645` | Cleared, Paid, Delivered states |
| Danger | Danger Red | `#DC2626` | Errors, cancellation, delete |

### Status Badge Language

| Status | Badge text | Colour |
|---|---|---|
| `draft` | Draft | Gray |
| `in_progress` | In Progress | Blue |
| `sourcing_complete` | Sourcing Complete | Purple |
| `shipped` | Shipped | Amber |
| `closed` | Closed | Green |
| `pending` | Pending | Gray |
| `in_transit` | In Transit | Blue |
| `arrived` | Arrived | Amber |
| `cleared` | Cleared | Purple |
| `delivered` | Delivered | Green |

### Typography

**Interface:** Instrument Sans — interface-optimized, premium feel
**Documents:** Times New Roman — print-standard, cross-platform

### DataTable Pattern

All list pages use the shared `DataTable.vue` / `ServerPaginatedDataTable.vue` component with:
- Column visibility toggle
- Export to CSV/Excel
- Server-side or client-side search + sort + pagination
- Selectable rows for bulk actions
- Row actions with `condition` function for conditional visibility

---

## 19. Roles & Permissions Matrix

| Permission | Owner | Manager | Finance | Sales | Warehouse | Viewer |
|---|---|---|---|---|---|---|
| Manage organization settings | ✅ | — | — | — | — | — |
| Manage product catalogue | ✅ | ✅ | — | — | — | — |
| Create / manage sourcing trip | ✅ | ✅ | — | — | — | — |
| Record sourcing prices | ✅ | ✅ | — | — | — | — |
| Create / manage shipment | ✅ | ✅ | — | — | — | — |
| Record freight / tax cost | ✅ | ✅ | ✅ | — | — | — |
| Run cost allocation | ✅ | ✅ | ✅ | — | — | — |
| Create / manage LPO | ✅ | ✅ | ✅ | — | — | — |
| Create / manage production order | ✅ | ✅ | — | — | — | — |
| Set selling / outlet price | ✅ | ✅ | — | — | — | — |
| Receive stock (warehouse) | ✅ | ✅ | — | — | ✅ | — |
| Create transfer order | ✅ | ✅ | — | — | ✅ | — |
| Approve transfer order | ✅ | ✅ | — | — | — | — |
| Create sale (full payment) | ✅ | ✅ | ✅ | ✅ | — | — |
| Create instalment sale | ✅ | ✅ | ✅ | ✅ | — | — |
| Approve instalment sale | ✅ | ✅ | — | — | — | — |
| Post manual journal entry | ✅ | — | ✅ | — | — | — |
| View financial reports | ✅ | ✅ | ✅ | — | — | ✅ |
| Manage employees | ✅ | ✅ | — | — | — | — |
| Manage bank accounts | ✅ | — | ✅ | — | — | — |
| Manage subscription & billing | ✅ | — | — | — | — | — |

---

## 20. Non-Functional Requirements

| Category | Requirement |
|---|---|
| Performance | P95 API response < 500ms; reports < 10s for 1-year range |
| Monetary precision | All monetary values as `DECIMAL(15,4)`; no floating-point arithmetic |
| Accounting integrity | `SUM(debits) = SUM(credits)` enforced at service layer and DB check constraint |
| Soft deletes | All financial and catalogue records soft-deleted only; hard delete prohibited |
| Audit trail | `created_by`, `updated_by`, `creator_organization_id` on every table |
| Security | TLS 1.3; AES-256 at rest; bcrypt passwords; TOTP 2FA; tenant global scope; signed URLs for public links; encrypted customer ID numbers |
| Availability | 99.5% monthly uptime SLA |
| Localisation | Multi-currency; locale-aware number formatting; per-org timezone and fiscal year |
| Scalability | Read replicas for reporting; Laravel Horizon for async jobs |
| DB compatibility | Primary: PostgreSQL 15+; migrations must avoid PG-only DDL where possible |
| Print / PDF | A4, DomPDF for server-side PDF; browser print-to-PDF for complex layouts; `table-layout: fixed` + `word-break: break-word` to prevent overflow |
| Mobile | Direct PDF download routes (DomPDF) work without browser print dialog; no JS required for document access |

---

## 21. Glossary

| Term | Definition |
|---|---|
| **Organization** | Independent business tenant on Tradixor; unit of data isolation |
| **Outlet** | Customer-facing sales location (showroom, retail shop, branch, kiosk) |
| **Warehouse** | Storage location; goods held but not sold directly |
| **Product Catalogue** | Master list of all products; single source of product identity regardless of how they enter inventory |
| **InventoryLot** | A specific batch of a product with a known `landed_cost_per_unit`; the universal cost-carrying unit |
| **source_type** | Enum on inventory lots: `import`, `local_purchase`, `production`, `opening_stock` |
| **Landed Cost** | True unit acquisition cost for imports: source price + allocated freight + insurance + tax + CFA fee + haulage |
| **Sourcing Trip** | A procurement journey to foreign markets; one trip can produce multiple shipments |
| **shippable_quantity** | Denormalized column on `sourcing_trip_items`: `sourced_quantity - total_shipped`; maintained by `ShipmentService` |
| **Proforma Invoice (PI)** | Pre-shipment supplier order confirmation; generated per supplier from trip items |
| **Shipment** | A single cargo movement from origin port to destination port; linked to one sourcing trip |
| **Tax Clearance Record** | Revenue authority assessment + CFA fee record; must reach `cleared` before shipment delivery |
| **Haulage Record** | Last-mile delivery record (ICD/port → warehouse); driver, vehicle, route, cost |
| **CostAllocation** | Per-shipment-item cost distribution record; one row per (item, allocation_type) |
| **cost_allocation_locked** | Boolean on `shipments`; when true, prevents re-running allocation |
| **Deferred Props** | Inertia v3 feature: `Inertia::defer(fn() => ...)` — prop loaded asynchronously after page shell renders; shown as skeleton until ready |
| **CFA** | Clearing & Forwarding Agent; manages customs clearance for the importer |
| **FIFO** | First In First Out; oldest inventory lots consumed first regardless of source type |
| **CBM** | Cubic Metre; `length_cm × width_cm × height_cm / 1,000,000`; used for volume-based freight allocation |
| **is_local** | Flag on `suppliers`: `false` = international, `true` = domestic/local |
| **BelongsToOrganization** | Laravel trait applying a global Eloquent scope; filters all queries by `auth()->user()->organization_id` |
| **SysGeneratorService** | Singleton service generating unique reference codes for all entities; microtime-based for internal codes, timestamp+random for business documents |
| **Natural FK** | Foreign key referencing a meaningful stable code column (`currency_code`, `country_code`) rather than a surrogate ID |
| **Signed URL** | Time-limited tamper-proof URL for unauthenticated public links |
| **Tenant Scope** | The `BelongsToOrganization` global scope; the primary security mechanism for multi-tenancy |
| **Navy Depth** | Brand colour `#0F1E45` — sidebar and darkest brand anchor |
| **Trade Blue** | Brand colour `#136396` — primary action colour |
| **Trade Amber** | Brand colour `#F49A0E` — warnings and active state accent |
| **Clearance Green** | Brand colour `#28A645` — success and cleared states |
