# Title Building — Product Name Conventions & SKU Prefix Command

## Background

Product titles in Cydekick follow a two-stage build process:

**Stage 1 — SKU prefix** (this command): prepend the SKU to the bare part name.
**Stage 2 — Rich title builder**: adds `LAND ROVER -`, `Suitable for {vehicles}`, brand attribution, etc. (done separately, see `description-building.md` for the description side).

> **Stage 3 — Title cleaner** (`s4x4:clean-titles`) was run in August 2026 to strip the `Suitable for {vehicles}` fitment tail (added by Stage 2) and standardise vendor attribution. See the section at the bottom of this file.

A fully built title looks like:
```
STC3135 LAND ROVER - MATRIX - HEATER - AIR CONDITIONING - Suitable for Discovery 1, Range Rover Classic
```

A stage-1-only (SKU-prefixed but not yet enriched) title looks like:
```
572312LR BUSH - MOUNTING - RADIATOR
```

An unbuilt title (bare, no SKU prefix) looks like:
```
BUSH - MOUNTING - RADIATOR
```

---

## DB State (as of 2026-06-30)

| State | Count | Detection |
|-------|-------|-----------|
| Fully built (SKU prefix present) | **9,086** | `name LIKE CONCAT(sku, ' %')` |
| Unbuilt (no SKU prefix) | **11,422** | `name NOT LIKE CONCAT(sku, ' %')` |
| Total active products | **20,508** | `company_id = 2, deleted_at IS NULL` |

All unbuilt titles are on `company_id = 2` (Somerset4x4 / Banwells).

One known edge case: `LR035543` has name `FLR035543 LAND ROVER - ...` — the SKU appears inside a longer part number. The command correctly skips it because the name starts with `F` not `L`.

---

## The Command

**File:** `app/Console/Commands/PrefixSkuToTitlesCommand.php`  
**Artisan signature:** `inventory:prefix-sku-titles`

### What it does

For every product where `name NOT LIKE CONCAT(sku, ' %')` (i.e., the name does not already start with the SKU), it:

1. Updates the product name: `name = "{sku} {name}"`
2. Flags any `channel_listings` row for that product from `listed` → `needs_update`, so the next Shopify sync pushes the new title up

Example:
- Before: `BUSH - MOUNTING - RADIATOR`
- After:  `572312LR BUSH - MOUNTING - RADIATOR`
- Channel listing: `status = needs_update` (queued for Shopify sync)

Products that already start with their SKU are untouched.

The channel flagging mirrors what `ProductObserver::updated()` does when a name is changed through the admin UI. The command uses raw `DB::table()` so the observer doesn't fire — the flagging is done explicitly instead.

### Options

| Option | Default | Purpose |
|--------|---------|---------|
| `--company=N` | `2` | Limit to this `company_id` |
| `--dry-run` | off | Preview changes, show a table of before/after, no DB writes |
| `--limit=N` | all | Process only N products (for testing) |

---

## How to Run

### Step 1 — Always dry-run first

```bash
php artisan inventory:prefix-sku-titles --dry-run --limit=50
```

Check the Before/After table. Confirm the SKU is being prepended correctly and no already-built titles are in the list.

### Step 2 — Dry-run at full scale

```bash
php artisan inventory:prefix-sku-titles --dry-run
```

Should report ~11,422 products. No writes happen.

### Step 3 — Run for real

```bash
php artisan inventory:prefix-sku-titles
```

Progress bar shows progress. Final line:
```
Done. Updated 11,422 product titles, flagged 8,341 channel listings as needs_update.
```
(The listing count will be lower than the title count — only products already listed on Shopify get flagged. Products not yet listed will get the new title when they're first synced.)

### On the server

```bash
# SSH in, then:
cd /var/www/html/Cydekick
php artisan inventory:prefix-sku-titles --dry-run --limit=20   # sanity check
php artisan inventory:prefix-sku-titles                         # full run
```

The command is idempotent — running it twice won't double-prefix because the second pass sees the SKU already at the front and skips those products.

---

## Stage 2 — Inject Make Label

**File:** `app/Console/Commands/InjectMakeToTitlesCommand.php`  
**Artisan signature:** `inventory:inject-make-to-titles`

Run this after Stage 1. It injects `LAND ROVER -` or `JAGUAR -` (or both) into each title using PSP fitment `marque` data, with the product vendor field as a fallback.

```bash
php artisan inventory:inject-make-to-titles --dry-run --limit=20   # sanity check
php artisan inventory:inject-make-to-titles --dry-run               # full preview
php artisan inventory:inject-make-to-titles                         # full run
```

Make rules:
- LR only → `{SKU} LAND ROVER - {description}`
- JAG only → `{SKU} JAGUAR - {description}`
- Both → `{SKU} LAND ROVER - {description} JAGUAR`

Vendor fallback: `LAND ROVER` → LR, `JAGUAR` → JAG, `JLR` → both.

Step 2 also verifies existing `LAND ROVER` titles against PSP data and appends ` JAGUAR` suffix where needed. Step 3 exports a CSV of products with no fitment/vendor make data for manual review.

---

## Safety Notes

- The command only touches `company_id = 2` by default.
- It uses direct `DB::table()` updates, not model events — so `updated_at` is explicitly set but no observers fire.
- The detection condition (`name NOT LIKE CONCAT(sku, ' %')`) is case-sensitive in MySQL default collation — SKUs are stored uppercase, names may be mixed case, but the test is against the stored SKU value so this is consistent.
- Run with `--dry-run` first every time, especially on the live server.

---

## Stage 3 — Somerset4x4 Title Cleaner

**File:** `app/Console/Commands/S4x4CleanTitlesCommand.php`
**Artisan signature:** `s4x4:clean-titles`

Run once in August 2026 across all 20,508 Somerset4x4 products to clean up titles that had been enriched with vehicle fitment by Stage 2.

### What it does (in order)

1. Strips everything from ` - Suitable for` onwards (vehicle fitment tail)
2. Strips any existing ` From [Brand]` suffix
3. Trims trailing dashes and whitespace
4. Applies vendor-specific label from the `vendor` DB field:
   - Vendor = `LAND ROVER` or `JLR` → injects `GENUINE` before `LAND ROVER` in the title
   - Vendor = `JAGUAR` → injects `GENUINE` before `JAGUAR`
   - Vendor = `SUPERSEDED` → no suffix added
   - Any other vendor → appends ` From {vendor}` (e.g. `From ALLMAKES`, `From NEOLUX`)
5. Flags affected `channel_listings` rows as `needs_update` for Shopify re-sync

### Target title format after cleanup

```
ANR2224 GENUINE LAND ROVER - CLIP - PLASTIC - DRIVE RIVET
STC50519 GENUINE LAND ROVER - POWER STEERING OIL - 1 LTR
10211 LAND ROVER - BULB - 12V-5W - SIDE AND TAIL LAMP From NEOLUX
1311289G LAND ROVER - OIL FILTER - PAPER ELEMENT TYPE - TD6 2.7 DIESEL From MAHLE
ERR3340 LAND ROVER - Oil filter From ALLMAKES
```

### Options

| Option | Default | Purpose |
|--------|---------|---------|
| `--company=N` | `2` | Limit to this `company_id` |
| `--dry-run` | off | Preview changes, no DB writes |
| `--limit=N` | all | Process only N products (for testing) |
| `--all` | off | Process all products, not just those with fitment tails |
| `--unlisted-only` | off | Only products not yet listed on any channel |
| `--export=path` | off | Write full before/after results to a CSV file |

### Usage

```bash
# Preview changes on fitment-affected products only
php artisan s4x4:clean-titles --dry-run

# Preview all 20k products with CSV export for review
php artisan s4x4:clean-titles --dry-run --all --export=storage/app/titles-review.csv

# Run on fitment-affected products only
php artisan s4x4:clean-titles

# Run on all products (adds GENUINE and From Vendor to previously clean titles)
php artisan s4x4:clean-titles --all
```

### Shopify handle impact

The Shopify batch sync job (`ShopifyBatchSyncJob`) already handles URL handles in Phase 3.6 — it computes a clean slug from the new title and pushes it via individual `productUpdate` GraphQL calls. Shopify automatically creates 301 redirects from old handles to new ones, preserving SEO.

### Channel title overrides

Channel-specific title overrides are stored in `channel_listings.attributes['title']`. Only **1 listing** had a manual override (TF117) as of August 2026 — the cleaner does not touch those.

### Over-80-char products

Products whose cleaned title still exceeds 80 chars are updated in Cydekick and Shopify but flagged in the command output and CSV as `OVER 80 CHARS`. eBay will reject these at listing time — they need manual shortening in the product edit screen before eBay batch create will succeed.
