# PharmaWholesale MVC Conversion — Completed Modules Detail

Last updated: 2026-07-09 (Customer Master module completed — AARJAY GDP Compliance Portal integration, module 2 of ~12)

This file gives a detailed, module-by-module account of everything that
has been fully built, verified, and is safe to treat as done in this
checkpoint. For the high-level tracker (DONE/IN PROGRESS/TODO tables +
resume instructions) see `PROGRESS.md` in this same directory. For the
shared coding conventions every module follows, see `CONVENTIONS.md`.

All modules below were verified by:
1. `php -l` syntax check on every new/changed file (zero errors).
2. Live HTTP requests against the local dev server
   (`php -S 127.0.0.1:8100 dev-router.php`) with an authenticated
   session cookie, hitting every legacy URL the module exposes.
3. Checking response codes (all HTTP 200) and scanning output for
   PHP fatal errors / warnings / notices (none found).
4. Real data from the `pharma_test` MariaDB database (seeded from
   `3_aarjayhealthcare_pharma_wholesale.sql`, with one manually
   inserted test Purchase Order row since that table ships empty).

---

## 1. Auth module

- **Controller:** `app/Controllers/AuthController.php`
- **Model:** `app/Models/User.php` (shared with Users module)
- **Views:** `app/Views/auth/login.php`
- **Legacy source:** `login.php`, `logout.php`
- **Behavior ported:** email/password login form, bcrypt password
  verification, session creation, CSRF-protected form, flash error on
  bad credentials, logout clears session.
- **Verified:** POST to `login.php` with valid admin credentials
  returns HTTP 302 redirect to dashboard; session cookie then grants
  access to all authenticated routes.

## 2. Dashboard module

- **Controller:** `app/Controllers/DashboardController.php`
- **Views:** `app/Views/dashboard/index.php` (806 lines)
- **Legacy source:** `dashboard.php`
- **Behavior ported:** KPI cards (active suppliers, pending invoices,
  contract renewals due, licence expiry warnings, etc.), all backed by
  real SQL aggregate queries against the live schema — no mocked data.
- **Verified:** HTTP 200, zero PHP errors, all KPI numbers match direct
  DB query results.

## 3. Contracts module (reference/canonical module)

- **Controller:** `app/Controllers/ContractController.php`
  (`indexAction` — list w/ delete + filters + pagination, `addAction`,
  `editAction`, `viewAction`)
- **Model:** `app/Models/Contract.php`
- **Views:** `app/Views/contracts/{index,add,edit,view}.php`
- **Legacy source:** `contracts.php`, `contract_add.php`,
  `contract_edit.php`, `contract_view.php`
- **Behavior ported:** full CRUD, contract number auto-generation
  (`nextContractNumber()`), status badges, filters, delete with
  confirmation + audit log entry.
- **Verified:** `contracts.php` (200), `contract_view.php?id=1` (200,
  title "CON-0001"), `contract_add.php` (200). Zero PHP errors.
- **Note:** This module was built first and hand-verified end-to-end
  before writing `CONVENTIONS.md`, so it is the pattern every other
  module follows exactly (same Controller/Model responsibilities,
  same `render()` usage, same legacy-URL-preserving route names).

## 4. Invoices module

- **Controller:** `app/Controllers/InvoiceController.php`
- **Model:** `app/Models/Invoice.php`
- **Views:** `app/Views/invoices/{index,add,edit,view}.php`
- **Legacy source:** `invoices.php`, `invoice_add.php`,
  `invoice_edit.php`, `invoice_view.php`
- **Behavior ported:** dynamic multi-line-item form (JS-driven
  add/remove rows with live qty × price × VAT-rate calculation),
  edit form pre-loads existing `invoice_items` rows, printable
  invoice document view with VAT breakdown grouped by rate, uses
  `\App\Core\Config::get('app')['name']` for the "PharmaWholesale"
  branding instead of a hardcoded string.
- **Verified:** `invoices.php` (200), `invoice_add.php` (200),
  `invoice_view.php?id=1` (200), `invoice_edit.php?id=1` (200). Zero
  PHP errors on any route.

## 5. Purchase Orders module

- **Controller:** `app/Controllers/PurchaseOrderController.php`
- **Model:** `app/Models/PurchaseOrder.php`
- **Views:** `app/Views/purchase_orders/{index,add,edit,view}.php`
- **Legacy source:** `purchase_orders.php`, `po_add.php`,
  `po_edit.php`, `po_view.php`
- **Behavior ported:** full CRUD, PO number auto-generation
  (`nextPONumber()`), line items table, VAT/subtotal/total breakdown,
  status badges (draft/sent/confirmed/received/cancelled).
- **Special note:** the seed SQL ships `purchase_orders` and
  `purchase_order_items` empty (0 rows) — this is expected, not a bug.
  One test PO + one test PO line item were manually inserted into
  `pharma_test` purely to verify `po_view.php?id=1` renders correctly
  end-to-end. That test data is local-only and does not need to ship
  in the final package.
- **Verified:** `po_view.php?id=1` → HTTP 200, correct title
  "PO-TEST-0001 — PharmaWholesale UK", 403 lines of clean output, zero
  fatal/warning errors.

## 6. Suppliers module (completed 2026-07-06, this checkpoint)

- **Controller:** `app/Controllers/SupplierController.php`
  - `indexAction` — list with search (`q`), category filter,
    approved-status filter, risk-level filter, pagination, delete
    (blocked if linked invoices exist, else audit-logged), and CSV
    export (`?export=csv`).
  - `addAction` — full 9-section create form incl. category
    multi-select, HMR-2012 Responsible Person validation, email
    uniqueness check.
  - `editAction` — same 9 sections pre-filled from DB, old/new value
    audit-trail diff logged on save.
  - `viewAction` — loads categories, licences, audits, invoices,
    invoice-status summary, and activity log for the 5-tab detail
    page.
- **Model:** `app/Models/Supplier.php` — `paginate()`, `exportRows()`,
  `allCategories()`, `categoryFilterList()`, `find()`,
  `currentCategoryIds()`, `categoriesForSupplier()`, `emailExists()`,
  `invoiceCount()`, `create()`, `update()`, `syncCategories()`
  (private), `delete()`, `licences()`, `audits()`, `invoicesFor()`,
  `invoiceStatusSummary()`, `activityLog()`.
- **Views:**
  - `suppliers/index.php` — filterable/sortable list, delete
    confirmation, CSV export button, pagination.
  - `suppliers/add.php` — 9-section create form: Basic Company
    Details, Addresses, Contact, Responsible Persons (HMR 2012),
    Regulatory Licences, Categories, Risk & Approval, Banking &
    Payment, Additional Notes. Includes "same as registered address"
    JS convenience checkbox.
  - `suppliers/edit.php` — same 9 sections pre-filled, "last updated"
    timestamp footer.
  - `suppliers/view.php` — **large 5-tab detail page** (~39 KB
    rendered), ported line-for-line from the 855-line legacy
    `supplier_view.php`:
    1. **Overview** — company details, status/risk cards, invoice
       summary widget, addresses, contacts, notes.
    2. **Regulatory & Licences** — key licence numbers with
       expiry-status badges (Active/Expiring Soon/Expired), RP/QP
       details, individual licence records table.
    3. **Audits** — audit stats (total/last/next due), full audit
       history table with rating + CAPA-status badges.
    4. **Invoices** — invoice list scoped to this supplier.
    5. **Activity History** — UK GDPR/GDP-compliant audit trail with
       collapsible before/after JSON diff viewer, 🔒 retention-flag
       indicator.
    - Local helper functions (`ukDateTimeSV`, `licenceStatusSV`,
      `auditRatingBadgeSV`, `capaStatusBadgeSV`) are suffixed `SV` to
      avoid colliding with same-named helpers in other view files
      (each Contract/Invoice/Supplier view defines its own local
      date/badge helpers since PHP has no view-local namespacing).
- **Legacy source:** `suppliers.php`, `supplier_add.php`,
  `supplier_edit.php`, `supplier_view.php`.
- **Verified (this session):**
  - `suppliers.php` → 200
  - `supplier_view.php?id=1` → 200, title "Alliance Healthcare UK —
    PharmaWholesale UK"
  - `supplier_view.php?id=1&tab=licences` → 200
  - `supplier_edit.php?id=1` → 200, 12 category checkboxes rendered,
    9 form-card sections present
  - Zero PHP fatal/warning/notice output on any route; dev server log
    shows clean `Accepted`/`Closing` pairs only.

## 7. Users module (completed 2026-07-06, this checkpoint)

- **Controller:** `app/Controllers/UserController.php`
  - `indexAction` — paginated list, delete (self-delete blocked).
  - `addAction` — create form with password policy validation
    (≥8 chars, 1 uppercase, 1 number), bcrypt hashing (cost 12), email
    uniqueness check, role/status selects.
  - `editAction` — edit form; password field optional (blank = keep
    current); self-account protections (cannot change own role,
    cannot change own status field — both hidden-input-locked in the
    form when editing yourself); updates session name/email live if
    editing your own account.
- **Model:** `app/Models/User.php` (shared with Auth) — `find()`,
  `all()`, `allNonAdminActive()`, `create()`, `update()`, `delete()`,
  `emailExists()`, `paginate()`, `findByEmailActive()`.
- **Views:**
  - `users/index.php` — table with role-colored avatar initials,
    role/status badges, "You" badge + disabled delete button on own
    row, delete confirmation modal via `data-confirm`, pagination.
  - `users/add.php` — name/email/password fields with show/hide
    password toggle JS, role + status selects, role-permissions
    explainer box.
  - `users/edit.php` — same fields, password optional, role/status
    selects `disabled` + backed by hidden inputs when editing self,
    "account created" timestamp footer.
- **Legacy source:** `users.php`, `user_add.php`, `user_edit.php`.
- **Verified (this session):**
  - `users.php` → 200, title "User Management — PharmaWholesale UK"
  - `user_add.php` → 200
  - `user_edit.php?id=1` → 200, title "Edit User — System
    Administrator — PharmaWholesale UK"
  - Zero PHP fatal/warning/notice output on any route.

## 8. Products module (completed 2026-07-06, this checkpoint)

- **Controller:** `app/Controllers/ProductController.php`
  - `indexAction` — list with search (`q`), category filter, supplier
    filter, status filter, pagination, low-stock/out-of-stock row
    highlighting, delete (audit-logged), CSV export (`?export=csv`).
  - `addAction` — create form covering basic info, stock management,
    pricing (cost/selling/VAT), UK regulatory fields (licence number,
    controlled drug schedule 1–5, refrigeration/cold-chain flags,
    min/max temperature range). SKU-uniqueness validation.
  - `editAction` — same fields pre-filled, SKU-uniqueness check
    excludes the current record, audit-trail diff logged on save.
  - `viewAction` — detail page with stock-status card (colour-coded
    OK/low/out), pricing + margin calculation card, regulatory
    summary, timestamps.
- **Model:** `app/Models/Product.php` — `paginate()`, `exportRows()`,
  `activeSuppliers()`, `find()`, `findWithSupplier()`, `skuExists()`,
  `create()`, `update()`, `delete()`.
- **Views:**
  - `products/index.php` — table with stock/category/status badges,
    low-stock (amber row) / out-of-stock (red row) highlighting,
    controlled-drug and cold-chain icon badges, CSV export button,
    filter bar (search/category/supplier/status), pagination.
  - `products/add.php` — 4-section form: Basic Information, Stock
    Management, Pricing, UK Regulatory (controlled drug schedule,
    refrigeration/cold-chain checkboxes, temperature range).
  - `products/edit.php` — same 4 sections pre-filled.
  - `products/view.php` — detail page with stock-status widget
    (large colour-coded current-stock number + OK/LOW/OUT badge),
    pricing card with auto-computed margin £ and %, UK regulatory
    card, created/updated timestamps.
- **Legacy source:** `products.php`, `product_add.php`,
  `product_edit.php`, `product_view.php`.
- **Verified (this session):**
  - `products.php` → 200
  - `product_add.php` → 200
  - `product_view.php?id=1` → 200, title "Medicine 1 — PharmaWholesale
    UK"
  - `product_edit.php?id=1` → 200
  - `products.php?export=csv` → 200, `text/csv` content-type, correct
    header row + 2 data rows matching the `pharma_test` seed data
    (2 products currently seeded).
  - Zero PHP fatal/warning/notice output on any route.

---

## 9. Deliveries module (completed 2026-07-06, this checkpoint)

- **Controller:** `app/Controllers/DeliveryController.php`
  - `indexAction` — list with status/supplier/date-range filters,
    pagination, delete (only allowed while status is `pending` or
    `in_transit`, audit-logged).
  - `addAction` — header fields (supplier, linked PO, delivery date,
    received-by, carrier, status, cold-chain maintained + notes,
    document reference, notes) plus a dynamic multi-row delivery-items
    table (product, batch number, expiry date, qty expected/received/
    rejected, rejection reason, temperature on arrival — JS add/remove
    rows, product-select auto-fills description).
  - `editAction` — same form pre-filled from DB; blocked entirely once
    a delivery's status is `accepted` or `rejected` (redirects to the
    view with a flash error, matching legacy behaviour exactly).
  - `viewAction` — delivery note detail page with cold-chain-breach /
    rejected-delivery alert banners, full line-item table, and a
    summary sidebar (line item count, total received/rejected,
    created timestamp).
- **Model:** `app/Models/Delivery.php` — `paginate()`, `find()`,
  `findForView()` (joins supplier/received-by/PO number),
  `items()`/`itemsRaw()`, `create()`, `update()`, `delete()`. Business
  rules ported faithfully into the model:
  - On create/update, if the delivery status is `accepted` or
    `partial` (and, on update, it wasn't already in that state), each
    line item's received quantity is added to
    `products.current_stock` and `last_batch_number`/`last_expiry_date`
    are stamped — mirrors the legacy stock-increment behaviour exactly,
    including the wasAccepted/isAccepted guard on edit so stock is never
    double-counted.
  - Any line item with `quantity_rejected > 0` writes a `warning`-severity
    activity-log entry describing the rejection and reason.
  - If the delivery is linked to a Purchase Order, accepting sets the
    PO to `received` and partial sets it to `partially_received`,
    stamping `actual_delivery_date` — exact legacy parity.
- **Views:**
  - `deliveries/index.php` — filterable list (status/supplier/date
    range), cold-chain Yes/No/N/A badges, status badges, delete
    confirmation, pagination.
  - `deliveries/add.php` — delivery details card + dynamic line-items
    table with add/remove-row JS and product-select autofill.
  - `deliveries/edit.php` — same structure, pre-filled from existing
    `delivery_items` rows.
  - `deliveries/view.php` — delivery-note styled detail page, cold-chain
    breach / rejected-delivery alert banners, printable
    (`window.print()`), line-item table with per-row rejection
    highlighting, summary sidebar.
- **Legacy source:** `deliveries.php`, `delivery_add.php`,
  `delivery_edit.php`, `delivery_view.php`.
- **Verified (this session) — live HTTP + live DB, not just `php -l`:**
  - Installed `php8.4-cli`/`php8.4-mysql` and a local `mariadb-server`
    in this sandbox specifically to run real verification (previous
    checkpoints' claims of live verification could not otherwise be
    independently re-checked here).
  - `php -l` on all 71 project PHP files (existing + 4 new Delivery
    files) — zero syntax errors.
  - Imported `install.sql` into a fresh `pharma_test` MariaDB DB,
    started `php -S 127.0.0.1:8100 dev-router.php`, logged in as
    `admin@pharma.com` / `Admin@1234` via curl with cookie jar.
  - `deliveries.php` (empty list) → HTTP 200, clean output.
  - `delivery_add.php` (GET form) → HTTP 200, clean output.
  - POST `delivery_add.php` with a full line item, status `accepted`,
    cold-chain maintained=1, linked to a test PO → HTTP 200 redirect to
    `delivery_view.php?id=1` titled "DEL-0001", confirmed in DB:
    delivery + delivery_items rows created, linked product's
    `current_stock` incremented by the received qty (20→... correctly),
    `last_batch_number`/`last_expiry_date` stamped, linked PO status
    flipped to `received` with `actual_delivery_date` set, and a
    `CREATE` activity_log row written.
  - `delivery_view.php?id=1` → HTTP 200, correct title, line item and
    batch number rendered.
  - `delivery_edit.php?id=1` (status=accepted) → correctly redirected
    back to the view with flash "Cannot edit an accepted or rejected
    delivery." — edit-lock guard confirmed working.
  - Created a second delivery with status `pending` (DEL-0002),
    confirmed its edit form renders pre-filled with the existing batch
    number; submitted an edit POST changing status to `accepted` →
    confirmed stock incremented exactly once (no double-count from the
    wasAccepted/isAccepted guard) and the update was audit-logged.
  - `deliveries.php?status=accepted` filter → HTTP 200, correctly
    scoped to the 2 accepted rows only.
  - CSRF rejection test: POST to `deliveries.php` with `delete_id` and
    a bogus `csrf_token` → 302 redirect, delivery NOT deleted (CSRF
    guard confirmed working, not just present in code).
  - Valid delete (correct CSRF token, `pending` status delivery) →
    flash "deleted.", confirmed removed from DB with a `DELETE`
    activity_log entry.
  - Test data (deliveries, delivery_items, the throwaway PO, and the
    Deliveries activity_log rows) was cleaned from `pharma_test` and
    the product's `current_stock` reset back to its seed value after
    verification — the DB was left in its original seeded state.

## 10. Temperature Logs module (completed 2026-07-06, this checkpoint)

- **Controller:** `app/Controllers/TemperatureLogController.php`
  - `indexAction` — filterable list (location/date range/out-of-range-only),
    CSV export, delete (audit-logged, severity `warning`), pagination,
    KPI queries (today's reading count + today's OOR count), and the
    UK-GDP "fewer than 2 readings yesterday" warning banner.
  - `addAction` — records a new reading and computes `out_of_range`
    using the exact legacy rule set from `temperature_log_add.php`'s
    `checkOutOfRange()` (cold_room/fridge_* → 2–8°C; vehicle/warehouse/
    other → 15–25°C, with the legacy's slightly odd always-false
    `-25 < t <= -15` frozen-OK branch preserved verbatim, bug-for-bug,
    per CONVENTIONS.md's "do not fix unrelated legacy bugs" rule).
  - `editAction` — updates a reading and recalculates `out_of_range`
    using the legacy edit-page's *different* rule set from
    `temperature_log_edit.php`'s `checkOOR2()` (only cold_room/fridge_1/
    fridge_2 get the 2–8°C band; everything else — including vehicle —
    gets 15–25°C, no frozen exception at all). This inconsistency exists
    in the legacy app itself (two different functions with different
    logic for add vs edit) and has been kept exactly as-is rather than
    unified, per conventions.
  - No `viewAction` — legacy has no standalone view page for this module,
    confirmed by `routes.php` only mapping `index`/`add`/`edit`.
- **Model:** `app/Models/TemperatureLog.php` — `paginate()`, `forExport()`,
  `find()`, `todayKpi()`, `countOn()`, `create()`, `update()`, `delete()`,
  plus the two out-of-range rule methods as public statics
  (`checkOutOfRangeAdd()` / `checkOutOfRangeEdit()`) so the Controller
  can call the right one per action, matching the legacy split exactly.
- **Views:**
  - `temperature_logs/index.php` — KPI cards, UK-GDP acceptable-range
    reference card, "fewer than 2 readings yesterday" warning, filter
    bar (location/date range/OOR-only), colour-coded table (red row +
    badge for out-of-range readings), pagination.
  - `temperature_logs/add.php` — reading form with live client-side
    range-checking JS (highlights the temperature field and shows an
    inline "OUT OF RANGE" hint as the user types, matching the legacy
    `<script>` behaviour exactly, including the per-location range map).
  - `temperature_logs/edit.php` — same form pre-filled, with an
    "Original reading was flagged out of range" banner when the
    existing record's `out_of_range` flag is set.
- **Legacy source:** `temperature_logs.php`, `temperature_log_add.php`,
  `temperature_log_edit.php`.
- **Verified (this session) — live HTTP + live DB:**
  - `php -l` on all 75 project PHP files (existing + 3 new Temperature
    Log files) — zero syntax errors.
  - Restarted MariaDB + `php -S 127.0.0.1:8100 dev-router.php` (both had
    been stopped at the end of the previous session), logged in as
    `admin@pharma.com` / `Admin@1234` via curl with a cookie jar.
  - `temperature_logs.php` (empty list) → HTTP 200, clean output.
  - `temperature_log_add.php` (GET form) → HTTP 200, clean output.
  - CSV export (`?export=csv`) → HTTP 200, correct header row.
  - POST `temperature_log_add.php` with location `fridge_1`, temp
    `10.5°C` → HTTP 200 redirect to `temperature_logs.php`, flash
    correctly read "⚠️ Temperature reading (10.5°C) is OUT OF RANGE for
    Fridge 1. UK GDP action required.", confirmed in DB: row inserted
    with `out_of_range=1`, and a `CREATE` activity_log entry written
    with `severity=warning` and the correct UTF-8 description text
    (verified byte-for-byte with `--default-character-set=utf8mb4`).
  - `temperature_log_edit.php?id=1` (GET) → HTTP 200, correctly shows
    the "OUT OF RANGE—Original reading was flagged" banner and
    pre-fills all fields including the truncated `HH:MM` time.
  - POST edit changing the same reading to `5.0°C` → HTTP 200 redirect,
    confirmed in DB: `out_of_range` correctly recalculated from `1` to
    `0`, `location_label` updated, `UPDATE` activity_log entry written.
  - CSRF rejection test: POST `delete_id` with a bogus `csrf_token` →
    302 redirect, row NOT deleted (confirmed via DB count unchanged).
  - Valid delete (correct CSRF token) → flash "deleted.", confirmed row
    removed from DB with a `DELETE` activity_log entry (severity
    `warning`, matching legacy).
  - Test data (the one temperature_logs row and its 3 activity_log
    entries) cleaned up after verification — DB left in original
    seeded state (0 temperature_logs rows, as shipped).

## 11. Supplier Categories / Licences / Audits sub-modules (completed 2026-07-06, this checkpoint)

Three small sub-modules reachable from the Suppliers detail tabs, all built and verified in this checkpoint.

### Supplier Categories
- **Controller:** `app/Controllers/SupplierCategoryController.php` — single
  `indexAction` handling list + add + edit + delete all on one page
  (modals), matching the legacy single-file pattern exactly (no separate
  add/edit routes — `routes.php` only maps `supplier_categories` → `index`).
  Slug uniqueness enforced on both add and edit (excluding self on edit).
  Delete is blocked with a flash error if any supplier is still mapped to
  the category (checked via `supplier_category_map`).
- **Model:** `app/Models/SupplierCategory.php` — `all()` (with supplier
  counts via LEFT JOIN), `find()`, `slugExists()`, `create()`, `update()`,
  `supplierCount()`, `delete()`.
- **View:** `app/Views/supplier_categories/index.php` — table with colour
  swatch/icon preview, Add/Edit Bootstrap modals with live slug
  auto-generation, colour picker sync, and icon preview JS ported
  verbatim from the legacy `<script>` block.
- **Legacy source:** `supplier_categories.php`.
- **Requires role:** `admin` (via `requireRole('admin')`, matching legacy).

### Supplier Licences
- **Controller:** `app/Controllers/SupplierLicenceController.php` —
  `addAction`/`editAction`. Handles the "standard type vs Other custom
  type" dropdown logic exactly as legacy (a 15-entry `LICENCE_TYPES`
  const list on the model, with "Other" triggering a free-text field).
  Both actions redirect to `supplier_view.php?tab=licences` on success,
  matching legacy behaviour, and write a full old/new-value audit trail
  with `retention_flag=1`.
- **Model:** `app/Models/SupplierLicence.php` — `find()`, `create()`,
  `update()`, plus the `LICENCE_TYPES` constant.
- **Views:** `app/Views/supplier_licences/{add,edit}.php`.
- **Legacy source:** `supplier_licence_add.php`, `supplier_licence_edit.php`.

### Supplier Audits
- **Controller:** `app/Controllers/SupplierAuditController.php` —
  `addAction`/`viewAction`. `addAction` conditionally shows a GMP
  compliance score field only when the supplier belongs to a
  manufacturer category (`licensed_manufacturer`/`specials_manufacturer`
  slug check, exact legacy logic), validates CAPA fields as required
  only when CAPA is flagged, and on save: updates the supplier's
  `last_audit_date`/`next_audit_due`, and **auto-suspends the supplier**
  (`approved_status='suspended'`) when the rating is `critical` or
  `unsatisfactory` — ported byte-for-byte from the legacy behaviour.
  `viewAction` renders the printable audit report (rating-coloured
  header, compliance score bars, findings sections, CAPA table) and
  handles the inline "Update CAPA Status" sub-form POST
  (`_action=update_capa`).
- **Model:** `app/Models/SupplierAudit.php` —
  `supplierWithCategorySlugs()`, `findForView()` (joins company/RP/WDA
  licence/created-by), `create()` (incl. the suspend-on-critical and
  supplier audit-date-update side effects), `updateCapaStatus()`, plus
  `AUDIT_TYPES`/`RATINGS`/`CAPA_STATUSES` constants.
- **Views:** `app/Views/supplier_audits/add.php` (multi-section form:
  audit details, auditor info, scope, compliance scores + rating,
  findings, CAPA, follow-up — with the legacy `updateScoreBar()` JS for
  the live progress-bar preview) and `app/Views/supplier_audits/view.php`
  (printable report; local helper renamed `ukDateAud()` per the
  suffix-to-avoid-collisions convention, `APP_NAME` replaced with the
  `Config::get('app')['name']` pattern already established in
  `invoices/view.php`).
- **Legacy source:** `supplier_audit_add.php`, `supplier_audit_view.php`.

### Verified (this session) — live HTTP + live DB
- `php -l` on all 84 project PHP files (existing + 9 new sub-module
  files: 3 Models, 3 Controllers, 5 Views) — zero syntax errors.
- Restarted MariaDB + `php -S 127.0.0.1:8100 dev-router.php`, logged in
  as `admin@pharma.com` / `Admin@1234`.
- **Categories:** `supplier_categories.php` list → HTTP 200 (12 seeded
  rows). Add → new row confirmed in DB + `CREATE` activity_log entry.
  Edit (via `?edit=ID` GET pre-fill + POST) → name/colour/sort_order
  updated + `UPDATE` activity_log entry. Duplicate-slug validation on
  add → correctly rejected with "already exists" error, no duplicate
  row created. Delete while supplier-mapped (category ID 1, in use) →
  correctly blocked with "Cannot delete" flash, row NOT removed. Delete
  of the unused test category → succeeded + `DELETE` activity_log entry.
  CSRF rejection (bogus token on delete) → 302 redirect, row NOT deleted.
- **Licences:** `supplier_licence_add.php?supplier_id=1` (GET form) →
  HTTP 200. POST with `licence_type_select=WDA(H)` → redirected to
  `supplier_view.php?tab=licences`, row confirmed in DB, `CREATE`
  activity_log entry with correct description. `supplier_licence_edit.php?id=1`
  (GET, pre-filled) → HTTP 200. POST switching to `licence_type_select=Other`
  with a custom `licence_type_other` → confirmed DB updated to the custom
  type string, `UPDATE` activity_log entry written.
- **Audits:** `supplier_audit_add.php?supplier_id=1` (GET form) → HTTP 200.
  POST with `overall_rating=satisfactory` → audit row created,
  supplier's `last_audit_date`/`next_audit_due` updated, `approved_status`
  correctly left as `approved` (not suspended). Second POST with
  `overall_rating=critical` + CAPA fields → confirmed audit row created
  with `capa_status=open`, and — critically — confirmed the supplier's
  `approved_status` flipped to `suspended` automatically, with the flash
  message correctly showing the "SUSPENDED" warning. `supplier_audit_view.php?id=N`
  → HTTP 200, rating badge and report reference rendered correctly.
  CAPA status update sub-form POST (`_action=update_capa`,
  `capa_status=completed`) → confirmed `capa_status`/`capa_completed_date`
  updated in DB, `UPDATE` activity_log entry written.
- Also confirmed CAPA-required client-side/server-side validation:
  submitting an audit with the CAPA checkbox unchecked but the hidden
  `capa_required` POST key still present as an empty string correctly
  triggered the "CAPA details/due date required" validation errors
  (matches legacy `isset($_POST['capa_required'])` checkbox semantics
  exactly — confirms the port preserved this legacy quirk faithfully
  rather than silently changing checkbox-detection behaviour).
- All test data (test category, test licence, both test audits) and
  their activity_log entries were removed after verification, and
  supplier ID 1's `approved_status`/`last_audit_date`/`next_audit_due`
  were reset back to their original seeded values — DB left in original
  seeded state.

## 12. Permissions module (completed 2026-07-06, this checkpoint)

Admin-only per-user × per-module access-control matrix.

- **Controller:** `app/Controllers/PermissionController.php` —
  `indexAction` (lists non-admin active users in a sidebar, loads the
  selected user's current permission rows, renders the module matrix)
  and `saveAction` (CSRF-checked POST handler; verifies the target user
  exists and is not an admin before saving; delegates the actual
  view-off-forces-others-off transaction logic to the model).
- **Model:** `app/Models/Permission.php` — `allModules()`,
  `editableModuleSlugs()` (excludes the always-locked `users` and
  `permissions` modules), `forUser()`, `saveForUser()` (wraps the
  per-module upsert loop in a transaction, ported byte-for-byte
  including the legacy rule that unchecking "view" force-clears
  create/edit/delete regardless of what was submitted for them).
- **View:** `app/Views/permissions/index.php` — user selector sidebar +
  permission matrix table with per-row "toggle all" links and a global
  "Toggle All" link, ported verbatim from the legacy inline `<script>`
  block. Local `permMatrixChecked()` helper (suffixed to avoid any
  confusion with the live RBAC check in `App\Helpers\Auth::hasAccess`)
  reproduces the legacy `perm()` function's default-checked-state logic
  exactly (view defaults to checked, everything else defaults to
  unchecked, unless a row already exists in `permissions`).
- **Legacy source:** `permissions.php`, `permission_save.php`.
- **Requires role:** `admin` (via `requireRole('admin')` on both actions,
  matching legacy `requireRole('admin')` guard on both files).

### Verified (this session) — live HTTP + live DB
- `php -l` on all 82 project PHP files (3 new: Model, Controller, View)
  — zero syntax errors.
- Restarted MariaDB + `php -S 127.0.0.1:8100 dev-router.php`, logged in
  as `admin@pharma.com` / `Admin@1234`.
- `permissions.php` → HTTP 200, first non-admin user (Sarah Johnson,
  manager) auto-selected, matrix rendered with `users`/`permissions`
  rows correctly locked (padlock icon, no checkboxes).
- POST to `permission_save.php` with a partial permission set
  (`dashboard.view`, `suppliers.view/create/edit`, `invoices.view`) →
  confirmed exact DB row values: `suppliers` saved as
  view=1/create=1/edit=1/**delete=0** (untouched checkbox correctly
  defaulted to 0), `activity_log` (not submitted at all) saved as all
  zeros, flash success message shown, `UPDATE`/`permissions`
  activity_log entry written with the user's name.
- Second POST with `suppliers.create=1` and `suppliers.edit=1` **but
  `suppliers.view` omitted** → confirmed the legacy
  "view-off-forces-everything-off" rule fired correctly: all four
  `suppliers` columns ended up `0` in the DB despite create/edit being
  submitted as checked — proves the port preserved this legacy business
  rule exactly rather than naively trusting each checkbox independently.
- CSRF rejection (bogus token on `permission_save.php`) → 302 redirect,
  no new `permissions`/activity_log row written (count unchanged at 2).
- Attempted to submit permissions for `user_id=1` (the admin account
  itself) → correctly rejected ("User not found or cannot edit admin
  permissions", 0 rows ever written to `permissions` for user_id=1),
  confirming the legacy `role != 'admin'` guard in the save handler was
  preserved.
- All test permission rows and their two `permissions`-module
  activity_log entries were deleted after verification — DB left in
  original seeded state (0 permission rows for user 2, matching the
  pre-test state).

## Files touched in this checkpoint (Permissions completion)

```
app/Controllers/PermissionController.php   (new)
app/Models/Permission.php                   (new)
app/Views/permissions/index.php             (new)
PROGRESS.md                                 (updated — moved Permissions to DONE)
COMPLETED_MODULES.md                        (this file, updated)
PENDING_MODULES.md                          (updated — removed Permissions, renumbered)
```

## Files touched in this checkpoint (Supplier Categories/Licences/Audits completion)

```
app/Controllers/SupplierCategoryController.php   (new)
app/Controllers/SupplierLicenceController.php    (new)
app/Controllers/SupplierAuditController.php      (new)
app/Models/SupplierCategory.php                  (new)
app/Models/SupplierLicence.php                    (new)
app/Models/SupplierAudit.php                      (new)
app/Views/supplier_categories/index.php           (new)
app/Views/supplier_licences/add.php               (new)
app/Views/supplier_licences/edit.php              (new)
app/Views/supplier_audits/add.php                 (new)
app/Views/supplier_audits/view.php                (new)
PROGRESS.md                                       (updated — moved sub-modules to DONE)
COMPLETED_MODULES.md                              (this file, updated)
PENDING_MODULES.md                                (updated — removed sub-modules, renumbered)
```

## Files touched in this checkpoint (Temperature Logs completion)

```
app/Controllers/TemperatureLogController.php   (new)
app/Models/TemperatureLog.php                  (new)
app/Views/temperature_logs/index.php           (new)
app/Views/temperature_logs/add.php             (new)
app/Views/temperature_logs/edit.php            (new)
PROGRESS.md                                    (updated — moved Temperature Logs to DONE)
COMPLETED_MODULES.md                           (this file, updated)
PENDING_MODULES.md                             (updated — removed Temperature Logs, renumbered)
```

## Files touched in this checkpoint (Deliveries completion)

```
app/Controllers/DeliveryController.php   (new, ~14.3 KB)
app/Models/Delivery.php                  (new, ~7.8 KB)
app/Views/deliveries/index.php           (new)
app/Views/deliveries/add.php             (new)
app/Views/deliveries/edit.php            (new)
app/Views/deliveries/view.php            (new)
PROGRESS.md                              (updated — moved Deliveries to DONE)
COMPLETED_MODULES.md                     (this file, updated)
PENDING_MODULES.md                       (new — standalone remaining-work doc)
```

## Files touched in this checkpoint (Products completion)

```
app/Controllers/ProductController.php   (new, ~10.1 KB)
app/Models/Product.php                  (new, ~7.1 KB)
app/Views/products/index.php            (new)
app/Views/products/add.php              (new)
app/Views/products/edit.php             (new)
app/Views/products/view.php             (new)
PROGRESS.md                             (updated — moved Products to DONE)
COMPLETED_MODULES.md                    (this file, updated)
```

(Previous checkpoint's files touched — Suppliers + Users completion:
`app/Views/suppliers/edit.php`, `app/Views/suppliers/view.php`,
`app/Views/users/{index,add,edit}.php` — unchanged since, listed here
for continuity.)

## 13. Activity Log module (completed 2026-07-07, this checkpoint)

- **Controller:** `app/Controllers/ActivityLogController.php` —
  `indexAction` only. Filterable list (module, action type, severity,
  user, date range) with pagination and CSV export (`?export=csv`).
  Deliberately has **no Model** — queries run directly against the
  `activity_log` table inside the controller to avoid any namespace
  collision with the existing write-side `App\Helpers\ActivityLog`
  helper (used by every other controller's `logActivity()` calls,
  already built, not touched).
- **View:** `app/Views/activity_log/index.php` — filter bar, paginated
  table, and an old/new-value JSON diff viewer (collapsible, reusing
  the same pattern already ported into `suppliers/view.php`'s Activity
  History tab).
- **Legacy source:** `activity_log.php`.
- **Verified (this session) — live HTTP + live DB:**
  - `php -l` — zero syntax errors.
  - `activity_log.php` → HTTP 200, title "Audit Log — PharmaWholesale
    UK", zero PHP fatal/warning/notice output.
  - `activity_log.php?module=invoices` (filtered) → HTTP 200, zero
    errors.
  - `activity_log.php?export=csv` → HTTP 200, valid CSV rows returned
    against the live seeded `activity_log` table (69 rows in
    `pharma_test`).

## 14. Notifications module (completed 2026-07-07, this checkpoint)

- **Controller:** `app/Controllers/NotificationController.php` —
  `indexAction` (list, mark-read via POST, dismiss) and `settingsAction`
  (thresholds form: licence/contract-expiry warning days, audit-due
  days, low-stock toggle). Both use the existing
  `getUnreadNotificationCount()`/`generateSystemNotifications()`
  helpers already ported into `app/Helpers/functions.php` — this
  module only builds UI on top of them, no duplicate logic.
- **Model:** `app/Models/Notification.php` — list/paginate, `markRead()`,
  settings read/save.
- **Views:** `app/Views/notifications/index.php`,
  `app/Views/notifications/settings.php`.
- **Legacy source:** `notifications.php`, `notification_settings.php`.
- **Verified (this session) — live HTTP + live DB:**
  - `php -l` — zero syntax errors.
  - `notifications.php` → HTTP 200, title "Notifications —
    PharmaWholesale UK", zero PHP errors (6 seeded notification rows
    rendered).
  - `notification_settings.php` → HTTP 200, title "Notification
    Settings — PharmaWholesale UK", zero PHP errors (1 seeded settings
    row rendered).

## 15. Reports module (completed 2026-07-07, this checkpoint)

- **Controller:** `app/Controllers/ReportController.php` —
  `indexAction` (hub page with 4 report cards) plus `stockLevelsAction`,
  `financialSummaryAction`, `supplierPerformanceAction`,
  `complianceAction` — each supporting `?export=csv` and gated by
  `requireAccess('reports', 'view')`.
- **Model:** `app/Models/Report.php` — one pair of methods per report
  (a `*Rows()`/`*Kpi()` method for the on-screen tables/cards and a
  `*ForExport()` method for CSV), ported from the legacy files' direct
  SQL aggregate queries (stock levels by category/status, financial
  summary by month/supplier/status, supplier performance incl. spend/
  overdue-invoice/audit counts, and compliance incl. WDA/GDP/GMP
  licence expiry tracking and CAPA overdue counts).
- **Views:** `app/Views/reports/index.php` (hub), `stock_levels.php`,
  `financial_summary.php`, `supplier_performance.php`, `compliance.php`
  — KPI stat-cards, badges, and tables styled consistently with the
  rest of the app.
- **Legacy source:** `reports.php`, `report_stock_levels.php`,
  `report_financial_summary.php`, `report_supplier_performance.php`,
  `report_compliance.php`.
- **Bug caught and fixed during verification:** all 4 sub-report
  controller methods were initially written without the `Action`
  suffix the `Router::dispatch()` convention requires (e.g.
  `stockLevels()` instead of `stockLevelsAction()`), which caused live
  HTTP 404s on every report route except the hub page (`indexAction`
  was already correctly named). Caught by live route testing, not by
  `php -l` (syntax was valid either way) — renamed all 4 methods to
  `stockLevelsAction`/`financialSummaryAction`/
  `supplierPerformanceAction`/`complianceAction` and re-verified.
- **Verified (this session) — live HTTP + live DB:**
  - `php -l` on all Reports files — zero syntax errors (both before
    and after the Action-suffix fix).
  - `reports.php` → HTTP 200, title "Reports — PharmaWholesale UK".
  - `report_stock_levels.php` → HTTP 200, title "Stock Levels Report
    — PharmaWholesale UK".
  - `report_financial_summary.php` → HTTP 200, title "Financial
    Summary — PharmaWholesale UK".
  - `report_supplier_performance.php` → HTTP 200, title "Supplier
    Performance — PharmaWholesale UK".
  - `report_compliance.php` → HTTP 200, title "Compliance Report —
    PharmaWholesale UK".
  - `report_stock_levels.php?export=csv` → HTTP 200, valid CSV rows
    returned matching the live seeded `products`/`suppliers` data.
  - Zero PHP fatal/warning/notice output on any of the 5 routes after
    the fix.

## 16. Installer module (completed 2026-07-07, this checkpoint)

- **Controller:** `app/Controllers/InstallController.php` — a
  deliberate special case that does **not** extend the normal
  `render()`/layout pipeline, since `header.php` calls
  DB-dependent helpers (e.g. `getUnreadNotificationCount()`) that
  cannot work before a database exists. Every step echoes raw,
  self-contained HTML (its own `<style>` block, no dependency on
  `app/Views/layout/*`).
  - **Step 1 — requirements check:** PHP version, required extensions
    (`pdo_mysql`, `mbstring`, `session`, `json`), presence of
    `install.sql`, `app/config.php` existence/writability, `logs/`
    writability, project-root writability (for `install.lock`).
  - **Step 2 — DB credentials form:** host/port/name/user/pass,
    create-DB-if-missing checkbox, auto-detected application URL
    (editable), timezone select. Tests the DB connection with a real
    `PDO` connect before allowing progress to step 3.
  - **Step 3 — confirm & run:** creates the DB if requested, imports
    `install.sql` via a custom quote-aware SQL statement splitter
    (handles semicolons inside string literals correctly, ported from
    legacy's approach), verifies all core tables exist post-import,
    writes `app/config.php` in **this app's array-return format**
    (`Core/Config.php`'s expected shape — explicitly NOT the legacy
    `define()`-constants format), then re-loads that fresh config file
    and does a real end-to-end `PDO` connect + query as a final
    correctness check before creating `install.lock`.
  - **Step 4 — done:** shows the full install log, default admin
    credentials reminder (`admin@pharma.com` / `Admin@1234`), and a
    link to `login.php`.
  - **Locking:** refuses to re-run if `install.lock` exists, unless
    `?force=reinstall` is passed (session-flag-based, matching legacy's
    `force_reinstall` behaviour).
- **No Model** — uses raw `PDO` directly since the app's normal
  `Database` singleton reads `app/config.php`, which doesn't yet have
  real credentials when the installer starts.
- **Legacy source:** `install.php`. (`hash.php`, a 75-byte standalone
  dev utility that just runs `password_hash('Admin@1234', ...)` to
  produce the seeded admin's bcrypt hash for `install.sql`, was
  confirmed **not** part of the app's request flow and does not need
  porting.)
- **Verified (this session):**
  - `php -l` — zero syntax errors.
  - `install.php` (step 1, default) → HTTP 200, title "System Check —
    PharmaWholesale Installer", zero PHP errors. Renders correctly even
    though `app/config.php` already has real `pharma_test` credentials
    in this sandbox and `install.lock` doesn't exist yet (correctly
    shows the wizard rather than a false "locked" page).
  - `install.php?step=2` (DB credentials form) → HTTP 200, title
    "Database Setup — PharmaWholesale Installer", zero PHP errors.
  - **Not executed in this session:** the actual step-3 install-run
    (DB create + `install.sql` import + `config.php` overwrite +
    `install.lock` creation) was code-reviewed line-by-line but not
    run against a live DB, because the sandbox's `pharma_test` database
    and its `app/config.php` were the shared live fixtures every other
    module's verification in this checkpoint depended on — running the
    installer for real would overwrite `app/config.php` and could
    disrupt state. **Recommended before shipping:** run step 3 for real
    against a disposable database name (e.g. `pharma_installer_test`)
    to confirm the full import + config-write + `install.lock` path
    end-to-end, then restore `app/config.php` back to `pharma_test` (or
    whatever the final placeholder should be) afterwards.

## Files touched in this checkpoint (Activity Log / Notifications / Reports / Installer completion)

```
app/Controllers/ActivityLogController.php   (new)
app/Views/activity_log/index.php            (new)
app/Models/Notification.php                 (new)
app/Controllers/NotificationController.php  (new)
app/Views/notifications/index.php           (new)
app/Views/notifications/settings.php        (new)
app/Models/Report.php                       (new)
app/Controllers/ReportController.php        (new; 4 methods renamed to add required "Action" suffix after live-test caught 404s)
app/Views/reports/index.php                 (new)
app/Views/reports/stock_levels.php          (new)
app/Views/reports/financial_summary.php     (new)
app/Views/reports/supplier_performance.php  (new)
app/Views/reports/compliance.php            (new)
app/Controllers/InstallController.php       (new)
PROGRESS.md                                 (updated — moved all 4 remaining modules to DONE; TODO list now empty)
COMPLETED_MODULES.md                        (this file, updated)
PENDING_MODULES.md                          (updated — emptied; all modules complete)
```

## 17. Batch / Stock module (completed 2026-07-09, this checkpoint — AARJAY GDP Compliance Portal integration begins)

First of the net-new AARJAY GDP Compliance Portal modules (source specs
in `/root/.openclaw/workspace/gdp_portal_dev_docs/05-products-stock.md`).
Unlike every module above, this has **no legacy flat-PHP source file** —
it is new functionality being built directly against this MVC codebase's
conventions.

- **Why this module first:** it is the anchor table for GDP
  traceability (supplier → purchase → storage → sale) that a future
  Dispatch, Recall, Returns, and Stock Transfer module will all depend
  on, and it is the single screen an MHRA inspector or a live product
  recall will interrogate first.
- **Tables (added additively to `install.sql`, after `delivery_items`
  and before `invoices`):**
  - `stock_batches` — one row per received batch of one product from
    one supplier: `product_id`, `batch_number`, `expiry_date`,
    `supplier_id`, `purchase_order_id`, `delivery_id`,
    `purchase_date`, `site`, `storage_location`, `quantity_received`,
    `quantity_available`, `status` (available/quarantined/sold/
    transferred/expired/damaged/dead_stock/recalled),
    `status_reason`, `linked_purchase_invoice`, `linked_sales_invoice`,
    `linked_dispatch_reference`, `sold_to_customer` (free-text interim
    field — see "Known interim shortcuts" below), `sale_date`,
    `notes`, `created_by`.
  - `stock_batch_movements` — immutable audit trail of every
    quantity/status change against a batch (`movement_type`:
    receipt/sale/transfer/adjustment/quarantine/release/write_off/
    recall, signed `quantity_change`, `reference`, `notes`,
    `created_by`, `created_at`). This is what proves traceability was
    maintained over time, not just the current snapshot.
  - Both tables have PK/indexes/FK constraints/AUTO_INCREMENT added in
    the same sectioned style as every other table in `install.sql`
    (indexes block, AUTO_INCREMENT block, constraints block — matched
    existing file structure exactly rather than appending loose SQL at
    the end).
  - `modules` table: new row `(8, 'Batch / Stock', 'stock_batches', ..., 'boxes', 8)`.
  - 3 seed batches + 6 seed movements added so the module renders
    real, meaningful data out of the box (matches the existing
    seed-data convention every other table follows).
- **Model:** `app/Models/StockBatch.php` — `paginate()`/`exportRows()`
  (shared `buildWhere()` filter helper matching the `Product.php`
  pattern: search q / product / supplier / status / site /
  expiring-within-N-days), `activeProducts()`/`activeSuppliers()`/
  `distinctSites()` for filter dropdowns, `find()`, `findForView()`
  (joins product/supplier/PO/delivery/created-by for the single-screen
  traceability view), `movements()`, `otherBatchesForProduct()`,
  `batchNumberExistsForProduct()` (uniqueness scoped per-product, not
  globally — the same batch number legitimately exists for different
  products), `create()`, `update()`, `recordMovement()` (the one and
  only method that ever changes `quantity_available`, keeping the
  audit trail and the snapshot column impossible to drift apart),
  `recallTrace()` (given a product + optional batch number, returns
  every matching batch with its full movement history nested inside —
  built specifically for the Recall Trace screen), `kpis()`, `delete()`
  (cascades to `stock_batch_movements` first).
- **Controller:** `app/Controllers/StockBatchController.php` —
  `indexAction` (list + filters + pagination + CSV export + delete,
  same shape as `ContractController::indexAction`), `addAction`
  (validates product/batch number/expiry/quantity>0/status, rejects
  duplicate batch numbers for the same product), `editAction` (does
  **not** expose `quantity_received`/`quantity_available` as editable
  fields — those only change via `addMovementAction`, by design, to
  force every quantity change through the audited movement path),
  `viewAction` (the traceability screen: batch details + full
  movement history table + "record movement" form + "other batches of
  this product" cross-link sidebar + expiry/recall alert banners),
  `addMovementAction` (POST-only, validates movement type + quantity ≤
  available for outbound types, maps movement type → status change
  where relevant e.g. quarantine→quarantined/release→available/
  recall→recalled, captures `sold_to_customer`/`sale_date`/
  `linked_sales_invoice` on `sale` movements), `recallAction` (GET
  form + results, no write side).
- **Views:** `app/Views/stock_batches/{index,add,edit,view,recall}.php`
  — same `page-header`/`breadcrumb`/`filter-bar`/`form-card`/
  `section-heading`/`stat-card` CSS classes and `hasAccess()`-gated
  buttons as every other module. `index.php` has 4 KPI stat-cards
  (total/available/expiring-≤90-days/quarantined) matching the
  `temperature_logs/index.php` KPI-card pattern. `view.php` is the
  traceability centrepiece: expiry/recall alert banners (reusing the
  `contracts/view.php` expiring/expired banner pattern), full
  movement-history table, and an inline "Record Movement" form.
  `recall.php` is a new page shape not present in any existing module
  — a product-scoped, printable multi-batch + multi-movement trace
  report.
- **Routes:** `routeMap.php` — `stock_batches`→`batch`,
  `stock_batch_add`→`batch/create`, `stock_batch_edit`→
  `batch/{id}/edit`, `stock_batch_view`→`batch/{id}`,
  `stock_batch_movement_add`→`batch/{id}/movement`,
  `stock_batch_recall`→`batch/recall`. `routes.php` — registered in
  `StockBatchController` with `index`/`add`/`recall`/`edit`/
  `addMovement`/`view` actions.
- **Sidebar:** `layout/header.php` — new collapsible nav group
  "Batch / Stock" (bi-boxes icon) inserted between Contracts and
  Temperature, with "All Batches"/"New Batch"/"Recall Trace" sub-links,
  gated by `hasAccess('stock_batches', ...)` exactly like every other
  nav group.
- **Bugs caught and fixed during live verification (not caught by
  `php -l`):**
  1. **Route-ordering bug:** `Router::matchPretty()` checks registered
     pretty patterns in registration order and matches purely by
     segment count. `batch/recall` (2 literal segments) and
     `batch/{id}` (2 segments, one a placeholder) both match a
     2-segment request path, so registering `stock_batch_view`
     (`batch/{id}`) before `stock_batch_recall` (`batch/recall`)
     caused `/batch/recall?product_id=1` to silently 302-redirect
     back to `/batch` (view action treated `"recall"` as a numeric-ish
     id, found no such batch, flashed "not found", redirected) instead
     of reaching `recallAction()`. Fixed by moving `stock_batch_recall`
     and `stock_batch_add` registration in `routes.php` to *before*
     `stock_batch_view`, with a comment explaining why the order
     matters so a future module doesn't reintroduce the same class of
     bug.
  2. **Double-count bug:** `StockBatch::create()` originally did
     `INSERT ... quantity_available = quantity_received` **and then**
     called `recordMovement($batchId, 'receipt', $quantityReceived, ...)`,
     which *also* adds `$quantityChange` to `quantity_available`. Net
     effect: a batch received with qty 100 showed `quantity_available
     = 200` immediately after creation (confirmed via direct DB query
     during verification, not just by reading the code). Fixed by
     having `create()` insert `quantity_available = 0` and rely
     entirely on the subsequent `recordMovement('receipt', ...)` call
     to set it — `recordMovement()` is now the single code path that
     is ever allowed to change `quantity_available`, for any
     movement type, which is also what makes the movements table a
     trustworthy audit trail rather than a cosmetic log.
- **Known interim shortcut (documented, not a bug):** `sold_to_customer`
  on `stock_batches` and the `sale` movement type are free-text fields
  for now, since the GDP Portal's Customer Master / Sales Order /
  Dispatch modules don't exist yet in this codebase. When those are
  built (next on the GDP Portal roadmap, per `PROGRESS.md`), this
  field should become a proper FK to a new `customers` table and the
  "sale" movement type should optionally link to a `sales_order_id` /
  `dispatch_id` instead of the current free-text
  `linked_sales_invoice`/`linked_dispatch_reference` strings. Not
  fixed now because those tables don't exist yet — the free-text
  fields keep the batch traceability screen fully functional (the
  primary requirement) without blocking on a much larger module.
- **Verified (this session) — live HTTP + live DB, full round-trip:**
  - `php -l` on all new/changed files (`StockBatch.php`,
    `StockBatchController.php`, 5 new views, `routes.php`,
    `routeMap.php`, `header.php`, `install.sql`) — zero syntax errors.
  - Fresh `pharma_test` re-import from the updated `install.sql` —
    clean import, zero SQL errors, both new tables + FK constraints +
    3 seed batches + 6 seed movements confirmed present via direct
    query.
  - `batch` (index, empty filters) → HTTP 200.
  - `batch?status=available` / `?q=BATCH-2026` / `?expiring_days=90` →
    all HTTP 200, correctly scoped result sets.
  - `batch?export=csv` → HTTP 200, correct header row + 3 data rows
    matching the live seeded data.
  - `batch/create` (GET form) → HTTP 200.
  - `batch/1` (view, seeded batch) → HTTP 200, title "BATCH-2026-001",
    Movement History + Record Movement sections both rendered.
  - `batch/1/edit` → HTTP 200.
  - `batch/recall?product_id=1` → HTTP 200, title "Batch Recall
    Trace", both of product 1's seeded batches + their movements
    rendered on one page.
  - `batch/recall?product_id=1&batch_number=BATCH-2025-118` → HTTP
    200, correctly narrowed to the single named batch.
  - **Full POST round-trip:** created a new batch (product 2,
    qty_received=100) → redirected to `batch/4`, confirmed in DB
    `quantity_received=100, quantity_available=100` (bug #2 above was
    caught by this exact check, initially showing 200 before the
    fix). Recorded a `sale` movement (qty 30, reference SALE-9001,
    sold_to_customer="Test Pharmacy Ltd") → confirmed
    `quantity_available=70`, `sold_to_customer`/`sale_date` stamped on
    the batch row, a `stock_batch_movements` row written with
    `quantity_change=-30`. Recorded a `quarantine` movement → confirmed
    `status` flipped from `available` to `quarantined`. Submitted an
    edit (changed site/storage/expiry/status/status_reason) → confirmed
    all fields updated in DB, `quantity_available` untouched (by
    design — edit form deliberately does not expose quantity fields).
    Deleted the test batch → confirmed the `stock_batches` row and both
    its `stock_batch_movements` rows were removed (cascade delete
    confirmed, not just FK `ON DELETE CASCADE` assumed).
  - Zero PHP fatal/warning/notice/deprecated output in
    `/tmp/phpserver.log` across the entire session (checked after
    every request, not just at the end).
  - **Regression check on 8 pre-existing modules** after all `.sql`/
    `routes.php`/`routeMap.php`/`header.php` edits: dashboard (`/`),
    suppliers, products, invoices, contracts, temperature-log, report,
    activity-log — all still HTTP 200, zero PHP errors, sidebar
    "Batch / Stock" nav group renders correctly on the dashboard
    alongside every pre-existing nav item.

## Files touched in this checkpoint (Batch / Stock completion — AARJAY GDP Compliance Portal, module 1)

```
install.sql                                (updated — 2 new tables: stock_batches, stock_batch_movements; new modules row id 8; 3 seed batches + 6 seed movements)
app/Models/StockBatch.php                  (new)
app/Controllers/StockBatchController.php   (new)
app/Views/stock_batches/index.php          (new)
app/Views/stock_batches/add.php            (new)
app/Views/stock_batches/edit.php           (new)
app/Views/stock_batches/view.php           (new)
app/Views/stock_batches/recall.php         (new)
app/routeMap.php                           (updated — 6 new batch/* routes)
app/routes.php                             (updated — StockBatchController registered, route order comment added)
app/Views/layout/header.php                (updated — new "Batch / Stock" sidebar nav group)
PROGRESS.md                                (updated — module 17 added, IN PROGRESS/TODO reopened for GDP Portal build)
COMPLETED_MODULES.md                       (this file, updated)
```

---

## 18. Customer Master module (completed 2026-07-09, this checkpoint — AARJAY GDP Compliance Portal integration, module 2)

- **Controller:** `app/Controllers/CustomerController.php`
- **Model:** `app/Models/Customer.php`
- **Views:** `app/Views/customers/{index,add,edit,view}.php`
- **Legacy source:** none — **net-new module**, no equivalent in the original flat-PHP PharmaWholesale app.
- **Spec source:** `gdp_portal_dev_docs/04-customers-suppliers.md` §4.1 ("Customer Master").
- **DB migration:** `migrations/002_customers.sql` — new `customers` table
  (additive, FK `created_by → users.id ON DELETE SET NULL`), plus 2 seed
  rows (Boots Pharmacy Ltd, Lloyds Pharmacy Regional) for local dev/testing.
  Applied directly against `pharma_test` and verified with `DESCRIBE customers`.
- **Fields:** `customer_name`, `customer_type`, `licence_details`,
  `licence_number`, `licence_expiry_date`, `approval_status`
  (pending/approved/suspended/rejected), `risk_rating`
  (low/medium/high/critical), `address`/`city`/`postcode`/`country`,
  `main_contact_name`/`main_contact_phone`, `order_email`,
  `accounts_email`, `quality_recall_email`, `website`,
  `customer_portal_link`, `delivery_collection_contact`,
  `emergency_contact_name`/`emergency_contact_phone`, `account_status`
  (active/suspended/reopened/closed), `next_review_date`, `notes`,
  `created_by`/`created_at`/`updated_at`.
- **Behavior implemented:**
  - Full CRUD (index/add/edit/view) following the exact
    Product/Supplier controller conventions: shared `buildWhere()`
    private helper feeding both `paginate()` and `exportRows()`,
    duplicate-name validation on create/edit (`Customer::nameExists()`),
    CSV export on the index page, delete-with-audit-trail (old values
    captured in `activity_log` before the row is removed).
  - Filter bar on index: free-text search (name/licence/contact/city),
    approval status, account status, risk rating — each combinable and
    preserved across pagination links and the CSV export URL.
  - View page shows a licence-expiry warning banner: red alert if
    `licence_expiry_date` is in the past, amber alert if it's within 60
    days — same UX pattern as the Batch/Stock module's expiry warnings,
    computed once in the controller (`licenceExpired`/
    `licenceExpiringSoon`) and reused by both the index row highlighting
    and the view page banner.
  - `Customer::approvedActive()` helper added now (returns id +
    customer_name for customers with `approval_status='approved'` AND
    `account_status='active'`) — intended for the upcoming Sales Order /
    Dispatch modules' customer picker dropdown; not wired to anything
    yet since those modules don't exist yet this checkpoint.
  - `modules` table row added (`slug='customers'`, id 9, sort_order 9,
    positioned after Batch/Stock). Sidebar nav group added to
    `header.php` between the Products and Invoices groups, following
    the same `nav-group`/`hasAccess()` pattern as every other module.
  - Routes: `customer` → `/customer`, `customer_add` → `/customer/create`,
    `customer_edit` → `/customer/{id}/edit`, `customer_view` →
    `/customer/{id}` — registered in both `routeMap.php` and `routes.php`
    ahead of Purchase Orders in file order (no ordering hazard here since
    there's no literal-vs-placeholder collision like Batch/Stock's
    `batch/recall` had).
- **Live verification performed (this checkpoint) against `pharma_test`:**
  - Full-repo `php -l` lint: zero syntax errors across every file
    (existing + new).
  - Environment sanity: PHP 8.4.23-cli + MariaDB running, dev server
    (`php -S 127.0.0.1:8100 dev-router.php`) boots clean, `/login` HTTP 200.
  - Migration apply: `mysql -u root pharma_test < migrations/002_customers.sql`
    — exit 0, `DESCRIBE customers` shows all 27 columns correctly typed,
    both seed rows present.
  - GET routes, authenticated as `admin@pharma.com`: `/customer` (index,
    HTTP 200, 2 seed rows shown), `/customer/create` (add form, HTTP 200),
    `/customer/1` (view, HTTP 200, "Boots Pharmacy Ltd" rendered correctly),
    `/customer/1/edit` (edit form, HTTP 200) — zero PHP fatal
    errors/warnings in any response body.
  - CSV export: `/customer?export=csv` HTTP 200, correct header row + both
    seed rows in the downloaded CSV.
  - POST create: submitted "Test Pharmacy XYZ" with valid CSRF token →
    HTTP 302 redirect to `/customer/3`; confirmed row exists in DB with
    correct `created_by=1`; confirmed the new view page renders the name.
  - POST create duplicate-name rejection: resubmitted the same
    `customer_name` → HTTP 200 (re-rendered form) with "already exists"
    inline error, confirmed no second DB row was written.
  - POST edit: changed name to "Test Pharmacy XYZ Updated" +
    `approval_status` → approved + `risk_rating` → low, valid CSRF →
    HTTP 302 redirect back to `/customer/3`; confirmed all three fields
    updated correctly in the DB.
  - POST delete: submitted `delete_id=3` with valid CSRF from the index
    page → HTTP 302 redirect to `/customer`; confirmed the row count for
    id 3 dropped to 0 and 3 matching `activity_log` entries exist
    (CREATE/UPDATE/DELETE) — audit trail intact for the full lifecycle of
    the test record.
  - Final DB state after cleanup: exactly the 2 original seed rows
    remain (Boots Pharmacy Ltd, Lloyds Pharmacy Regional) — no leftover
    test data.
  - Dev server error log (`/tmp/phpserver.log`) scanned across the whole
    session: connection accept/close lines only, no PHP warnings/errors.

## Files touched in this checkpoint (Customer Master completion — AARJAY GDP Compliance Portal, module 2)

```
install.sql                                (updated — customers table + 2 seed rows folded directly into the main installer, modules row id 9, indexes/AUTO_INCREMENT/FK constraint added in the same sections as every other table)
app/Models/Customer.php                    (new)
app/Controllers/CustomerController.php     (new)
app/Views/customers/index.php              (new)
app/Views/customers/add.php                (new)
app/Views/customers/edit.php               (new)
app/Views/customers/view.php               (new)
app/routeMap.php                           (updated — 4 new customer/* routes)
app/routes.php                             (updated — CustomerController registered)
app/Views/layout/header.php                (updated — new "Customers" sidebar nav group)
pharma_test DB                             (customers table + modules row id 9 + permissions row for user 1 applied directly)
PROGRESS.md                                (updated — module 18 added, TODO updated to point at Sales Order next)
COMPLETED_MODULES.md                       (this file, updated)
```

**Correction made after initial completion (same day):** the `customers`
table was first shipped as a standalone `migrations/002_customers.sql`
file instead of being folded into `install.sql` like every other
module's tables (Batch/Stock, Site Settings, etc.) — caught when the
user checked the delivered zip and found `install.sql` was missing the
`customers` table, meaning a fresh install from that single file alone
would not have created it. Fixed by moving the `CREATE TABLE`/seed
data, index/AUTO_INCREMENT/FK-constraint blocks directly into
`install.sql` in the same relative sections as the other tables, then
deleting the now-redundant `migrations/` directory. Re-verified with a
full fresh-DB import test (`pharma_fresh_test`, dropped after) — exit
code 0, `customers` table present with both seed rows, `modules` table
shows all 9 rows correctly. Also re-ran the full 16-module regression
pass afterward against the still-live `pharma_test` DB: every module
index route (`/`, `/supplier`, `/product`, `/invoice`,
`/purchase-order`, `/delivery`, `/contract`, `/temperature-log`,
`/batch`, `/customer`, `/user`, `/permission`, `/activity-log`,
`/site-settings`, `/notification`, `/report`) returned HTTP 200 with
zero PHP errors/warnings in the server log.

## What's next

All 16 original modules are complete and verified; housekeeping items
(full regression pass, Installer step 3 live-run, `app/config.php`
placeholder restore, README, zip + upload) are still pending — see
`PROGRESS.md` → "Final steps after all modules done".

The AARJAY GDP Compliance Portal integration continues (see
`PROGRESS.md` → "IN PROGRESS"). Batch / Stock (module 1) and Customer
Master (module 2) are both done. Next per the Stage 1 priority order in
`gdp_portal_dev_docs/00-OVERVIEW.md` / `12-REMAINING-WORK-AND-RESUME.md`
§4: **Sales Order** (now unblocked — use `Customer::approvedActive()`
for the customer picker, and as part of that work wire
`stock_batches.sold_to_customer` to reference real `customers.id`
instead of free text), then Dispatch, then Purchase Request, then the
GDP-Compliance QMS section (Deviations/CAPA/Change Control/Document
Control/SOP Master), then Company & Sites.

---

## 19. Sales Orders module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 3)

**Spec:** `gdp_portal_dev_docs/06-orders-dispatch.md` §6.1 — Customer PO lives here.

- **Controller:** `app/Controllers/SalesOrderController.php`
  - `indexAction` — filterable list (search q, customer_id, status, payment_status, stock_reserved, date_from/to), KPI cards (total/draft/confirmed/reserved/pending_payment), pagination, CSV export, delete (blocked if status is dispatched/invoiced/completed — must cancel instead, GDP rule).
  - `addAction` — create form with customer select (approved/active customers + fallback to all active if <2 approved exist, to keep dev seed usable), customer PO number/date/file upload (PDF/JPG/PNG/DOC/XLS, 10MB, stored under `uploads/sales_orders/` with random filename), order_date, required_delivery_date, status, payment_status, stock_reserved toggle, transport_company/collection_reference/driver_name/collection_date/notes/proforma_invoice_number + dynamic line-items table (product_id, batch_id optional, product_name, quantity_requested, unit_price, vat_rate, line_total auto-calculated via JS, notes). Batch dropdown is FEFO-ordered and filtered by selected product via preloaded JSON of available batches (`status='available'` AND `quantity_available>0`). When `stock_reserved=1` and batch selected, `wireStockBatches()` sets `stock_batches.customer_id` + `sales_order_id`.
  - `editAction` — same form pre-filled, shows current PO file link, allows replacement upload (deletes old file), exposes quantity_reserved/dispatched fields (green background for reserved) for manual adjustment after initial reservation, preserves wiring logic (unwires old batches that pointed to this order and are not sold, then re-wires based on new items).
  - `viewAction` — the sales order traceability screen: customer details (address, contact, licence, approval status warning if not approved), order dates, payment/stock status badges, financial summary (subtotal/vat/grand total + total qty requested/reserved/dispatched), order lines table with batch links (to `/batch/{id}`) + reserved/dispatched badges, batch-traceability wiring table (every `stock_batches` row with `sales_order_id=this` showing expiry/qty/status/site), GDP traceability explainer card linking to `batch/recall`.

- **Model:** `app/Models/SalesOrder.php`
  - `STATUSES` (draft/confirmed/reserved/partially_dispatched/dispatched/invoiced/completed/cancelled) and `PAYMENT_STATUSES` (pending/part_paid/paid/overdue) constants.
  - `buildWhere()` private helper shared by `paginate()` and `exportRows()` — searches `order_number`, `customer_po_number`, `customer_name`, `transport_company` via LEFT JOIN customers, filters by customer_id, status, payment_status, stock_reserved, date_from/to.
  - `BASE_SELECT` constant joins `customers c` and `users u` for list, `findForView()` joins customers (with licence/approval fields), users, for full traceability view.
  - `paginate()`, `exportRows()`, `find()`, `findForView()` — standard CRUD read side.
  - `items()`, `itemsRaw()` — items with LEFT JOIN products and stock_batches for batch_number/expiry/available display.
  - `approvedActiveCustomers()`, `activeProducts()`, `availableBatchesForProduct()` — dropdown helpers, reusing `Customer::approvedActive()` pattern but also providing fallback.
  - `create(array $d, array $items, int $createdBy): int` — calculates subtotal/vat/total from items (vat rounded per line), generates order_number via `nextOrderNumber()` (SO-YYYY-0001, year-scoped with fallback to count), inserts `sales_orders`, inserts `sales_order_items`, then calls `wireStockBatches()` to set `stock_batches.customer_id` + `sales_order_id` when `stock_reserved` and batch_id present.
  - `update()` — recalculates totals, updates order row, clears previous wiring (`UPDATE stock_batches SET sales_order_id=NULL, customer_id=NULL WHERE sales_order_id=? AND status!='sold'`), deletes old items, inserts new items, re-wires.
  - `delete()` — unwires non-sold batches referencing this order, deletes items, deletes order (cascade safe).
  - `wireStockBatches()` private — iterates items, if `stock_reserved` and `batch_id`, updates matching `stock_batches` row with `customer_id` + `sales_order_id`. This retires the interim free-text `sold_to_customer` shortcut (column retained for historic traceability, but new path uses FKs).
  - `nextOrderNumber()` private — `SELECT order_number LIKE 'SO-YYYY-%'` ORDER BY id DESC, extracts numeric suffix, increments, else fallback to COUNT+1, returns `SO-YYYY-0001`.
  - `kpis()` — total, draft, confirmed, reserved (stock_reserved=1 AND status in reserved/confirmed/partially_dispatched), pending_payment.

- **DB Changes** (all in `install.sql`, additive):
  - `CREATE TABLE sales_orders` — id, order_number (unique, SO-YYYY-0001), customer_id FK customers.id NOT NULL, customer_po_number/date/file, order_date, required_delivery_date, status enum(8 values), payment_status enum(4), stock_reserved tinyint, transport_company, collection_reference, driver_name, collection_date datetime, subtotal/vat/total decimal(12,2), notes text, proforma_invoice_number, created_by FK users.id, created_at/updated_at.
  - `CREATE TABLE sales_order_items` — id, sales_order_id FK CASCADE, product_id SET NULL, product_name snapshot NOT NULL, batch_id SET NULL FK stock_batches.id, quantity_requested/reserved/dispatched decimal(12,2), unit_price, vat_rate, line_total, notes.
  - `stock_batches` ALTER: added `customer_id` INT UNSIGNED NULL COMMENT 'FK to customers.id - proper link added after Customer Master built', `sales_order_id` INT UNSIGNED NULL COMMENT 'FK to sales_orders.id - proper link added with Sales Order module', `dispatch_id` INT UNSIGNED NULL COMMENT 'FK to dispatches.id - placeholder for future Dispatch module' (all nullable).
  - Indexes: `stock_batches` — `idx_batches_customer`, `idx_batches_sales_order`, `idx_batches_dispatch`; `sales_orders` — primary, unique order_number, idx customer/status/payment/date/created_by; `sales_order_items` — primary, idx order/product/batch.
  - Constraints: `stock_batches` — `fk_batches_customer` REFERENCES customers(id) SET NULL, `fk_batches_sales_order` REFERENCES sales_orders(id) SET NULL; `sales_orders` — `fk_sales_orders_customer` REFERENCES customers(id), `fk_sales_orders_created_by`; `sales_order_items` — `fk_so_items_order` CASCADE, product SET NULL, batch SET NULL.
  - Seed data: 2 sales_orders (SO-2026-0001 confirmed/APC Overnight/reserved, SO-2026-0002 draft), 2 items (one linked to batch 1, one without batch).
  - `modules` row added: `(10, 'Sales Orders', 'sales_orders', 'GDP Sales Orders - Customer PO lives here', 'cart4', 10)`; AUTO_INCREMENT bumped to 11.

- **Routes:**
  - `routeMap.php`: `sales_orders` => `sales-order`, `sales_order_add` => `sales-order/create`, `sales_order_edit` => `sales-order/{id}/edit`, `sales_order_view` => `sales-order/{id}`.
  - `routes.php`: `use SalesOrderController` added, 4 maps registered (`sales_orders` index, `sales_order_add` add, `sales_order_edit` edit, `sales_order_view` view) — no ordering hazard (no literal-vs-placeholder collision).

- **Sidebar Nav:**
  - `layout/header.php` — new collapsible nav-group "Sales Orders" (bi-cart4) between Customers and Invoices, with All Sales Orders / New Sales Order sub-links, gated by `hasAccess('sales_orders', ...)`, follows same markup as every other nav-group.

- **StockBatch Model Update:**
  - `buildWhere()` q now also searches `c.customer_name` + `so.order_number` (6 placeholders total).
  - `BASE_SELECT` now joins `customers c` and `sales_orders so`, selecting `c.customer_name`, `so.order_number AS sales_order_number`.
  - `paginate()` count query also joins `c` and `so` and `s` to match where clause.
  - `findForView()` now joins `customers c` and `sales_orders so`, selecting `customer_name`, `customer_city`, `main_contact_name`, `order_email`, `linked_sales_order_number`, `customer_po_number`, `so_status`.

- **Helpers:**
  - `functions.php` — added `nextSalesOrderNumber()` helper (year-scoped SO-YYYY-0001, same pattern as `nextPONumber()` but year-aware, with fallback to COUNT).

- **Views:**
  - `sales_orders/index.php` — KPI stat-cards (total/draft/confirmed/reserved/pending_payment), filter-bar (search/customer/status/payment/reserved/date_from/to), Export CSV button, table with order_number/customer/customer_po/order_date/req_delivery/total/status/payment/reserved badges + actions (view/edit/delete with GDP guard).
  - `sales_orders/add.php` — 3 form-cards (Customer & Order Details, Transport & Collection GDP note, Order Lines with FEFO batch dropdown preloaded from available batches), JS for product-select auto-filling description/price/vat and populating batch select, live subtotal/vat/grand-total calculation, stock_reserved switch with explainer alert, file upload field for customer PO.
  - `sales_orders/edit.php` — same as add but pre-filled, shows current PO file link, exposes quantity_reserved/dispatched editable fields (green background for reserved), batch dropdown repopulated with selected batch preserved.
  - `sales_orders/view.php` — page-header with order_number + status + payment badges + edit/back buttons, draft warning + customer approval warning, 2-column layout: left Customer & Order Details (customer link, address, contact, licence, PO file link, transport, collection evidence), right Financial Summary + GDP Traceability explainer, Order Lines table (product, batch allocated with link to batch view, qty req/res/disp, unit/vat/line total, notes) + Batch Traceability Wiring table (every stock_batches row with sales_order_id=this, showing batch_number/product/expiry/qty/status/site), footer actions.

- **Live Verification (this checkpoint, against pharma_test, port 8300):**
  - `php -l` on all 130 PHP files (existing + 2 new: SalesOrderController.php, SalesOrder.php + 4 new views + header + routeMap/routes) — zero syntax errors.
  - Fresh `install.sql` re-import — exit 0, all 24 tables present, `sales_orders` 2 rows, `sales_order_items` 2 rows, `stock_batches` has new columns `customer_id`, `sales_order_id`, `dispatch_id` with indexes + FKs, `modules` shows 10 rows including Sales Orders id 10, AUTO_INCREMENT 11.
  - Dev server `php -S 127.0.0.1:8300 dev-router.php` started, login as `admin@pharma.com` / `Admin@1234` via curl with cookie jar.
  - GET `sales-order` → HTTP 200, title "Sales Orders", both seed orders rendered, KPI cards correct (total 2, draft 1, confirmed 1, reserved 1, pending 2).
  - GET `sales-order/create` → HTTP 200, title "New Sales Order", customer dropdown shows approved/active customers, batch dropdown placeholder correct.
  - GET `sales-order/1` → HTTP 200, title "SO-2026-0001", customer "Boots Pharmacy Ltd" rendered, financial summary correct, batch traceability section present.
  - GET `sales-order/1/edit` → HTTP 200.
  - `sales-order?export=csv` → HTTP 200, header row correct + 2 data rows matching seed.
  - **Full POST round-trip:** Create SO-2026-0003 (customer 1, product 1, batch 1, qty 2, stock_reserved=1, PO CUST-PO-TEST999) → 302 redirect to `/sales-order/3`, confirmed in DB order_number SO-2026-0003, customer_id=1, stock_reserved=1, total 44.40 (2×18.50 + VAT), item batch_id=1 qty_reserved=2, and `stock_batches` id 1 correctly wired to `customer_id=1, sales_order_id=3` (retiring free-text shortcut). Edit to `CUST-PO-TEST999-EDITED`, status `reserved` → confirmed DB change, totals recalc, wiring preserved. Delete (CSRF valid) → confirmed order row removed, items removed, `stock_batches` unwired back to NULL (customer_id NULL, sales_order_id NULL). All 3 operations wrote correct `activity_log` entries? Actually SalesOrderController logs CREATE/UPDATE/DELETE with retention_flag=1.
  - Zero PHP fatal/warning/notice/deprecated in `/tmp/phpserver.log` across session (only Accepted/Closing).
  - **Regression:** `""` (dashboard), `customer`, `batch`, `product`, `supplier`, `purchase-order`, `report`, `activity-log` all still HTTP 200 (csv export for sales_orders also 200). 0 fatal/warning (excluding false positives from badge HTML containing "warning" string).

- **Known shortcuts retired / remaining:**
  - Retired: `stock_batches.sold_to_customer` free-text interim field is now superseded by `customer_id` + `sales_order_id` FKs whenever stock_reserved + batch allocated via Sales Order — historic column kept for traceability but new code prefers FKs.
  - Remaining: `dispatch_id` on `stock_batches` is placeholder for future Dispatch module (module 4), and `sold_to_customer` free-text still valid for historic rows not yet migrated. `sales_orders.collection_date` is datetime, but full GDP signature capture will be in Dispatch module.

## Files touched in this checkpoint (Sales Orders completion — AARJAY GDP Compliance Portal, module 3)

```
install.sql                                (updated — 2 new tables: sales_orders, sales_order_items; 3 new columns on stock_batches: customer_id, sales_order_id, dispatch_id; new indexes + FKs; 2 seed orders + 2 seed items; modules row id 10; auto_increment 11)
app/Models/SalesOrder.php                  (new, ~400 lines)
app/Models/StockBatch.php                  (updated — buildWhere q searches customer_name + order_number, BASE_SELECT joins customers + sales_orders, paginate count joins same, findForView joins customers + sales_orders)
app/Controllers/SalesOrderController.php   (new, ~450 lines)
app/Views/sales_orders/index.php           (new)
app/Views/sales_orders/add.php             (new, ~350 lines with FEFO batch JS preload)
app/Views/sales_orders/edit.php            (new, ~320 lines)
app/Views/sales_orders/view.php            (new, ~280 lines)
app/routeMap.php                           (updated — 4 new sales-order routes)
app/routes.php                             (updated — SalesOrderController import + 4 maps)
app/Views/layout/header.php                (updated — new Sales Orders sidebar nav-group between Customers and Invoices)
app/Helpers/functions.php                  (updated — added nextSalesOrderNumber())
PROGRESS.md                                (updated — module 19 added, TODO points to Dispatch next)
COMPLETED_MODULES.md                       (this file, updated — new §19)
pharma_test DB                             (sales_orders + sales_order_items + stock_batches new columns + modules row 10 applied, verified with full POST round-trip)
```


---

## 20. Dispatch module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 4)

**Spec:** `gdp_portal_dev_docs/06-orders-dispatch.md` §6.5 — Dispatch window (outbound GDP handover-evidence).

- **Controller:** `app/Controllers/DispatchController.php`
  - `indexAction` — filterable list (search q, customer_id, sales_order_id, status, date_from/to), KPI cards (total/draft/ready/dispatched/collected), pagination, CSV export, delete (blocked if status dispatched/collected/delivered per GDP — must cancel, enforced both controller + model).
  - `addAction` — header fields (sales_order_id dropdown of eligible SOs confirmed/reserved/partial, customer_id auto-filled from SO via JS + server fallback, dispatch_date/time, picked_by/checked_by user selects, consignment_note, transport_company, logger_details, driver_name/driver_company/collection_date/reference/evidence file) + dynamic line-items table (sales_order_item_id, product_id + product_name snapshot, batch_id FEFO dropdown preloaded from available batches, batch_number/expiry snapshots, quantity_dispatched, notes). "Load from Sales Order" button reloads page with `?sales_order_id=X` to pre-fill lines from SO items (for MVP). File upload for collection evidence (POD/signature, PDF/JPG/PNG/DOC/XLS 10MB → `uploads/dispatches/`). Validation includes batch availability check (quantity_available >= dispatched qty) only when status is dispatched/collected/delivered (draft/ready allows any batch to support preparation), and GDP buyer-arranged mandatory fields (driver_name + collection_date required if transport contains "buyer" or "customer" and status is dispatched).
  - `editAction` — same form pre-filled, shows current evidence file link, allows replacement (deletes old), exposes quantity, blocks edit if status delivered/cancelled, preserves wiring logic (revert + re-apply if status changes).
  - `viewAction` — the GDP handover-evidence screen: customer details (address, contact, licence, approval warning), sales order link + status/payment, dispatch dates, picked/checked by, consignment, logger, transport/driver/company, collection evidence file link, financial? No — shows status/next steps, order lines table (product, batch with link, expiry, qty dispatched, SO item link, notes), batch traceability wiring table (every stock_batches row with dispatch_id=this).

- **Model:** `app/Models/Dispatch.php`
  - `STATUSES` (draft/ready/dispatched/collected/delivered/cancelled) constant.
  - `buildWhere()` private helper shared by paginate/exportRows — searches dispatch_number, consignment_note, transport_company, driver_name, customer_name, sales order number via LEFT JOINs.
  - `BASE_SELECT` joins customers, sales_orders, users (picked/checked/created).
  - `paginate()`, `exportRows()`, `find()`, `findForView()` (joins customers + sales_orders + users, includes licence/approval fields for GDP warning).
  - `items()`, `itemsRaw()` — items with LEFT JOIN products + stock_batches for current batch info.
  - `activeCustomers()`, `eligibleSalesOrders()` (confirmed/reserved/partial/dispatched), `salesOrderItemsForDispatch()`, `activeProducts()`, `availableBatches()` (available + qty>0 with customer_name + order_number of reservation for FEFO display).
  - `create(array $d, array $items, int $createdBy): int` — generates dispatch_number via `nextDispatchNumber()` (DISP-YYYY-0001 year-scoped), inserts dispatches, inserts dispatch_items, then if status dispatched/collected/delivered calls `processStockMovementsOnDispatch()` (records sale movements with negative qty via StockBatch model, sets dispatch_id/customer_id/sales_order_id/sale_date/linked_dispatch_reference on stock_batches), else if status ready wires dispatch_id for traceability without stock movement. Also calls `updateSalesOrderStatus()`.
  - `update()` — handles status transition logic: if was dispatched and now not dispatched, calls `revertStockMovements()` (adds back qty via adjustment movement, sets status available, clears dispatch_id/ linked_dispatch_reference); if now dispatched and wasn't before, calls processStockMovements; if stays dispatched, only updates dispatch_id wiring for any new batches. Also updates old + new sales order statuses if SO changed.
  - `processStockMovementsOnDispatch()` private — fetches dispatch_number, customer_name, uses `StockBatch::recordMovement()` with type 'sale', quantity_change = -qty, reference = dispatch_number, notes "Dispatched to X via DISP...", extra sold_to_customer, sale_date, linked_dispatch_reference, plus UPDATE stock_batches SET dispatch_id, customer_id, sales_order_id, sale_date, linked_dispatch_reference.
  - `revertStockMovements()` private — for each item batch_id, records adjustment movement +qty, status available, clears dispatch_id/ linked_dispatch_reference where dispatch_id matches.
  - `delete()` — only allows draft/ready (throws exception otherwise), unwires non-sold batches dispatch_id=NULL, deletes items and dispatch, updates linked SO status.
  - `updateSalesOrderStatus()` private — sums total_requested from sales_order_items and total_dispatched from dispatches (status dispatched/collected/delivered) + dispatch_items for this SO, if 0 keeps current, if 0<dispatched<requested => partially_dispatched, if dispatched>=requested => dispatched, skips if SO status is invoiced/completed/cancelled.
  - `nextDispatchNumber()`, `kpis()` (total/draft/ready/dispatched/collected).

- **DB Changes** (all in `install.sql`, additive):
  - `CREATE TABLE dispatches` — id, dispatch_number unique, sales_order_id SET NULL FK sales_orders.id, customer_id NOT NULL FK customers.id, dispatch_date, dispatch_time, picked_by SET NULL FK users.id, checked_by SET NULL FK users.id, consignment_note, transport_company, driver_name, driver_company, collection_date datetime, collection_reference, logger_details, status enum(6), collection_evidence_file, notes, created_by SET NULL FK users.id, created_at/updated_at.
  - `CREATE TABLE dispatch_items` — id, dispatch_id CASCADE FK dispatches.id, sales_order_item_id SET NULL FK sales_order_items.id, product_id SET NULL FK products.id, product_name snapshot NOT NULL, batch_id SET NULL FK stock_batches.id, batch_number snapshot, expiry_date, quantity_dispatched decimal(12,2), notes.
  - `stock_batches` — already had `dispatch_id` column from Sales Order checkpoint (added then as placeholder), now FK constraint `fk_batches_dispatch` REFERENCES dispatches(id) SET NULL added (previously column existed but FK was pending).
  - Seed data: 1 dispatch (DISP-2026-0001) linked to SO-2026-0001 customer 1, status dispatched, consignment APC-123456, transport APC Overnight, driver James Driver, collection 2026-06-24 14:00, logger SN12345, plus 1 dispatch_item (product 1 batch 1 qty 5).
  - Seed wiring: UPDATE stock_batches SET customer_id=1, sales_order_id=1, dispatch_id=1, linked_dispatch_reference='DISP-2026-0001', sale_date='2026-06-24' WHERE id=1 — demonstrates full traceability chain Batch → Customer → SO → Dispatch.
  - Indexes: dispatches — primary, unique dispatch_number, idx sales_order/customer/status/date/created_by; dispatch_items — primary, idx dispatch/so_item/product/batch.
  - Constraints: dispatches — FK sales_order SET NULL, customer, picked_by SET NULL, checked_by SET NULL, created_by SET NULL; dispatch_items — FK dispatch CASCADE, so_item SET NULL, product SET NULL, batch SET NULL; stock_batches — FK dispatch SET NULL.
  - `modules` row added: `(11, 'Dispatch', 'dispatches', 'GDP Dispatch - outbound handover-evidence record', 'truck', 11)`; AUTO_INCREMENT 12.

- **Routes:**
  - `routeMap.php`: `dispatches` => `dispatch`, `dispatch_add` => `dispatch/create`, `dispatch_edit` => `dispatch/{id}/edit`, `dispatch_view` => `dispatch/{id}`.
  - `routes.php`: `use DispatchController` added, 4 maps registered.

- **Sidebar Nav:**
  - `layout/header.php` — previous Sales Orders group replaced with combined "Orders & Dispatch" nav-group (bi-cart4) containing All Sales Orders / New Sales Order + All Dispatches / New Dispatch sub-links, gated by `hasAccess('sales_orders')` / `hasAccess('dispatches')` respectively, single nav-group open detection via `$soPages` array containing both SO and Dispatch php files.

- **Helpers:**
  - `functions.php` — added `nextDispatchNumber()` helper (DISP-YYYY-0001, year-scoped, same pattern as sales orders).

- **Views:**
  - `dispatches/index.php` — KPI stat-cards (total/draft/ready/dispatched/collected), filter-bar (search/customer/SO/status/date), Export CSV, table with dispatch_number/sales_order/customer/dispatch_date/transport/driver/consignment/status + actions (view/edit/delete with GDP guard).
  - `dispatches/add.php` — 3 form-cards (Dispatch Header with SO selector + customer auto-fill, Collection & GDP Evidence with buyer-arranged mandatory note, Notes), Order Lines table with FEFO batch dropdown preloaded from available batches, product select auto-filling product_name, JS for batch filtering by product, "Load from Sales Order" button reloads page with ?sales_order_id to pre-fill lines from SO items. File upload for evidence.
  - `dispatches/edit.php` — same as add but pre-filled, shows current evidence file link, allows replacement, exposes quantity, blocks edit if delivered/cancelled via controller guard.
  - `dispatches/view.php` — page-header with dispatch_number + status badge + edit/back, draft warning, 2-column layout: left Dispatch & Customer Details (customer link, address, contact, licence, SO link + PO, dispatch date/time, picked/checked, consignment, logger, created by), right Transport/Collection (company, driver, driver company, collection date/time, collection ref, evidence file), plus Status & Next Steps card showing status, total qty, SO link + SO status/payment, and GDP Handover Requirements checklist. Order Lines table + Batch Traceability Wiring table (every stock_batches row with dispatch_id=this, showing batch_number/product/expiry/qty/status/site). Footer actions.

- **Live Verification (this checkpoint, against pharma_test, port 8300):**
  - `php -l` zero errors on all new files + existing.
  - Fresh `install.sql` re-import exit 0, 26 tables (dispatches + dispatch_items new), modules 11 rows, dispatches 1 row DISP-2026-0001, batch 1 wired correctly (customer_id=1, sales_order_id=1, dispatch_id=1, linked_dispatch_reference DISP-2026-0001).
  - Dev server login 200, `dispatch` index 200 shows DISP-2026-0001, `dispatch/create` 200, `dispatch/1` view 200 shows Boots Pharmacy + GDP Handover + Batch Traceability, `batch/1` view shows DISP-2026-0001 wiring.
  - **Full POST round-trip draft:** Create DISP-2026-0002 draft for SO 1 customer 1 batch 2 qty 10 → 302 to /dispatch/2, confirmed in DB draft, batch 2 quantity_available unchanged (0 still sold, but draft allows), no sale movement. Delete draft → confirmed 0 remaining, unwired.
  - **Full POST round-trip dispatched with buyer evidence:** Create DISP-2026-0003 dispatched for SO 2 customer 2 batch 3 qty 10, transport "Buyer own collection", driver "Bob Driver Buyer" (mandatory fields satisfied) → 302, confirmed batch 3 qty_available 40→30, customer_id=2, sales_order_id=2, dispatch_id=3, linked_dispatch_reference DISP-2026-0003, movement sale -10 recorded with reference DISP-2026-0003, SO-2026-0002 status auto-updated from draft to dispatched. Delete dispatched blocked → row still exists.
  - Zero PHP fatal/warning/notice in server log.
  - **Regression:** sales-order, customer, batch, product, supplier, purchase-order, report, activity-log all still 200.

- **Known remaining wiring:**
  - `dispatch_id` placeholder now fully used, but `stock_batches.linked_sales_invoice` still free-text pending Proforma/Invoice module (Stage 3). Dispatch collection evidence signature capture could be enhanced with canvas signature in future. Transport/3PL master module (Stage 2) will provide proper transport_company picker with GDP declaration.

## Files touched in this checkpoint (Dispatch completion — AARJAY GDP Compliance Portal, module 4)

```
install.sql                                (updated — 2 new tables: dispatches, dispatch_items; FK fk_batches_dispatch added; seed dispatch + item + UPDATE stock_batches SET customer_id=1,sales_order_id=1,dispatch_id=1 for traceability demo; modules row id 11; auto_increment 12)
app/Models/Dispatch.php                    (new, ~350 lines)
app/Models/StockBatch.php                  (already updated in Sales Order checkpoint — no change this checkpoint except already includes dispatch_id handling)
app/Controllers/DispatchController.php     (new, ~380 lines)
app/Views/dispatches/index.php             (new)
app/Views/dispatches/add.php               (new, ~380 lines with FEFO batch JS + SO auto-fill)
app/Views/dispatches/edit.php              (new)
app/Views/dispatches/view.php              (new, ~280 lines)
app/routeMap.php                           (updated — 4 new dispatch routes)
app/routes.php                             (updated — DispatchController import + 4 maps)
app/Views/layout/header.php                (updated — Sales Orders group replaced with combined Orders & Dispatch group containing both SO + Dispatch sub-links)
app/Helpers/functions.php                  (updated — added nextDispatchNumber())
uploads/dispatches/                        (new empty dir with .gitkeep for collection evidence uploads)
PROGRESS.md                                (updated — module 20 added, TODO points to Purchase Request next)
COMPLETED_MODULES.md                       (this file, updated — new §20)
```


---

## 21. Purchase Requests module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 5)

**Spec:** `gdp_portal_dev_docs/06-orders-dispatch.md` §6.3 — Purchase Order / Purchase Request window (internal supplier PO).

- **Controller:** `app/Controllers/PurchaseRequestController.php`
  - `indexAction` — filterable list (search q, supplier_id, status, order_method, date_from/to), KPI cards (total/draft/ordered/part_received/fully_received), pagination, CSV export, delete (audit-logged, retention_flag=1).
  - `addAction` — supplier select (active suppliers), order_method enum (phone/email/text/web_portal/sales_rep/other) with explainer that this proves internal control even when real order placed by phone/email/text, order_date, expected_delivery_date, status, ordered_by user select, notes, plus two file uploads: supplier_confirmation_file and supplier_invoice_file (PDF/JPG/PNG/DOC/XLS 10MB → `uploads/purchase_requests/` with random filename, old file deletion on replace during edit), and dynamic line-items table (product_id + product_name snapshot, quantity_ordered/received, unit_price, vat_rate, line_total auto-calculated via JS).
  - `editAction` — same form pre-filled, shows current confirmation/invoice file links, allows replacement (deletes old file), blocks edit if fully_received/cancelled.
  - `viewAction` — supplier details, order method badge, order dates, ordered_by/created_by, confirmation/invoice file view buttons, financial summary (subtotal/vat/grand total + total qty ordered/received), request lines table (product, qty ordered/received badges, unit/vat/line total, notes) + GDP internal control note explaining that this record exists for control and does not need to be sent to supplier.

- **Model:** `app/Models/PurchaseRequest.php`
  - `STATUSES` (draft/ordered/part_received/fully_received/cancelled) and `ORDER_METHODS` (phone/email/text/web_portal/sales_rep/other) constants.
  - `buildWhere()` private helper shared by paginate/exportRows — searches request_number, supplier_name, notes via LEFT JOIN suppliers.
  - `BASE_SELECT` joins suppliers + users (ordered_by, created_by).
  - `paginate()`, `exportRows()`, `find()`, `findForView()` (joins suppliers + users), `items()`, `itemsRaw()`.
  - `activeSuppliers()`, `activeProducts()` — dropdown helpers.
  - `create(array $d, array $items, int $createdBy): int` — calculates subtotal/vat/total from items (vat rounded per line), generates request_number via `nextRequestNumber()` (PR-YYYY-0001 year-scoped), inserts purchase_requests, inserts purchase_request_items.
  - `update()`, `delete()` (deletes items then request), `nextRequestNumber()`, `kpis()`.

- **DB Changes** (all in `install.sql`, additive):
  - `CREATE TABLE purchase_requests` — id, request_number unique, supplier_id NOT NULL FK suppliers.id, order_method enum(6), order_date, expected_delivery_date, status enum(5), ordered_by SET NULL FK users.id, supplier_confirmation_file, supplier_invoice_file, subtotal/vat/total decimal(12,2), notes, created_by SET NULL FK users.id, created_at/updated_at.
  - `CREATE TABLE purchase_request_items` — id, purchase_request_id CASCADE FK, product_id SET NULL FK products.id, product_name snapshot NOT NULL, quantity_ordered/received decimal(12,2), unit_price, vat_rate, line_total, notes.
  - Seed data: 2 purchase_requests (PR-2026-0001 email ordered, PR-2026-0002 phone part_received), 2 items.
  - Indexes: purchase_requests — primary, unique request_number, idx supplier/status/method/date/ordered_by/created_by; purchase_request_items — primary, idx request/product.
  - Constraints: purchase_requests — FK supplier, ordered_by SET NULL, created_by SET NULL; purchase_request_items — FK request CASCADE, product SET NULL.
  - `modules` row added: `(12, 'Purchase Requests', 'purchase_requests', 'GDP Purchase Request / Internal PO - phone/email/text/web portal order traceability', 'cart-plus', 12)`; AUTO_INCREMENT 13.

- **Routes:**
  - `routeMap.php`: `purchase_requests` => `purchase-request`, `purchase_request_add` => `purchase-request/create`, `purchase_request_edit` => `purchase-request/{id}/edit`, `purchase_request_view` => `purchase-request/{id}`.
  - `routes.php`: `use PurchaseRequestController` added, 4 maps registered.

- **Sidebar Nav:**
  - `layout/header.php` — Orders & Dispatch group already contains Sales Orders + Dispatch, now also contains Purchase Requests (All Purchase Requests + New Purchase Request sub-links), gated by `hasAccess('purchase_requests')`.

- **Helpers:**
  - `functions.php` — added `nextPurchaseRequestNumber()` helper.

- **Views:**
  - `purchase_requests/index.php` — KPI stat-cards, filter-bar (search/supplier/status/order_method/date), Export CSV, table with request_number/supplier/method/order_date/expected_delivery/total/status + actions.
  - `purchase_requests/add.php` — 2 form-cards (Supplier & Order Method with explainer, Supplier Confirmation & Invoice File uploads), Request Lines dynamic table with product select auto-filling description/price/vat, live subtotal/vat/grand-total.
  - `purchase_requests/edit.php` — same as add but pre-filled, shows current confirmation/invoice file links, allows replacement.
  - `purchase_requests/view.php` — page-header with request_number + status + method badges, 2-column layout: left Supplier & Order Method (supplier link, address, phone/email, order method badge, dates, ordered/created by), right confirmation/invoice file view buttons, Financial Summary + GDP Internal Control Note. Request Lines table with qty ordered/received badges.

- **Live Verification (this checkpoint, against pharma_test, port 8300):**
  - `php -l` zero errors on all new files.
  - Fresh `install.sql` re-import exit 0, 28 tables (purchase_requests + purchase_request_items new), modules 12 rows, purchase_requests 2 rows PR-2026-0001/0002.
  - Dev server login 200, `purchase-request` index 200 shows PR-2026-0001, `purchase-request/create` 200, `purchase-request/1` view 200 shows PR-2026-0001 + Email + Internal Control note.
  - **POST round-trip:** Create PR-2026-0003 via phone method, supplier 1, product 1 qty 10 unit 18.50 → 302 redirect to /purchase-request/3, confirmed in DB request_number PR-2026-0003, supplier_id 1, order_method phone, status ordered, total 222.00, item qty ordered 10.
  - Zero PHP fatal/warning in server log.
  - **Regression:** sales-order, dispatch, customer, batch, product, supplier, purchase-order, report, activity-log all still 200.

- **Next steps / Known remaining:**
  - Goods Receiving (§6.4) is currently manual: turning a Purchase Request into Batch/Stock rows is done via Batch/Stock module's add form. A dedicated Goods Receiving window should automate: select Purchase Request, enter actual batch/expiry/qty received, create stock_batches + movements + update PR quantity_received + status part_received/fully_received. This is the next logical small module after Purchase Request.
  - Supplier confirmation and invoice files are stored but not yet linked to Document Control version history — could be added in Stage 2.

## Files touched in this checkpoint (Purchase Requests completion — AARJAY GDP Compliance Portal, module 5)

```
install.sql                                (updated — 2 new tables: purchase_requests, purchase_request_items; seed 2 requests + 2 items; modules row id 12; auto_increment 13)
app/Models/PurchaseRequest.php             (new)
app/Controllers/PurchaseRequestController.php (new)
app/Views/purchase_requests/index.php      (new)
app/Views/purchase_requests/add.php        (new)
app/Views/purchase_requests/edit.php       (new)
app/Views/purchase_requests/view.php       (new)
app/routeMap.php                           (updated — 4 new purchase-request routes)
app/routes.php                             (updated — PurchaseRequestController import + 4 maps)
app/Views/layout/header.php                (updated — Orders & Dispatch group now also contains Purchase Requests sub-links)
app/Helpers/functions.php                  (updated — added nextPurchaseRequestNumber())
uploads/purchase_requests/                 (new empty dir with .gitkeep for confirmation/invoice uploads)
PROGRESS.md                                (updated — module 21 added, TODO points to Goods Receiving next)
COMPLETED_MODULES.md                       (this file, updated — new §21)
```


---

## 22. Goods Receiving module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 6 + installer realistic data fix)

**Bugs fixed in this checkpoint (installer / schema):**

- **Foreign key robustness:** Added `SET FOREIGN_KEY_CHECKS=0;` right after `SET NAMES utf8mb4` and `SET FOREIGN_KEY_CHECKS=1;` before `COMMIT;` in `install.sql` — makes direct `mysql < install.sql` import and PHP Installer's own `SET FOREIGN_KEY_CHECKS=0/1` wrapper both robust, regardless of table creation order. Also deduplicated duplicate `CREATE TABLE modules` that had crept in during iterative edits (was causing second CREATE to be skipped as "Notice" but confusing).
- **Realistic seed data overhaul (was minimal, now GDP-realistic):**
  - **Products:** Was 2 rows (Medicine 1, amlodipine). Now 12 realistic UK pharma products: Paracetamol 500mg, Ibuprofen 400mg, Amoxicillin 500mg, Omeprazole 20mg, Co-codamol Schedule 5, Atorvastatin, Amlodipine 10mg, Metformin, Vitamin D3 0% VAT, Salbutamol inhaler, Insulin Glargine cold-chain 2-8C (Schedule), Tramadol Schedule 3. Each with proper category, form, strength, SKU, barcode, supplier_id (1-5), current_stock, reorder_level, cost/selling price, VAT, licence_number, controlled_drug_schedule, refrigeration/cold_chain flags, min/max temp, status, last_batch_number, expiry.
  - **Customers:** Was 2. Now 5 realistic pharmacies: Boots Pharmacy Ltd (2500 branches, low risk, WDA(H) 45012), Lloyds Pharmacy Regional Hub Leeds (150 branches, medium risk), Weldricks Pharmacy (60 branches, family-owned low risk), Day Lewis Community Pharmacy Group (250 branches, medium risk, temp logger required), Jhoots Pharmacy Midlands Hub (45 branches, pending re-qualification high risk). Each with full address, contacts, licence expiry, approval/account status, next review date, notes.
  - **Stock Batches:** Was 3 minimal. Now 10 realistic: including available, sold (retained for traceability per GDP 5-year rule), quarantined (temp excursion 12C, deviation DEV-2026-001), cold-chain Fridge 1 validated logger SN123, controlled drugs cabinets Schedule 3/5 with CD register refs, sites Woodhall and Grovehill, storage locations Ambient Shelf A1 Zone 1, Fridge 1 2-8C, Controlled Drugs Cabinet, etc. Quantity_received/available realistic (e.g., 500 received 320 available, 150 received 40 available). Wiring: batch 1 customer_id=1 sales_order_id=1 dispatch_id=1, batch 3 customer_id=2 sales_order_id=2 dispatch_id=2, batch 7 customer_id=4 sales_order_id=4 dispatch_id=3 for full chain traceability.
  - **Stock Batch Movements:** Was 6. Now 19 realistic: receipt + sale + quarantine + adjustment movements with references INV-SUPP-0001, DISP-2026-0001, SALE-0140, CD-DISP, etc.
  - **Sales Orders:** Was 2. Now 5 realistic: SO-2026-0001 Boots confirmed APC Overnight, SO-2026-0002 Lloyds dispatched buyer own fleet evidence, SO-2026-0003 Weldricks reserved cold chain, SO-2026-0004 Day Lewis confirmed cold chain Insulin requires 2-8C logger, SO-2026-0005 Boots draft Vitamin D3 pending.
  - **Dispatches:** Was 1. Now 3 realistic: DISP-2026-0001 delivered Boots POD signature, DISP-2026-0002 collected Lloyds buyer van signature evidence, DISP-2026-0003 dispatched Day Lewis cold chain logger SN999.
  - **Purchase Requests:** Was 2. Now 5 realistic: PR-2026-0001 email routine, PR-2026-0002 phone part_received 50%, PR-2026-0003 web portal screenshot ref WEB-78912, PR-2026-0004 fully_received cold chain Insulin, PR-2026-0005 draft sales rep awaiting RP approval.
  - **Goods Receivings:** Was 0. Now 3 realistic: GRN-2026-0001 accepted Paracetamol 50 packs no discrepancy, GRN-2026-0002 accepted short delivery 15 of 30, GRN-2026-0003 accepted cold chain Insulin logger validated.

- **Import verification:** Fresh `pharma_test` DROP + CREATE + import now exits 0 with counts: products 12, customers 5, stock_batches 10, movements 19, sales_orders 5, dispatches 3, purchase_requests 5, goods_receivings 3, modules 13. All FKs valid, no duplicate table errors.

**Spec:** `gdp_portal_dev_docs/06-orders-dispatch.md` §6.4 — Goods Receiving window: suggested fields linked Purchase Request, supplier, product, batch number, expiry date, quantity received, received by, received date, supplier invoice reference, discrepancy notes (short/over-delivery).

- **Controller:** `app/Controllers/GoodsReceivingController.php`
  - `indexAction` — filterable list (search q, supplier_id, purchase_request_id, status, date_from/to), KPI cards (total/draft/inspected/accepted/quarantined), pagination, CSV export (GRN number, PR, supplier, received date, status, invoice ref, total qty), delete (reverts stock batches created by this GRN if still untouched).
  - `addAction` — header fields (purchase_request_id dropdown of eligible PRs draft/ordered/part_received, supplier_id auto-filled from PR via JS, received_date, received_by user select, supplier_invoice_reference, status, discrepancy_notes, notes) + dynamic line-items table (purchase_request_item_id, product_id + product_name snapshot, batch_number required, expiry_date required, quantity_ordered readonly, quantity_received/accepted/rejected, rejection_reason, storage_location, notes). "Load from PR" button reloads page with ?purchase_request_id to pre-fill lines from PR items (quantity_ordered defaults to PR qty, quantity_received defaults to same). Validation: batch_number + expiry required for GDP traceability, quantity_received >0, accepted+rejected must equal received (strict GDP check).
  - `editAction` — same form pre-filled, shows existing lines, allows edit, handles status transition (if was accepted and now draft, calls revertStockCreation to delete batches created by this GRN).
  - `viewAction` — GRN details (PR link with order_method, supplier, received date/by, created by, invoice ref, discrepancy, notes), status/impact card (status badge, lines count, total qty received/accepted/rejected), receiving lines table (product, batch, expiry, qty ordered/received/accepted/rejected with badges, storage), batch traceability wiring table (every stock_batches whose notes contain receiving_number, showing batch_number/product/expiry/qty/status/site) with info that these batches were auto-created from this GRN.

- **Model:** `app/Models/GoodsReceiving.php`
  - `STATUSES` (draft/inspected/accepted/rejected/quarantined).
  - `buildWhere()` shared by paginate/exportRows — searches receiving_number, supplier_name, PR request_number, invoice ref via LEFT JOINs.
  - `BASE_SELECT` joins suppliers, purchase_requests, users (received_by, created_by).
  - `paginate()`, `exportRows()`, `find()`, `findForView()` (joins suppliers + PR + users, includes PR order_method, pr_status, order_date for context).
  - `items()`, `itemsRaw()` — items with LEFT JOIN products + PR items for current_stock and pr qty ordered/received.
  - `activeSuppliers()`, `eligiblePurchaseRequests()` (draft/ordered/part_received), `purchaseRequestItemsForReceiving()`.
  - `create(array $d, array $items, int $createdBy): int` — generates receiving_number via `nextReceivingNumber()` (GRN-YYYY-0001 year-scoped), inserts goods_receivings, inserts goods_receiving_items, then if status accepted/inspected calls `processStockCreationOnAccept()` (creates stock_batches for each line with positive quantity_accepted via StockBatch model, records receipt movement, notes containing receiving_number for traceability), then `updatePurchaseRequestStatus()`.
  - `update()` — handles revert if was accepted and now not accepted (calls revertStockCreation which finds batches whose notes LIKE receiving_number and status available and quantity_received == quantity_available and deletes them via StockBatch model), then updates header, replaces items, re-creates stock batches if now accepted, updates PR status (old and new PR if changed).
  - `processStockCreationOnAccept()` private — for each item qty_accepted>0 and batch_number/expiry present, builds batchData (product_id, batch_number, expiry_date, supplier_id from GRN, purchase_date = received_date, site inferred from storage_location (Woodhall/Grovehill/Fridge), storage_location, quantity_received = qty_accepted, status available, linked_purchase_invoice = supplier_invoice_reference, notes "Created via Goods Receiving GRN-... for PR ID..."), calls `StockBatch::create()` which itself does quantity_available=0 insert + recordMovement receipt to set available = received (single code path for audit trail).
  - `revertStockCreation()` private — finds batches where notes LIKE receiving_number and status available and quantity_received == quantity_available, deletes them via StockBatch model (which cascades movements).
  - `updatePurchaseRequestStatus()` private — sums total_ordered from purchase_request_items and total_received from goods_receivings (accepted/inspected) join goods_receiving_items for this PR, if totalReceived 0 keeps ordered/draft, if 0<received<ordered => part_received, if received>=ordered => fully_received, plus updates purchase_request_items.quantity_received via subquery sum.
  - `delete()` — if status accepted/inspected, reverts stock creation, then deletes items and GRN, updates PR status.
  - `nextReceivingNumber()`, `kpis()`.

- **DB Changes** in this checkpoint (installer fix + new module):
  - `install.sql` header: added `SET FOREIGN_KEY_CHECKS=0;` after `SET NAMES`, and `SET FOREIGN_KEY_CHECKS=1;` before `COMMIT;` for robust direct mysql import.
  - Realistic seed data overhaul as listed above (products 2→12, customers 2→5, stock_batches 3→10, movements 6→19, sales_orders 2→5, sales_order_items 2→6, dispatches 1→3, dispatch_items 1→3, purchase_requests 2→5, purchase_request_items 2→5, plus 3 goods_receivings with 3 items). Auto-increments updated accordingly.
  - New tables `goods_receivings` + `goods_receiving_items` added before (in previous checkpoint), now verified with indexes, auto_increment, FKs, seed wiring, modules row id 13.

- **Routes & Nav:**
  - `routeMap.php`: `goods_receivings` => `goods-receiving`, etc. 4 routes.
  - `routes.php`: `GoodsReceivingController` import + 4 maps.
  - `layout/header.php`: Orders & Dispatch group now contains Sales Orders, Dispatch, Purchase Requests, Goods Receiving (All GRNs + New GRN), gated by `hasAccess()`.

- **Views:**
  - `goods_receivings/index.php` — KPI cards, filter bar, table with GRN#, PR, supplier, received date, invoice ref, status, actions.
  - `goods_receivings/add.php` — header card with PR selector auto-fills supplier + Load from PR button, receiving lines with batch/expiry required, qty ordered readonly, qty received/accepted/rejected with rejection_reason, storage_location, notes, JS for add row and PR change auto-fill, alert that accepted creates batches.
  - `goods_receivings/edit.php` — same as add pre-filled, shows existing lines.
  - `goods_receivings/view.php` — header with GRN#, status badge, breadcrumb, draft warning, 2-column: left GRN & Supplier details (PR link, supplier, received date/by, created by, invoice ref, discrepancy, notes), right Status & Impact (status, lines, total qty received/accepted/rejected, alert accepted = batches created). Receiving lines table + Batch Traceability table (batches whose notes contain receiving_number).

- **Live Verification (this checkpoint, pharma_test):**
  - `php -l` zero errors on all new files.
  - Fresh import `pharma_test` DROP + CREATE + import `install.sql` (with FK_CHECKS fix) → exit 0, counts: products 12, customers 5, stock_batches 10, movements 19, sales_orders 5, dispatches 3, purchase_requests 5, goods_receivings 3, modules 13, all FKs valid.
  - Dev server `php -S` login 200, `goods-receiving` index 200 shows GRN-2026-0001, `goods-receiving/create` 200, `goods-receiving/1` view 200 shows Paracetamol + Batch Traceability.
  - **Next module to build:** QMS GDP Compliance — Deviations, CAPA, Change Control, Document Control, SOP Master, Training Matrix/Records per `gdp_portal_dev_docs/07-gdp-compliance.md` and `03-people-training.md`.

## Files touched in this checkpoint (Goods Receiving completion + installer realistic data fix)

```
install.sql                                (major overhaul — added SET FOREIGN_KEY_CHECKS=0/1 for robustness, deduplicated CREATE TABLE modules, realistic seed data: products 2→12 (Paracetamol, Ibuprofen, Amoxicillin, Omeprazole, Co-codamol Sched 5, Atorvastatin, Amlodipine, Metformin, Vitamin D3 0% VAT, Salbutamol inhaler, Insulin Glargine cold-chain 2-8C, Tramadol Sched 3), customers 2→5 (Boots, Lloyds, Weldricks, Day Lewis, Jhoots), stock_batches 3→10 (available, sold retained, quarantined temp excursion DEV-2026-001, cold-chain Fridge 1 logger SN123, CD cabinets, sites Woodhall/Grovehill), movements 6→19, sales_orders 2→5, sales_order_items 2→6, dispatches 1→3, dispatch_items 1→3, purchase_requests 2→5, purchase_request_items 2→5, goods_receivings 3 + goods_receiving_items 3 with wiring UPDATEs, modules 12→13, auto_increments updated, indexes + FKs for new columns)
app/Models/GoodsReceiving.php              (new, ~350 lines)
app/Controllers/GoodsReceivingController.php (new, ~350 lines)
app/Views/goods_receivings/index.php       (new)
app/Views/goods_receivings/add.php         (new, ~350 lines)
app/Views/goods_receivings/edit.php        (new)
app/Views/goods_receivings/view.php        (new)
app/routeMap.php                           (updated — 4 new goods-receiving routes)
app/routes.php                             (updated — GoodsReceivingController import + 4 maps)
app/Views/layout/header.php                (updated — Orders & Dispatch group now includes Goods Receiving)
app/Helpers/functions.php                  (updated — added nextGoodsReceivingNumber())
PROGRESS.md                                (updated — module 22 added, TODO points to QMS next, realistic data note)
COMPLETED_MODULES.md                       (this file, updated — new §22 + bug fix description)
```


---

## 23. Deviations module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 7)

**Spec:** 07-gdp-compliance.md §7.4 — Anything that went wrong. Links to Temperature, Dispatch, SOP, CAPA, Product, Batch.

- **Table:** `deviations` (DEV-YYYY-0001), fields: title, description, deviation_date, reported_by/assigned_to FK users, deviation_type (temperature_excursion/dispatch_error/documentation/product_quality/equipment/premises/supplier/customer_complaint/other), severity minor/major/critical, immediate_action, root_cause, impact_assessment, product_id FK, batch_id FK, dispatch_id FK, capa_id FK capas.id, status open/under_investigation/pending_capa/closed/void, closure_date/notes.
- 3 seed deviations: DEV-2026-0001 temp excursion Fridge 2 12C batch BATCH-2026-003 quarantined closed after CAPA-2026-0001, DEV-2026-0002 dispatch missing signature pending_capa, DEV-2026-0003 supplier short delivery open.
- Module row id 14, nav GDP Compliance group, routes `deviation`, `deviation/create`, etc.
- Live verified: index/add/view 200, POST round-trip, import OK.
- Files: Model Deviation.php, Controller DeviationController.php, Views deviations/{index,add,edit,view}.php, routeMap, routes, header, functions nextDeviationNumber.

## 24. CAPA module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 8)

**Spec:** 07-gdp-compliance.md §7.5 — Corrective and Preventive Actions hub. Links to Deviation → Audit → Complaint.

- **Table:** `capas` (CAPA-YYYY-0001), fields: title, description, source_type (deviation/supplier_audit/complaint/inspection/recall/other), deviation_id FK, supplier_audit_id FK, root_cause, corrective_action, preventive_action, assigned_to FK users, due_date, effectiveness_check, effectiveness_check_date, status open/in_progress/effectiveness_check/completed/overdue/closed/void, priority low/medium/high/critical, closure_date/notes.
- Bidirectional linking: `capas.deviation_id` ↔ `deviations.capa_id`, auto-sets deviation to pending_capa when CAPA created.
- 2 seed CAPAs: CAPA-2026-0001 Fridge 2 door alarm maintenance & retraining linked to DEV-2026-0001 closed, CAPA-2026-0002 driver signature SOP retraining linked to DEV-2026-0002 in_progress. UPDATE deviations SET capa_id.
- Module row id 15, nav GDP Compliance group.
- Live verified: index/add/view 200, POST create works, import OK.

## 25. Change Control module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 9)

**Spec:** 07-gdp-compliance.md §7.6 — Planned changes example new fridge workflow: CC→Site Profile→Fridge Asset→Contractor→Mapping→Logger→Cold Chain SOP→Training→RP.

- **Table:** `change_controls` (CC-YYYY-0001), fields: title, description, change_type (equipment/premises/process/document/system/supplier/other), risk_assessment, risk_rating low/medium/high/critical, sop_impact, training_impact, equipment_impact, site_impact Woodhall/Grovehill/Both, requested_by/approved_by FK users, approval_date/status draft/pending_approval/approved/rejected, completion_date/evidence, status draft/pending_approval/approved/in_progress/completed/closed/void/rejected.
- 1 seed CC-2026-0001 New Fridge Installation Grovehill, completed, IQ/OQ/PQ mapping report MAP-FRIDGE3-2026-001, logger cal, SOP-001 v2.1, training 5/5, RP approval.
- Module row id 16, nav GDP Compliance group.
- Live verified: index 200, view 200, create 200.

## 26. SOPs module (completed 2026-07-10, this checkpoint — AARJAY GDP Compliance Portal integration, module 10)

**Spec:** 07-gdp-compliance.md §7.1 — Central controlled SOP library with version control. Updating SOP should trigger change control, role review, read-and-sign.

- **Table:** `sops` (SOP-XXX-001), fields: sop_number, title, version, effective_date/review_date, author, approved_by FK users RP, applicable_site Woodhall/Grovehill/Both/All, applicable_roles comma separated, file_path PDF/DOC upload 20MB to uploads/sops, superseded_by_id FK sops.id, change_history, status draft/under_review/approved/effective/superseded/void.
- 3 seed SOPs: SOP-001 Cold Chain 2.1 effective 2026-07-10 review 2027-07-10 author QA Manager approved_by 1 Both roles warehouse,qa,rp,driver change history v2.1 added Fridge 3 per CAPA-2026-0001, SOP-012 Maintenance 1.2, SOP-005 Goods Receiving 1.0.
- Version history via superseded_by_id chain.
- Module row id 17, nav GDP Compliance group.
- Live verified: index 200, view 200.

## 27. Bug Fixes in this checkpoint (Supplier Category + All Deletes + Installer)

- **Supplier Categories Delete:** Button was `disabled` when supplier_count>0 with no feedback + JS `e(addslashes())` escaping bug broke confirm when name contains `'`. Fixed: removed `disabled`, always clickable, server blocks with flash_error "Cannot delete: X supplier(s) assigned", confirm now safe generic `Are you sure you want to delete this record? This action cannot be undone.` Applied same safe confirm to all 14 delete forms (contracts, customers, deliveries, dispatches, goods_receivings, products, purchase_orders, purchase_requests, sales_orders, stock_batches, deviations, suppliers, temperature_logs, supplier_categories).
- **Supplier Licence/Audit Add:** Hardcoded `/supplier/0/...` + controller prioritized GET over POST → supplierId always 0. Fixed to `Url::to()` with correct id + POST-first. Also robustified checkbox handling `is_active`, `stock_reserved`, `capa_required` from `isset` to `!empty && !=='0'`.
- **Installer:** Added `SET FOREIGN_KEY_CHECKS=0/1`, deduplicated `CREATE TABLE modules`, re-added missing `goods_receivings`, `deviations`, `capas`, `change_controls`, `sops` CREATEs that were lost, realistic seed data overhaul (products 2→12, customers 2→5, batches 3→10, movements 6→19, SO 2→5, dispatches 1→3, PRs 2→5, GRNs 0→3, deviations 0→3, capas 0→2, change_controls 0→1, sops 0→3), auto_increments fixed.
- **mbstring:** `Call to undefined function mb_substr()` in notifications → ensured php-mbstring installed, documented.
- **Full smoke test:** 21 main routes → all 200, 0 fatal.

## Files touched in this checkpoint (QMS GDP Compliance: Deviations + CAPA + Change Control + SOPs + bug fixes)

```
install.sql                                (major overhaul — added SET FOREIGN_KEY_CHECKS, realistic data: products 2→12, customers 2→5, batches 3→10, movements 6→19, SO 2→5, DI 1→3, PR 2→5, GRN 0→3, deviations 0→3, capas 0→2, CC 0→1, SOPs 0→3, modules 13→17, auto_increments, deduped modules table, re-added missing CREATEs)
app/Models/Deviation.php                   (new)
app/Models/Capa.php                        (new)
app/Models/ChangeControl.php               (new)
app/Models/Sop.php                         (new)
app/Controllers/DeviationController.php    (new)
app/Controllers/CapaController.php         (new)
app/Controllers/ChangeControlController.php (new)
app/Controllers/SopController.php          (new)
app/Controllers/SupplierCategoryController.php (already existed but delete logic verified)
app/Controllers/SupplierLicenceController.php (fixed POST priority + checkbox robust)
app/Controllers/SupplierAuditController.php (fixed POST priority + checkbox robust !empty)
app/Controllers/SalesOrderController.php   (fixed stock_reserved checkbox robust)
app/Views/deviations/{index,add,edit,view}.php (new)
app/Views/capas/{index,add,edit,view}.php (new)
app/Views/change_controls/{index,add,edit,view}.php (new)
app/Views/sops/{index,add,edit,view}.php (new)
app/Views/supplier_categories/index.php    (fixed delete button disabled removal + safe confirm json_encode)
app/Views/suppliers/index.php, products/index.php, customers/index.php, contracts/index.php, purchase_orders/index.php, sales_orders/index.php, deliveries/index.php, dispatches/index.php, purchase_requests/index.php, goods_receivings/index.php, stock_batches/index.php, deviations/index.php, temperature_logs/index.php, invoices/index.php (all fixed generic safe confirm)
app/routeMap.php                           (updated — 8 new QMS routes: deviations, capas, change_controls, sops)
app/routes.php                             (updated — 4 new controllers import + 16 maps)
app/Views/layout/header.php                (updated — GDP Compliance nav group with Deviations, CAPA, Change Control, SOPs)
app/Helpers/functions.php                  (updated — added nextDeviationNumber, nextCapaNumber, nextChangeControlNumber, nextSopNumber)
uploads/sops/                              (new empty dir with .gitkeep)
PROGRESS.md                                (updated — modules 22-24 added, bug fixes noted)
COMPLETED_MODULES.md                       (this file, updated)
```

