# JLR EPC Connector

Plugin: `plugins/webkul/jlr-epc/`

---

## What it does

Connects to the JLR Electronic Parts Catalogue (jlrepc.com) — a Salesforce Commerce Cloud (SFCC) PWA backed by a ForgeRock identity layer. Authenticates as a trade account, pulls the full vehicle list and catalogue hierarchy, stores everything locally, and serves it via the Browse Catalogue page without hitting the live API again.

---

## Authentication flow

This took significant reverse-engineering from browser DevTools. The flow is **not** simple OAuth2 — it involves three systems.

### Systems involved

| System | Role | Domain |
|---|---|---|
| ForgeRock AM | Identity provider (login) | `enterprise.jaguarlandrover.com` |
| SFCC SLAS | Token issuer (API access) | `zvjct2je.api.commercecloud.salesforce.com` (proxied via `jlrepc.com/mobify/proxy/api/shopper/auth/v1/...`) |
| SFCC PWA | The JLR EPC website | `www.jlrepc.com` |

### The full sequence

```
1. ForgeRock REST auth (callback-based)
   POST enterprise.jaguarlandrover.com/auth/json/realms/root/realms/b2b/authenticate
   ?service=iepc-login&goto=https://www.jlrepc.com/callback/forgerock
   &gotoOnFail=...&realm=b2b&tree=iepc-login&authIndexType=service&authIndexValue=iepc-login

   Round 1  →  ForgeRock returns NameCallback + PasswordCallback
   Round 2  →  We send email + password → ForgeRock returns tokenId (JWT) + sets SSOSession cookie

2. SFCC SLAS PKCE authorize
   GET jlrepc.com/mobify/proxy/api/shopper/auth/v1/.../oauth2/authorize
   ?client_id=d122e7d6-21b2-4e0e-b5f3-8a8e4819dac2&channel_id=jlr-epc
   &response_type=code&redirect_uri=https://www.jlrepc.com/callback/forgerock
   &code_challenge=...&code_challenge_method=S256

   → 303 redirect to ForgeRock OAuth2:
     enterprise.jaguarlandrover.com/auth/oauth2/realms/root/realms/b2b/authorize
     ?client_id=IEPCPortalClient&redirect_uri=zvjct2je.api.commercecloud.../idp/callback/forgerock-iepc
     &scope=openid profile&response_type=code&state=...

3. ForgeRock OAuth2 authorize
   We follow with our SSOSession cookie (from step 1) set on the jaguarlandrover.com domain.
   ForgeRock sees the existing session → skips re-auth → immediately issues a ForgeRock code
   → 302 redirect to SFCC SLAS IDP callback:
     zvjct2je.api.commercecloud.salesforce.com/shopper/auth/v1/idp/callback/forgerock-iepc
     ?code=<FORGEROCK_CODE>&iss=...&state=...

4. SFCC SLAS IDP callback
   SFCC receives the ForgeRock code, validates it server-to-server with ForgeRock,
   creates an SFCC authorization code, and redirects to the redirect_uri:
   → 303 redirect to:
     https://www.jlrepc.com/callback/forgerock?code=<SFCC_CODE>&usid=...&state=...

5. Token exchange
   POST jlrepc.com/mobify/proxy/api/shopper/auth/v1/.../oauth2/token
   grant_type=authorization_code_pkce
   code=<SFCC_CODE>
   code_verifier=<from step 2>
   client_id=d122e7d6-...
   channel_id=jlr-epc
   redirect_uri=https://www.jlrepc.com/callback/forgerock

   → Returns: access_token (30 min), refresh_token (90 days), id_token
```

### Key constants

```php
CLIENT_ID  = 'd122e7d6-21b2-4e0e-b5f3-8a8e4819dac2'
CHANNEL_ID = 'jlr-epc'
SFCC_ORG   = 'f_ecom_bjkk_prd'
SFCC_HOST  = 'zvjct2je.api.commercecloud.salesforce.com'  // proxied via jlrepc.com
FORGEROCK_REALM = '/realms/root/realms/b2b'
FORGEROCK_TREE  = 'iepc-login'
```

### Critical lessons learned

**1. The `goto` parameter is mandatory**
Without `?goto=https://www.jlrepc.com/callback/forgerock` in the ForgeRock authenticate URL, ForgeRock uses a different identity store and rejects credentials that are otherwise correct. The `goto` param gates which LDAP/store is used.

**2. `X-Requested-With: forgerock-sdk` header required**
The ForgeRock AM REST endpoint must receive this header (sent by the ForgeRock JS SDK) to return the correct callback-based JSON response and to route to the right auth tree path.

**3. ForgeRock returns a simple 2-round flow (with goto)**
- Round 1: `NameCallback` + `PasswordCallback` (no IdP selector)
- Round 2: send credentials → receive `tokenId`

Without `goto`, round 1 returns `SelectIdPCallback + NameCallback` (IdP selector). Choosing `localAuthentication` and continuing still fails because the wrong identity store is being queried.

**4. SFCC SLAS supported grant types for this org**
`authorization_code`, `refresh_token`, `client_credentials`, `authorization_code_pkce`, `session_bridge`

`urn:ietf:params:oauth:grant-type:sso_token` is **NOT** supported here.
`session_bridge` is supported but requires an SFCC-generated code, not the raw ForgeRock tokenId.

**5. The redirect chain has two `?code=` params — only the second is the SFCC code**
- ForgeRock → SFCC IDP callback URL contains `?code=FORGEROCK_CODE` (intermediate)
- SFCC IDP callback → `jlrepc.com/callback/forgerock?code=SFCC_CODE` (this is the one to exchange)

Only extract the code when the URL host is `www.jlrepc.com`.

**6. `jlrepc.com/callback/forgerock` is a React client-side route**
Calling it server-side returns the SPA HTML shell (200 OK), not a redirect or tokens. Do NOT try to extract tokens from it. Only use it as the `redirect_uri` parameter value — capture the `?code=` from the URL before following it.

**7. GuzzleHttp CookieJar cannot be cast to string**
`(string) $jar` is a fatal error. Use `count($jar)` or `$jar->toArray()`.

**8. Laravel HTTP client `->post($url, $data)` sends form-encoded by default**
Use `->asJson()->post(...)` for JSON bodies to ForgeRock. Using `->withHeaders(['Content-Type' => 'application/json'])` alone does NOT change encoding.

---

## Token storage

Stored in Spatie Settings group `jlr_epc` (`JlrEpcSettings`):

| Key | Description |
|---|---|
| `email` | JLR EPC account username |
| `password` | JLR EPC account password |
| `access_token` | SFCC SLAS JWT (30 min lifetime) |
| `refresh_token` | SFCC SLAS refresh token (~90 days) |
| `access_token_expires_at` | Unix timestamp for auto-refresh |
| `rate_limit_ms` | Delay between extraction API calls (default 500ms) |

`getAccessToken()` auto-refreshes using `grant_type=refresh_token` if within 60s of expiry. Full re-authentication is only needed when the refresh token expires (~90 days).

---

## Data extraction

### Sync Vehicles

Calls `jlrepc.com/mobify/proxy/api/product/shopper-products/v1/.../categories/root`

Stores in `jlr_epc_vehicles`:

| Column | Source |
|---|---|
| `sfcc_id` | `cat.id` |
| `cat_hie_id` | `cat.c_catHieId` — used for all subsequent EPC hierarchy calls |
| `name` | `cat.name` |
| `model_code` | `cat.c_modelCode` |
| `model_year` | `cat.c_modelYear` |
| `brand` | `cat.c_brand` (`LR` = Land Rover, else Jaguar) |
| `vehicle_line` | `cat.c_vehicleLine` |
| `sfcc_thumbnail_url` | `cat.image.link` |
| `thumbnail_r2_key` | Saved to R2 immediately (signed URL expires) |

### Extract All

Recursive walk of the EPC catalogue hierarchy. Dispatches chained jobs:

```
ExtractVehicleJob (per vehicle)
  └─ FetchHierarchyJob (per top-level section / sub-model)
       └─ FetchHierarchyJob (per child section, recursive)
            └─ FetchPartsJob (when leaf node reached)
```

#### Shared Catalogue vehicles (certain Jaguars — e.g. XJ40)

Some older Jaguar models use a "Shared Catalogue" structure where the vehicle has multiple
sub-model configurations (production ranges or market variants) before the actual sections
appear. On the JLR EPC website these show as a second-tier tile grid (e.g.
`/shared-catalogue/276`).

**API behaviour:** `fetchMajorSections(sfcc_id)` returns `catHieType=5` sub-model selector
nodes (not sections). Each has its own `catId = catHieId`. Calling `fetchHierarchy(catHieId)`
on a `catHieType=5` node correctly returns its 23 real sections — so `FetchHierarchyJob`
handles these nodes without any special casing.

**The original bug:** The API returns `parentCatHieId = ""` (empty) for these sub-model
nodes. `ExtractVehicleJob` was storing that as `parent_cat_hie_id = NULL`, but
`BrowseCatalogue::currentNodes()` queries `WHERE parent_cat_hie_id = vehicle->cat_hie_id`.
The nodes were in the DB all along — just unreachable.

**The fix** (`ExtractVehicleJob.php:115`): When `parentCatHieId` is empty from the API,
fall back to `$vehicle->cat_hie_id` instead of `null`. Normal vehicles are unaffected
because their API responses already carry the correct `parentCatHieId`.

**One-off DB fix for existing vehicles** (run once after deploy on any environment that
extracted shared-catalogue vehicles before the fix):

```php
// php artisan tinker
\Webkul\JlrEpc\Models\JlrVehicle::all()->each(function ($v) {
    $fixed = \Webkul\JlrEpc\Models\JlrNode::where('vehicle_id', $v->id)
        ->whereNull('parent_cat_hie_id')
        ->update(['parent_cat_hie_id' => $v->cat_hie_id]);
    if ($fixed > 0) echo "Fixed {$fixed} node(s) for: {$v->name}\n";
});
```

The XJ40 is the only known shared-catalogue vehicle currently in the DB (3 sub-model nodes
fixed). Any future Jaguar shared-catalogue models will work correctly at extraction time
without needing this one-off fix.

**`jlr_epc_nodes`** — every catalogue node (section, sub-model, subsection, leaf):

| `cat_hie_type` value | Meaning |
|---|---|
| `2` | Regular section node (normal vehicles) |
| `3` | Sub-section / leaf section |
| `5` | Sub-model selector (shared-catalogue vehicles only — sits between vehicle root and sections) |

**`jlr_epc_nodes`** — every catalogue node (section, sub-model, subsection, leaf):

| Column | Description |
|---|---|
| `vehicle_id` | FK to `jlr_epc_vehicles` |
| `cat_hie_id` | EPC hierarchy ID (unique per vehicle) |
| `parent_cat_hie_id` | Parent node ID |
| `cat_id` | Category ID (used for diagram image key) |
| `cat_hie_type` | Node type from API |
| `cat_hie_desc` | Section description / label |
| `breadcrumb_path` | Full path string for display |
| `is_leaf` | True when node has parts (no children) |
| `fetch_status` | `pending / fetching / done / failed` |
| `icon_r2_key` | Section icon saved to R2 |
| `diagram_r2_key` | Exploded diagram image saved to R2 |

**`jlr_epc_parts`** — individual parts on leaf nodes:

| Column | Description |
|---|---|
| `node_id` | FK to `jlr_epc_nodes` |
| `apn` | Aftermarket Part Number |
| `callout_code` | Bubble number on the diagram |
| `quantity` | Qty per vehicle |
| `unit_price` / `currency` | Price from API |
| `description` | Part description |
| `cp_from_vin` / `cp_to_vin` | VIN applicability ranges (JSON) |
| `features` | Applicable feature/option codes (JSON) |
| `osi_ind` / `reman_ind` | OSI / remanufactured flags |
| `supersession` / `supersession_fetched_at` | Supersession chain JSON + fetch checkpoint — see "Supersession chains" below |

**`jlr_epc_extraction_runs`** — one row per ExtractVehicleJob run, tracks progress.

### Images

Exploded diagrams have **short-lived signed URLs** (~15–60 min). They are downloaded immediately during `FetchPartsJob` (not deferred) and stored to R2/local disk under:

- Vehicle thumbnails: `jlr-epc/thumbnails/{sfccId}.{ext}`
- Section icons: `jlr-epc/icons/{catHieId}.{ext}`
- Exploded diagrams: `jlr-epc/diagrams/{catId}/{catHieId}.{ext}`

---

## Supersession chains (2026-09-29)

The `osi_ind = 'Y'` flag on a `jlr_epc_parts` row (already captured from `catEntries` since
the original build) turns out to gate a **separate, part-level API endpoint** on jlrepc.com
— clicking an OSI-flagged part in JLR's own UI opens a "Supersession" panel backed by:

```
GET https://www.jlrepc.com/mobify/proxy/apigee/iepc/part/api/v1/supersession?apn={apn}&langCode=EN&market=GB
```

(Note: `market`, not `marketCode` like the catalogue endpoints' JSON body uses — confirmed
from real DevTools traffic, easy to get wrong by analogy with the other endpoints.) Same
`mobify/proxy/apigee/iepc/...` host/proxy/token as everything else — `JlrEpcClient::fetchSupersession()`
reuses `getEpcToken()` exactly like `fetchHierarchy()`/`fetchParts()`.

**Response shape** (`responseObject`, stored verbatim into `jlr_epc_parts.supersession`):

```json
{
  "apn": "LR163197", "apnDesc": "PLUG - ENGINE", "market": "GB", "langCode": "EN",
  "supersededApns": [],
  "supersession": [
    { "apn": "LR179272", "apnDesc": "PLUG - ENGINE", "quantity": 1, "comment": null,
      "groupNumber": 1, "sequenceNumber": 1, "qtyMultiplier": true, "supersession": [] }
  ]
}
```

**Confirmed live** (not assumed from the one example above):
- No chain at all → `HTTP 200` with `"supersession": null`, not a 404 — `fetchSupersession()`
  treats null-return the same either way; the 404 branch in the client is a defensive
  fallback, not the actual mechanism observed.
- `supersession` genuinely branches (more than one entry at the same level — JLR's
  `groupNumber`/`sequenceNumber` model parallel replacement options, not just one successor)
  and nests (an entry can carry its own further `supersession: []`). Storage keeps the whole
  structure as-is; nothing is flattened to "one successor APN".
- `supersededApns` (reverse direction — what this APN itself superseded) is real and can be
  populated even when `supersession` (forward direction) is empty/null — captured for free
  since the whole `responseObject` is stored, not just the `supersession` sub-array.

### Scale and the dedicated sweep

At build time: 932,866 `jlr_epc_parts` rows, 410,280 with `osi_ind='Y'`, across **237,700
unique APNs** — at the existing 500ms rate limit, a full sweep is ~33h of continuous
requests. `FetchSupersessionsJob` (chunks of 200, self-redispatching) works through
`JlrPart::where('osi_ind','Y')->whereNull('supersession_fetched_at')` until nothing's left —
resumable by construction (that query *is* the resume checkpoint, no separate run-tracking
row) rather than by a counter that can drift from reality. If an entire chunk fails outright
(e.g. auth genuinely broken, not a transient blip), it logs loudly and backs off 5 minutes
before retrying, instead of silently spinning forever burning API calls.

Triggered via the "Fetch Supersessions" button on `/admin/jlr-epc` (states the APN count/time
estimate in its own confirmation modal) or `php artisan jlr-epc:fetch-supersessions`.

**Dedup by APN, not by row, while still storing a flat column**: the user wanted a plain
`supersession` column on `jlr_epc_parts` (not a separate table), but the same APN routinely
appears on several rows (different diagrams/vehicles). Fetching per-row would mean N API
calls for one physical part. Instead, the sweep fetches each unique APN once and writes the
result to every row sharing it in a single `UPDATE ... WHERE apn = ?` — one API call, N rows
updated. Confirmed against real data: APN `606178` sat on 12 rows locally; one fetch + one
`UPDATE` correctly populated all 12.

### `FetchPartsJob` also fetches/preserves it — not just the dedicated sweep

Two bugs found by the user actually using Refresh on a real node (RTC8922, node 82) right
after this was built, both now fixed in `FetchPartsJob.php`:

1. **Data-loss bug**: `FetchPartsJob` deletes and recreates every `JlrPart` row for a node on
   every refresh (`JlrPart::where('node_id', ...)->delete()` then re-`create()`). The
   original implementation never carried `supersession`/`supersession_fetched_at` across that
   delete+recreate — so hitting Refresh on a node silently wiped out previously-fetched
   supersession data for every part in it, forcing the bulk sweep to re-fetch it from
   scratch. **Fixed**: before deleting, the existing rows' `supersession`/
   `supersession_fetched_at` are read into a `keyBy('apn')` map and carried onto the freshly
   created row for the same APN.
2. **Missing behaviour**: Refresh only re-pulled `catEntries` (so `osi_ind` itself refreshed,
   but nothing about the actual chain) — the user's real expectation, confirmed when asked,
   was that Refresh should also *get* the data, not just avoid destroying it. **Added**: any
   OSI-flagged APN in the node that didn't have data to carry over gets fetched live, inline,
   right after the parts are created — same `fetchSupersession()` call the sweep uses, same
   per-APN try/catch-and-skip-on-failure. A single node's part list is small (the real test
   case had exactly 1), so this is cheap; it's genuinely what "Refresh" should mean for a
   section someone's actively looking at, rather than waiting for the 33h bulk sweep to
   eventually reach it.

Because `FetchPartsJob` is the one job every path funnels into for leaf-level work — single
Refresh (`refreshLeafParts`), whole-section Re-fetch (`refreshNode` → `FetchHierarchyJob` →
`FetchPartsJob` per descendant leaf), and full vehicle Re-extract All
(`ExtractVehicleJob` → `FetchHierarchyJob` → `FetchPartsJob`) — this fix and the inline fetch
apply identically to all three; nothing extra was needed per-path. The two paths (dedicated
sweep vs. inline-on-fetch) never do redundant work: whichever reaches a given APN first sets
`supersession_fetched_at`, and the other's `whereNull(...)` guard then skips it.

### UI

`BrowseCatalogue.php`'s leaf row map includes `'supersession' => $p->supersession` (the full
nested structure, unmodified). `browse-catalogue.blade.php` renders it via a small **recursive
Blade partial**, `jlr-epc::filament.partials.supersession-tree` — `@include`s itself for each
entry's own `supersession` array, so it correctly shows parallel siblings *and* arbitrary
depth (verified with a synthetic 2-branch, 3-level-deep fixture, not just the single-successor
real example) — under the APN in the Part No. column, same spot the REMAN badge already sits.

**Refresh spinner**: the actual work happens on the queue (`refreshLeafParts`/`refreshNode`
just flip `fetch_status` to `pending` and dispatch), so the Livewire click round-trip itself
is instant — without extra UI, the button looked like nothing happened while the real fetch
was still running in the background. Two layers: `wire:loading`/`wire:target` on the button
itself covers the click's own round-trip, and `currentLeafData()` now also returns the
selected node's `fetch_status`; a small "Refreshing…" spinner + `wire:poll.2s` (present only
while `fetch_status` is `pending`/`fetching`) shows for the actual duration of the background
job and then disappears on its own once the poll sees `done`/`failed`.

---

## Admin pages

| Page | URL | Purpose |
|---|---|---|
| JLR EPC Connector | `/admin/jlr-epc` | Stats, auth status, run Sync/Extract |
| Browse Catalogue | `/admin/jlr-epc/browse` | Navigate stored vehicles → sections → parts + diagram |

Sync Vehicles and Extract All buttons are hidden until a valid access or refresh token is stored.

---

## Service classes

| Class | Location | Purpose |
|---|---|---|
| `JlrEpcAuthService` | `src/Services/JlrEpcAuthService.php` | Full auth flow + token refresh |
| `JlrEpcClient` | `src/Services/JlrEpcClient.php` | API calls (vehicles, hierarchy, parts, image download) |

---

## Artisan commands

```bash
php artisan jlr-epc:test-auth       # prompt for credentials, test auth flow in terminal
php artisan jlr-epc:sync-vehicles   # dispatch SyncVehicleListJob
php artisan jlr-epc:extract --vehicle-id=X   # extract single vehicle
php artisan jlr-epc:extract --all            # extract all vehicles
php artisan jlr-epc:extract --all --resume   # skip already-completed nodes
```

---

## Server deployment

The plugin must be registered in the `plugins` table for its nav item and pages to appear (`Package::isPluginInstalled()` gates everything). The correct install command is:

```bash
php artisan jlr-epc:install
```

This registers the plugin in the `plugins` table AND runs its migrations. Run it once after the first deploy.

If migrations are needed separately (e.g. after a schema change):

```bash
php artisan migrate --path="plugins/webkul/jlr-epc/database/migrations" --force
php artisan migrate --path="plugins/webkul/jlr-epc/database/settings" --force
```

If `jlr-epc:install` is unavailable, insert the record manually:

```bash
php artisan tinker --execute="
\Webkul\Support\Models\Plugin::updateOrCreate(
    ['name' => 'jlr-epc'],
    ['is_installed' => true]
);"
```

### SSL — GlobalSign intermediate certificate (diagram downloads)

The JLR CDN (`rdm-cdn.jaguarlandrover.com`) serves SVG diagrams but its server **does not send the intermediate CA certificate** in the TLS handshake. Ubuntu's default CA bundle does not include `GlobalSign RSA OV SSL CA 2018`, so diagram downloads fail with `cURL error 60: SSL certificate problem: unable to get local issuer certificate` even though the system CA bundle is fully up to date.

Fix — download the missing intermediate and add it to the system trust store:

```bash
curl -o /tmp/gsrsaov2018.crt "http://secure.globalsign.com/cacert/gsrsaovsslca2018.crt"
openssl x509 -inform DER -in /tmp/gsrsaov2018.crt -out /usr/local/share/ca-certificates/gsrsaovsslca2018.crt
sudo update-ca-certificates
```

Verify with:

```bash
curl -v -o /dev/null "https://rdm-cdn.jaguarlandrover.com/" 2>&1 | grep -E "SSL|verify"
# Expected: SSL certificate verify ok.
```

**After fixing SSL**, any leaf nodes already extracted as `done` but with no diagram need to be reset so the resume extraction can fill them in:

```bash
/usr/bin/php8.3 artisan tinker --execute="
\$count = Webkul\JlrEpc\Models\JlrNode::where('is_leaf', true)->whereNull('diagram_r2_key')->update(['fetch_status' => 'pending']);
echo \$count . ' nodes reset to pending' . PHP_EOL;
"
```

Then run **Extract All → Resume** from the JLR admin page to download the missing diagrams without re-fetching nodes that already have parts data.

---

## SVG diagram cleaning (`cleanSvg()`)

SVGs downloaded from the JLR EPC API contain several pieces of internal metadata and copyright text that must be stripped before storage. This is done in `JlrEpcClient::cleanSvg()` at download time — not at serve time.

### Patterns removed

| Pattern | Example | Regex |
|---|---|---|
| Copyright line | `© Copyright, 2016. Jaguar Land Rover Limited.` | `Jaguar\s+Land\s+Rover` in `<text>` |
| Template/diagram ref codes | `AMNXOB7C`, `ADTXGA1A` | `[A-Z0-9]{6,10}` standalone in `<text>` |
| Section codes | `A01.030`, `A01.045` | `[A-Z]\d{2}\.\d{3}` |
| Date codes | `08-2005`, `02-2008` | `\d{2}-\d{4}` |
| Market/variant codes | `LC`, `L1`, `L2` | `[A-Z]{1,2}\d?` standalone |
| Illustrator credit | `TK Illustrations` | literal string |
| Category breadcrumb | `Misc. & Accessories Accessories [Section]` | `Misc\.\s*(?:&amp;|&)\s*Accessories` |
| Section header/footer | `ACCESSORIES - [Section]` | `ACCESSORIES\s*-[^<]*` |
| Diagram title group | `BRAKE MASTER CYLINDER AND SERVO` (and empty background paths) | entire `<g id="description">` group (case-insensitive) |

**Safe to remove**: black-fill (`fill="#000000"`) text elements near the bottom of the diagram (y ~ 237 in a 297-height viewBox). These are footer labels added by the JLR template.

**Keep**: Blue-fill (`fill="#0026FF"`) text elements — these are callout bubble numbers that link to the parts table. Content labels like `R.H FRONT` are also black-fill but appear at various y positions throughout the diagram body — the pattern matching targets specific known strings rather than a blanket fill-colour or y-coordinate rule to avoid accidentally removing diagram content.

### Dimension normalisation

JLR SVGs have `width="100%" height="100%"` on the `<svg>` element. This works when inline but renders at 0×0 in an `<img>` tag (no intrinsic size). `cleanSvg()` replaces percentage dimensions with explicit pixel values parsed from the `viewBox` attribute:

```php
// viewBox="0 0 420 297" → width="420" height="297"
```

This is needed for the section grid tile thumbnails, which use `<img src="...svg">`. The Alpine `loadSvg()` function in the leaf view strips these attributes anyway and applies `width:100%;height:auto` via CSS — so the explicit pixel values only matter in the `<img>` context.

### Patching existing stored SVGs

When new cleaning patterns are added, already-stored SVGs on R2 need to be patched in-place via tinker. The general approach:

```php
// Artisan tinker
$keys = DB::table('jlr_epc_nodes')
    ->whereNotNull('diagram_r2_key')
    ->pluck('diagram_r2_key');

foreach ($keys as $key) {
    $raw = Storage::disk('r2')->get($key);
    // apply regex fixes to $raw
    Storage::disk('r2')->put($key, $fixed, ['visibility' => 'public', 'ContentType' => 'image/svg+xml']);
}
```

After the footer text patterns were added, 9 of 74 stored SVGs were patched in-place. Future extractions will have all patterns applied automatically at download time.

---

## Leaf view diagram: CORS fix (server-side SVG embedding)

### Problem

The leaf detail panel originally used Alpine.js `fetch()` to load the SVG from R2 into the DOM. This worked in production but failed on localhost because R2 (`pub-621c753875bc457a86d8f8274d901eca.r2.dev`) does not send CORS headers for `localhost` origins.

**Diagnosis**: `data-diagram-url` was correctly set, the container was in the DOM, but the SVG never appeared. The URL worked fine in a browser tab (same-origin). DevTools showed the `fetch()` was being blocked by CORS.

### Fix

The SVG is now read server-side in `BrowseCatalogue::currentLeafData()` and embedded directly into the Livewire response:

```php
$raw = Storage::disk($disk)->get($node->diagram_r2_key);
$raw = preg_replace('/^<\?xml[^?]*\?>\s*/i', '', $raw);   // strip XML declaration
$raw = preg_replace('/<!DOCTYPE[^>]*>\s*/i', '', $raw);    // strip DOCTYPE
$svgContent = $raw;
```

The view embeds it with `{!! $leafData['svg_content'] !!}` inside a `wire:ignore` block (to prevent Livewire's morphdom wiping the injected SVG content on re-render).

The Alpine `init()` function checks for an already-present `<svg>` element in `$refs.svgContainer` and skips the `fetch()` fallback if found. `wire:key` on the diagram container forces Alpine to destroy and re-create its component when navigating between leaf nodes.

This approach also means the SVG is re-read on every Livewire update, so if extraction completes while the page is open, a manual tab switch (triggering a Livewire update) will pick up the newly stored SVG without a hard refresh.

---

---

## SKU Diagram Assignment

### What it does

Generates a JPEG image for a product's matched EPC diagram node with the SKU pill
overlaid at the callout position. The image is pushed into the product's `images` JSON
column so it appears alongside regular product photos in inventory.

Admin page: `/admin/jlr-epc-sku-diagrams` — lists all products, shows matched diagram
nodes, lets you generate individually or "Generate All".

### Two SVG types

| Type | Detection | Output path |
|---|---|---|
| Raster-backed | Contains `data:image/png` or `data:image/jpeg` base64 URI in `<image>` element | `SkuDiagramService::generate()` → extracts raster, overlays badges with GD, saves JPEG |
| Pure-vector | No embedded raster | `SkuDiagramService::generateFromVectorSvg()` → modifies SVG elements, rasterises with Imagick, saves JPEG |

### Raster-backed pipeline (GD)

1. Extract embedded PNG/JPEG from the SVG's `<image>` element (base64 decoded)
2. Parse `viewBox` + `<image>` transform to map SVG coords → raster pixels
3. Crop and scale to the visible region (`SCALE=5`, minimum 1200px short side)
4. Draw context badges (grey semi-transparent circles + white TTF numbers) via `drawContextBadgePx()`
5. Draw gold pill with SKU text via `drawSkuPillPx()` — no backing disc (see note below)
6. Save as PNG (lossless — avoids JPEG DCT ringing on pill edges)

**No gold disc on raster path**: An earlier version drew a `imagefilledellipse()` at `bpr + 8` radius (≈37px) over the callout to erase the original line-art number. This was removed — the embedded JPEG raster doesn't have SVG badge elements baked in, so the disc was unnecessary. Worse: at radius 37px it extended 16px above/below the pill (which is only ±21px tall), creating visible gold arcs ("dots") above and below the pill. The pill alone covers the callout position without needing a disc.

**White clearing rectangle in `drawSkuPillPx()`**: Before drawing the gold pill, a white rect pre-clears the pill zone from `y1 - $r*2` (40px above pill top) to `y2 + $r` (20px below). The 40px clearance is needed because dark diagram line-art can appear at dy=−45 relative to the pill top — a smaller clear zone left dark artefacts above the pill.

Font path used by GD (`findFont()`): tries in order:
- `C:/Windows/Fonts/arialbd.ttf` (local XAMPP)
- `/usr/share/fonts/truetype/msttcorefonts/Arial_Bold.ttf`
- `/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf`
- `/usr/share/fonts/liberation/LiberationSans-Bold.ttf`
- `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`

Falls back to GD's built-in bitmap font if no TTF found (looks worse).

### Pure-vector pipeline (Imagick)

1. Parse all gold callout badges from SVG via `parseCalloutBadges()`
2. Regex-remove all original gold `<circle>` + `<text>` badge pairs
3. Crop the `viewBox`: left/right/top sides to badge bounding box + 10× radius padding; **bottom always uses the full viewBox bottom** (`$vbMinY + $vbH`) — see note below
4. Compute `$pxScale = 1800 / max(cropW, cropH)` for pixel-anchored minimum font sizes
5. Re-inject all badges: context = grey circle + white number; target = gold pill only (no backing circle — having both makes the target look like a fat oval instead of a pill)
6. Pill position is clamped to the viewBox so it can't overflow and be clipped when the callout is near the crop edge
7. Inject white `<rect>` background as first child of `<svg>` (prevents dark lightbox overlay showing through) — must have explicit `stroke="none"` to prevent inheriting a black border (see note below)
8. Set explicit pixel `width`/`height` on `<svg>` element
9. Rasterise with Imagick → save as PNG (lossless)

SVG font-family used: `Liberation Sans,DejaVu Sans,Arial,Helvetica,sans-serif` and `dy="0.35em"` (more reliable than `dominant-baseline="central"` in librsvg when rasterising to PNG).

Falls back to saving as SVG if Imagick extension is not loaded.

**Why cropY2 = full viewBox bottom**: Some diagrams (oil filter canisters, gear assemblies) extend far below their badge positions — more than any fixed multiplier can predict. Using `max($allCy) + pad` for the bottom crop cuts these off. Top/sides still crop whitespace; the minor extra margin at the bottom is acceptable vs. the risk of clipping content. Node 2107 (ERR3340 Oil Filter) was the discovered case: badges sit at y≈100 while the canister diagram extends to y≈247 on a 297-unit A4 canvas.

**bg rect must have `stroke="none"`**: JLR EPC SVGs with complex diagrams carry `stroke="#000000"` on the root `<svg>` element. SVG presentation attributes cascade — without `stroke="none"` on the injected bg rect, it inherits a 1-unit black stroke (~9px wide at 1800px render scale), creating 5px black borders on all four edges of the generated image.

### Server dependencies (one-time)

```bash
# PHP Imagick extension — needed for pure-vector SVG → JPEG
sudo apt install -y php8.3-imagick
sudo systemctl restart php8.3-fpm
sudo supervisorctl restart cydekick-worker:*

# rsvg-convert — primary SVG rasteriser for pure-vector diagrams; produces much
# sharper pill text than Imagick's built-in SVG delegate.
# fonts-dejavu-core / fonts-liberation — font packages needed for legible pill/badge text.
# The code detects rsvg-convert via `which rsvg-convert` and falls back to Imagick if absent.
sudo apt install -y librsvg2-bin fonts-dejavu-core fonts-liberation && fc-cache -fv
sudo supervisorctl restart cydekick-worker:*
```

### Local (XAMPP Windows) dependencies

Imagick installed from ZIP: `php_imagick-3.8.1-8.3-ts-vs16-x64.zip`
- `php_imagick.dll` → `C:\xampp\php\ext\`
- All `CORE_RL_*.dll` and `IM_MOD_RL_*.dll` → `C:\xampp\php\`
- `extension=imagick` added to `C:\xampp\php\php.ini`

GD font (`findFont()`) uses `C:/Windows/Fonts/arialbd.ttf` which is always present on Windows.

### Stale assignment self-heal

On every render of `matchedDiagrams`, the page checks each `done` assignment: if its
`r2_key` is no longer in the product's `images` JSON (e.g. user deleted the image from
inventory), the assignment is reset to `pending` so it can be regenerated. This prevents
"stuck done" rows after manual image deletion. Images are read directly from DB (not from
the Livewire `selectedProduct` computed cache) to avoid stale cached values mid-render.

---

**PHP version:** The server must run PHP 8.3+ — the `composer.lock` pins packages that require `php-64bit ^8.3`. Switch Apache and CLI with:

```bash
sudo a2dismod php8.2 && sudo a2enmod php8.3 && sudo systemctl restart apache2
sudo update-alternatives --set php /usr/bin/php8.3
```
