# Bullet Points Generator

Builds Amazon-style "bullet points" (Key Product Features) per product from
existing Allmakes PSP scrape data, and writes them into the
`products_products.bullet_points` custom field — the same field the Amazon
Configurator maps to `bullet_point` (see
`z_notes/Claude_help/amazon-configurator.md`) and that Country of Origin's
sibling field lives next to on the product Edit/View pages.

Command: `plugins/webkul/allmakes-psp/src/Console/GenerateBulletPointsCommand.php`
Registered in: `AllmakesPspServiceProvider::boot()` (console-only, like the
other `allmakes:*` commands in this plugin).

---

## Why this exists

Amazon's full-listing mode requires `bullet_point` as one of its required
catalog attributes, and it was one of the fields blocking a clean create for
both ERR3340 and TF802 during the Amazon Configurator work. Rather than
asking someone to hand-type features for ~20,000 products, we already have
enough real data sitting in `allmakes_psp_results` and
`allmakes_psp_fitments` (scraped from the Allmakes supplier portal) to build
genuinely accurate bullets automatically — no invented content, everything
traces back to real PSP fields.

## What it actually says, and where each bullet comes from

Up to three bullets per product, pipe-separated (`|`) — matching the
Configurator's separator convention for mapped repeatable attributes (a real
line break doesn't reliably survive CSV import or a single-line DB column,
so `|` is used everywhere this field is read):

1. **Quality grade claim** — from `allmakes_psp_results.psp_quality_grade`.
   There's a real legend for these codes (found in the PSP admin page,
   `allmakes-product-data.blade.php`, that nobody had turned into customer
   copy before):

   | Code | Meaning |
   |---|---|
   | `G` | Genuine {marque} part |
   | `O` | OEM quality — manufactured for {marque} by the original supplier |
   | `R` | Quality aftermarket replacement part |
   | `RTF` / `TF` | Terrafirma performance/off-road range |
   | `OPR` / `PR` | Premium (own-brand) replacement part |

   Combo codes (`OPR2`, `RPR2`, `ORPR2`, etc.) are matched by longest known
   prefix after stripping trailing digits. Unrecognised codes (`Z`, `S` —
   not in the legend) produce no grade bullet, not a guess.

   `{marque}` is the vehicle brand (Land Rover / Jaguar), resolved from
   fitment data first (see below); if a SKU has no fitment rows, it falls
   back to the PSP `vendor` field when *that itself* is a vehicle-marque
   name (`LAND ROVER`, `JLR`, `JAGUAR` — JLR = Jaguar Land Rover, the parts
   group, not a separate marque). If neither source resolves a marque, the
   bullet uses generic phrasing ("Genuine manufacturer part") rather than
   guessing a specific brand.

2. **Vehicle fitment** — from `allmakes_psp_fitments`, joined by
   `supplier_sku` (**not** `product_id` — this table doesn't have that
   column). Deduplicated by `vehiclename`, capped at 6 models with a
   `+N more` suffix for parts that fit a large range (some fit 50+ raw
   fitment rows once every engine variant is counted). Example:
   `Fits: DEFENDER 1986 - 2006, DISCOVERY 1 1989 - 1998, ... +2 more`.

3. **Brand** — from `psp_results.vendor` (falling back to `psp_brand`),
   with two cleanup steps:
   - Strips PSP price artefacts baked into the column, e.g.
     `"LAND ROVER (£31.13)"` → `"LAND ROVER"`.
   - Rejects known non-brand placeholder values outright (`SUPERSEDED` is
     the big one — 417 rows literally have this as the vendor) rather than
     emitting `"Genuine SUPERSEDED part"`.
   - Skipped entirely if the grade bullet already names the same brand
     (literal match) or the same vehicle marque the brand belongs to (e.g.
     vendor `JLR` is treated as covered by *either* a Jaguar or a Land
     Rover grade bullet, not just Land Rover, which is JLR's default
     marque-fallback role in point 1).

**Deliberately left out**: `allmakes_psp_results.description` — sampled
during development and it's rough, internal warehouse shorthand (e.g.
`"d1/d2/def 83-06/rrc/p38/s3"`), usually redundant with the fitment bullet
in coded form, and one sample surfaced UN hazmat shipping text
inappropriately as a "feature." Not worth the noise; can be revisited later
with real cleanup if wanted.

## Coverage (as generated 2026-09-17, local dev DB)

- 20,491 of 20,508 PSP-linked products (99.9%) got at least one bullet.
- 17 skipped entirely — no grade, no brand, no fitment data at all for
  those SKUs (nothing to say).

## Running it

```bash
# Preview only — nothing written, just prints what each product would get
php artisan allmakes:generate-bullet-points --dry-run

# Test against specific SKUs (repeatable flag)
php artisan allmakes:generate-bullet-points --sku=ERR3340 --sku=TF802 --dry-run

# Try a small batch for real
php artisan allmakes:generate-bullet-points --limit=50

# The real, full run
php artisan allmakes:generate-bullet-points
```

Flags:
- `--sku=` — repeatable, restricts to specific SKUs (testing).
- `--limit=` — caps how many products are processed (testing on a subset).
- `--dry-run` — prints the bullets it would write; touches nothing.
- `--force` — overwrites products that **already have** `bullet_points`
  set. Without it, any product with a non-empty `bullet_points` is
  skipped — so a manually-edited bullet list, or one from a previous run,
  is never silently clobbered. This also makes the command **safe to
  re-run**: a second run with no `--force` only picks up products that
  are new or still empty (confirmed — a full re-run after the initial
  write reported "0 written, 17 skipped," the same 17 with no usable data
  at all).

No queue involved — writes happen synchronously via `DB::table()->update()`
per product (not Eloquent), the same reasoning as `amazon_asin` elsewhere:
avoids triggering `Webkul\Channel\Observers\ProductObserver` or any other
`Product` model observer, since `bullet_points` changing has nothing to do
with channel sync or pricing and shouldn't queue anything.

## Running it on the server

Nothing special — it's a normal Artisan command registered through the
plugin's service provider, so it's available immediately after a standard
deploy (`z_notes/deployment_routine.md`), no extra migration or step
needed. From the app directory on the server:

```bash
php artisan allmakes:generate-bullet-points --dry-run   # sanity check first
php artisan allmakes:generate-bullet-points              # then the real run
```

Safe to run as often as needed (idempotent per the `--force` behaviour
above) — e.g. after a fresh PSP scrape brings in new products or updates
fitment data for existing ones, re-running will only fill in products that
still have an empty `bullet_points`.

## Extending it later

- **New quality grade codes**: add to `GRADE_LEGEND` in the command
  (`named`/`generic` pair — `{marque}` placeholder optional).
- **New marque**: add to `MARQUE_MAP` (raw PSP value, upper-cased key →
  clean display name).
- **New vendor placeholder junk**: add to `VENDOR_BLOCKLIST`.
- **Change the fitment cap**: `MAX_FITMENT_MODELS` constant (currently 6).

## Known limitations

- Marque resolution assumes a SKU's fitments span exactly one normalised
  marque; if a SKU's fitment rows span more than one (rare — shared
  Jaguar/Land Rover parts), `resolveMarque()` returns `null` rather than
  guessing, and the grade bullet falls back to generic phrasing.
- Vendor values like `"PR2 ALLMAKES OE"` (grade code glued onto the brand
  name in the source data) produce an accurate but slightly clunky bullet
  (`"Genuine PR2 ALLMAKES OE part"`) — this is a PSP data-hygiene quirk in
  the `vendor` column itself, not something the generator can cleanly
  separate without a more specific parsing rule.
- The `description`-based bullet was deliberately left out (see above) —
  if it's wanted later, it needs real cleanup (case, hazmat text, stripping
  fitment codes already covered by the fitment bullet) before it's usable.
