# Canonical shop categories (Ops ↔ App sync)

Ops, the customer app, and the catalogue API all use the same **7 canonical categories + Other**.

| Slug | Label |
|------|-------|
| `vas_airtime` | VAS / Airtime |
| `home_essentials` | Home Essentials |
| `fresh_produce` | Fresh Fruits & Veg |
| `bakery` | Bakery |
| `dairy_eggs` | Dairy & Eggs |
| `meat_poultry` | Meat & Poultry |
| `other` | Other |

## Ops behaviour

- `App\Libraries\CatalogCategories` is the single normalizer (`normalizeSlug`, `resolveCategoryId`).
- Migration `2026-07-13-140000_SyncCanonicalCategories` upserts canonical rows and sets **legacy categories inactive** (they remain in the DB for history).
- Products linked to legacy slugs (`dairy-eggs`, `pantry`, etc.) are relinked to the matching canonical category where a mapping exists.
- **Ingestion** (`Ingestion::syncCatalogFromScrape`, `EquivalenceMatcher`) assigns `products.category_id` from scraped `category_raw` / bucket text.
- **Admin product forms** only list **active** categories (the canonical set).
- **Taxonomy** page shows canonical rows as read-only “App” categories; legacy rows show as Inactive.

## Legacy → canonical mapping

| Legacy Ops slug / pipeline bucket | Canonical |
|-----------------------------------|-----------|
| `dairy-eggs`, `dairy_bakery_chilled` | `dairy_eggs` |
| `bakery` | `bakery` |
| `fresh`, `fresh_produce` | `fresh_produce` |
| `meat-poultry` | `meat_poultry` |
| `household-essential`, `household_cleaning` | `home_essentials` |
| `pantry`, `staples`, `premium`, `basket_builders` | `other` |

Keyword rules in `CatalogCategories::normalizeSlug()` also map scraped department names (milk, bread, chicken, etc.).
