# Context: JLR EPC Catalogue API — Reverse-Engineered Structure

I've reverse-engineered the structure of JLR's Electronic Parts Catalogue (jlrepc.com) via browser DevTools, using my paid independent-trade account. I want to build a plugin/integration in Cydekick (our Laravel + Livewire ERP) that can pull catalogue hierarchy and parts data from this API, following the same pattern as our existing Channels plugin.

## What the site is

`jlrepc.com` is JLR's official Electronic Parts Catalogue — a login-gated single-page app for looking up Jaguar/Land Rover parts by vehicle. There's no public sitemap; the whole tree is loaded dynamically via a JSON API as you navigate the UI.

## API structure discovered

Base host for the app itself: `https://www.jlrepc.com`
Requests are proxied through `mobify/proxy/apigee/...` on the frontend, which forwards server-side to `https://api.jlr-ddc.com/iepc/catalogue/api/v1/...`.

### 1. Vehicle selection

`GET /jlr-epc/en-GB/config-selector/cat/{catId}`

Returns vehicle configuration options (wheelbase, engine, transmission, paint, etc.) as `primaryFeatures`/`minorFeatures` arrays, each with a `ftrFamCode`/`wersFtrCode` and a `sysDesc` label. This is the vehicle's build-spec selector, not catalogue content — `catId` here is the vehicle identifier (e.g. `13` = Discovery 1 Classic L318).

### 2. Catalogue hierarchy (recursive, single endpoint for all levels)

```
POST https://www.jlrepc.com/mobify/proxy/apigee/iepc/catalogue/api/v1/nextLevelHierarchies
```

This ONE endpoint returns the next level of the tree for any given `catHieId` — including the very first call, where you pass the vehicle's `catId` itself as `catHieId` to get the top-level major sections.

**Request payload:**
```json
{
  "vin": null,
  "catHieId": 56128,
  "featureCodes": ["_WB--E"],
  "filter": true,
  "langCode": "EN",
  "marketCode": "GB",
  "parentApplicable": true,
  "userId": "user@example.com",
  "userRole": "true-independent",
  "userType": "independent"
}
```

- `catHieId` — the parent node to expand. Pass the vehicle's `catId` for the top level.
- `featureCodes` — filters results to match a specific vehicle build (wheelbase/engine/etc, using codes from the config-selector response). Can be passed empty with `filter: false` to get the full unfiltered tree.
- `userId`/`userRole`/`userType` — tied to the authenticated trade account (independent trade user in our case).

**Response shape:**
```json
{
  "success": true,
  "responseObject": {
    "brand": "LR",
    "langCode": "EN",
    "catalogueHierarchies": [
      {
        "catHieId": 56128,
        "catId": 13,
        "illusId": 45482,
        "catHieType": 3,
        "parentCatHieId": 55764,
        "templateCode": "03",
        "catHieDesc": "03 Front Suspension",
        "vaImgUrl": "https://rdm-cdn.jaguarlandrover.com/...(signed, expiring)",
        "pngImgUrl": "https://rdm-cdn.jaguarlandrover.com/...(signed, expiring)",
        "vehType": "T",
        "vehLine": "LQ",
        "status": "PB",
        "brand": "LR",
        "applicableInd": true,
        "catalogueEntries": null
      }
    ],
    "breadcrumb": null
  }
}
```

**Confirmed hierarchy depth (4 levels + leaf parts), observed on Discovery 1 Classic L318:**

| Level | `catHieType` | Example |
|---|---|---|
| 0 (root) | — | Vehicle itself, `catId` e.g. `13` |
| 1 | `2` | "Axles & Suspension" (`catHieId 55764`, `parentCatHieId 13`) |
| 2 | `3` | "03 Front Suspension" (`catHieId 56128`, `parentCatHieId 55764`) |
| 3 | `4` | "015 Shock Absorbers & Springs" (`catHieId 58998`, `parentCatHieId 56128`) |
| leaf | — | Parts list (see below) |

A node with `catalogueEntries: null` and no children returned from `nextLevelHierarchies` is a leaf — that's when you switch to fetching its parts list directly (see below).

### 3. Leaf-level parts list (CONFIRMED)

When you're at a leaf node (e.g. "005 Anti Roll Bar", `catHieId 58996`), the parts table data comes from:

```
POST https://www.jlrepc.com/mobify/proxy/apigee/iepc/catalogue/api/v1/catEntries
```

(proxied to `https://api.jlr-ddc.com/iepc/catalogue/api/v1/catEntries` server-side — same backend host pattern as `nextLevelHierarchies`).

**Request payload — identical shape to `nextLevelHierarchies`, just a different endpoint:**

```json
{
  "catHieId": 58996,
  "vin": null,
  "featureCodes": ["_WB--E"],
  "filter": true,
  "langCode": "EN",
  "marketCode": "GB",
  "parentApplicable": true,
  "userId": "user@example.com",
  "userRole": "true-independent",
  "userType": "independent"
}
```

This is a useful simplification: **both endpoints share one request contract** — pass a `catHieId`, get back either `catalogueHierarchies` (more nodes to descend into) or `catalogueEntries` (leaf parts list) depending on which endpoint you hit. A client service class can share the same request-builder for both.

**Confirmed response shape for a leaf node's parts (`catHieId: 58996` example, "005 Anti Roll Bar" for Discovery 1 Classic L318):**

```json
{
  "success": true,
  "responseObject": {
    "catHieId": 58996,
    "catId": 13,
    "parentCatHieId": 56128,
    "catHieDesc": "Anti Roll Bar",
    "templateCode": "005",
    "vaImgUrl": "...(exploded diagram, signed URL, expires)",
    "pngImgUrl": "...(exploded diagram, signed URL, expires)",
    "catHieDetail": { "cpFrom": [], "cpTo": [] },
    "calloutColourCodes": { "1": "#00f", "2": "#008000", "...": "..." },
    "catalogueEntries": [
      {
        "catEntryId": [1040441],
        "catHieId": 58996,
        "apn": "NTC6836",
        "callOutCode": "1",
        "quantity": "1",
        "cpFromVIN": [],
        "cpToVIN": ["(V)LA081991"],
        "catEntryFeatures": [],
        "features": [],
        "unitPrice": 661.65,
        "currency": "GBP",
        "osiInd": "Y",
        "remanInd": false,
        "catEntryDesc": ["Bar-anti-roll front suspension"],
        "catEntryCmts": [],
        "publishStatus": "PB",
        "tradePart": false
      },
      {
        "catEntryId": [1040442],
        "apn": "NTC6837",
        "callOutCode": "1",
        "quantity": "1",
        "cpFromVIN": ["(V)MA081992"],
        "cpToVIN": [],
        "unitPrice": 187.45,
        "currency": "GBP",
        "catEntryDesc": ["Bar-anti-roll front suspension"]
      }
    ]
  }
}
```

Note the two entries above both use `callOutCode: "1"` (same diagram position) but have non-overlapping `cpFromVIN`/`cpToVIN` ranges — this is the VIN-change-point superseded-part pattern: same physical position on the diagram, different part number depending on which VIN range the vehicle falls in. Any matching logic against our own stock needs to account for this rather than assuming one APN per callout.

Key fields per part entry:
- `apn` — the JLR part number (this is what we'd match against our own catalogue/PIM)
- `catEntryDesc` — human-readable part description
- `callOutCode` — position number on the exploded diagram
- `quantity` — quantity required per assembly
- `unitPrice` / `currency` — trade price
- `cpFromVIN` / `cpToVIN` — VIN change-point range this part number applies to (i.e. superseded parts across production runs)
- `catEntryFeatures` / `features` — which build-spec variant(s) this specific line applies to (e.g. wheelbase-specific parts)
- `osiInd` — appears to flag some kind of stock/order-status indicator, meaning not yet confirmed
- `remanInd` — remanufactured part indicator

## What I want to build in Cydekick

The goal is a **full extraction of the JLR EPC catalogue into our own database**, for internal reference only (not a live customer-facing lookup tool). Specifically:

- Recursively walk the entire hierarchy for our vehicle range (major section → minor section → page section) and store every node (`catHieId`, `parentCatHieId`, `catHieDesc`, `templateCode`, `catHieType`, breadcrumb path).
- For every leaf node, pull the full parts list (`catEntries`) and store every part entry — APN, description, quantity, callout code, unit price, currency, VIN change-point ranges (`cpFromVIN`/`cpToVIN`), applicable features/variants, comments.
- Download and store the **exploded parts diagrams** (the `vaImgUrl`/`pngImgUrl` images) in our **Cloudflare R2** bucket, under a dedicated folder separate from our other stored assets, since those signed source URLs expire — so we need to fetch and persist the actual image files there, not just the URLs.
- This should end up queryable as our own internal reference database — e.g. "show me every part and diagram under Front Suspension for a Defender L316" — without needing to go back to jlrepc.com at all once extracted.
- Scope: start with the vehicles we actually stock/sell for (Defender, Discovery, etc. — our core range), not necessarily every JLR vehicle ever made, though the extractor should be generic enough to point at any `catId`.
- This is a one-off/periodic bulk extraction job, not a real-time sync — JLR's own catalogue doesn't change often, so a queued job we re-run occasionally to refresh is fine.

## Constraints & things to figure out together

1. **Auth is session-based** — a Bearer token + cookie pulled from an authenticated browser session, tied to a real independent-trade login (mine). It expires periodically (signed image URLs suggest short windows, ~15-60 min). We need to decide: is this something a human refreshes manually and pastes into config each time, or is there a login flow we could automate against `jlrepc.com` directly? (Not yet investigated — may be worth checking if there's a standard OAuth/login POST we could hit headlessly.)
2. **Signed image URLs expire** (`Expires=`/`Signature=` query params, seen with ~15-60 min windows) — since storing the diagrams locally is part of the goal, the extractor needs to download each `vaImgUrl`/`pngImgUrl` (or the `.svgz` variant — note some are gzipped SVG, `le0003.svgz`, so may need decompressing) immediately when a node is visited, not defer it, or the URL will be dead by the time a later job tries to fetch it. **Store images in our Cloudflare R2 bucket, under a dedicated folder/prefix (e.g. `jlr-epc/{catId}/{catHieId}.png`) separate from our other stored assets**, and store the R2 object key/path against the `catHieId` in the DB rather than the signed source URL.
3. **VIN change-point handling** — parts entries carry `cpFromVIN`/`cpToVIN` ranges, and the same `callOutCode` can map to multiple superseded part numbers. Any storage schema needs to preserve this rather than assuming one APN per diagram position.
4. **Scale** — walking the full tree for one vehicle is a few hundred requests (major section → minor section → page section → parts, recursively). Walking it for our full vehicle range would be significant — worth rate-limiting and probably running as a background queued job rather than synchronous, consistent with how we've built other Cydekick integrations (e.g. Pricing plugin's queued recalculation jobs).
5. This should follow our existing Channels plugin as the structural reference for a new plugin (service class for API calls, Livewire components for any UI, standard plugin folder conventions).

## Ask

Both API endpoints (`nextLevelHierarchies` and `catEntries`) are now fully confirmed — same request contract, different response payload. Please review this and propose a plugin structure (following the Channels plugin pattern) for a **full bulk-extraction job** — covering: where the API client/service class should live, a database schema for the hierarchy + parts + image references (new tables, likely `jlr_epc_categories`, `jlr_epc_parts`, plus an R2 path/key column for diagrams), how the R2 connector integrates (a dedicated `jlr-epc/` folder/prefix, distinct from other stored assets), how to handle the token refresh problem for a long-running extraction, and how to structure this as a queued/resumable background job (so it can pick up where it left off if a token expires mid-run, without re-downloading images or re-fetching nodes already saved). Ask me anything you need clarified before writing code.
