# Module 3 — Menu Catalog (Categories, Items, Attributes, Ingredients)

> **KB version:** 2.0 · **Code baseline:** `19ed3d9` · **Last reviewed:** 2026-11-03
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Ingredient categories (`ingredient_categories`, type > category > sub category, leaf-only ingredients) and their dashboard CRUD with its tree/deletion guards
> - Ingredient `sku` unique (nullable) + `ingredient_category_id` must be a leaf category; category locked while the ingredient takes part in a replacement; ingredient delete blocked while used in a recipe or as a replacement
> - Recipe line columns on `item_ingredients`: decimal `size`, `is_removable`, `sort_order`, unique (item, ingredient)
> - Per-recipe-line replacements (`item_ingredient_replacements`) restricted to the original ingredient's leaf category, with own `size` and `extra_charge`
> - Add-ons = items of menu category `ASP_CAT_1000` (or below), linked per meal via `item_addons`; auto-removed when an item leaves that category; item delete blocked while it is an add-on
> - `items.has_replacement` / `items.has_addon` derived flags recomputed on every dashboard save
> - New item response shape (recipe lines with replacements, item-level `addons[]`), `GET /pos/items/view/{id}`, and the two lookups (`replacement-candidates`, `add-ons`)
> - `categories.code` platform lookup key
> - Ingredient `kg` unit
> - `POST /pos/ingredient/cc/ingredient-creation` endpoint, incl. unvalidated unit-code mapping risk
> - Platform item push (`POST /pos/items/mobile/items-creation`): open to any `auth.platform` platform, optional `slug` (written to `mapper_slug`), field validation, partial updates keyed on sku, recipe/add-ons by sku with dashboard composition rules, `created_by` = platform user
> - `items.created_from` = calling platform's slug (push) or `dashboard` (dashboard create), set on create, never changed on update
> - `POST /pos/items/cc/item-creation-simulate` dry run of the sized CostControl payload as the calling platform (validates and saves as the sized push, then rolls back — add-ons included; returns action, validated payload and item preview)
> - `GET /dashboard/items/add-ons` optional `category_id` filter (must be the add-on category or a category below it)
> - Recipe-line replacements are stored but not returned by the item resources (`ItemResource`, `DBItemResource`, and so every item list/view and the simulate preview)
> - `POST /pos/items/cc/item-creation`: sized CostControl item push — only the `ready_meals` size is stored, add-ons created or updated by sku from their full definition, `price` also written as `delivery_app_price`
> - Sized push add-on recipe: `addons[].sizes[].ingredients[] {sku, size > 0, removable?, sort_order?}` — all lines of the add-on's `ready_meals` size stored (not removable unless sent, sort_order defaults to list position, no replacements); empty list clears, no `ready_meals` size keeps
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** TBD (requires As-Built Spec OD register, out of scope this pass)

---

## Module Overview
- **Purpose:** Define what can be sold: the category tree, sellable items, item attributes, recipe ingredients and their classification, replacements and add-ons.
- **Business Goal:** Let head office manage a hierarchical menu including "make your own meal" (custom meal builder) flows and customisable meals (removable ingredients, replacements, add-ons), and give the POS fast structured access to it.
- **Main Actors:** Admin/superadmin (CRUD), cashier/chief (read), platforms (reference items by `mapper_slug`).
- **Dependencies:** Attachments (item images), Ingredients, Ingredient categories, Orders (items are attached to orders with a price snapshot).
- **Entry Points:**
  - Dashboard: `/v1/dashboard/categories/*`, `/v1/dashboard/items/*`, `/v1/dashboard/ingredients/*`, `/v1/dashboard/ingredient-categories/*`
  - POS: `/v1/pos/categories/*`, `/v1/pos/items/search`, `/v1/pos/items/view/{id}`

## Business Workflow
1. Admin creates categories, optionally linking each category to one or more **parent categories** (a many-to-many hierarchy via `parent_categories`, each link carrying a `parent_level`).
2. Admin maintains the ingredient category tree (type > category > sub category) and files each ingredient under a leaf category.
3. Admin creates items with price, image, categories (each link carries `category_level` and `is_custom_category`), an optional recipe (ingredient lines with size, removable flag and per-line replacements), optional add-ons, optional attributes, optional recipe text.
4. The POS home screen loads **main categories** (`is_not_main = 0`, level 0 or 2) together with order types + allowed payment types + the **next order number** (one round-trip boot).
5. Cashier drills into subcategories or item lists; for "make your own meal", the POS calls the multi-category item resolver to assemble component choices. To customise a meal, the POS loads `GET /pos/items/view/{id}` (recipe lines, replacements, add-ons).
6. Items chosen become order lines (Module 4).

## Business Rules
- **Category fields:**
  - `name` unique; `slug` auto-derived (`lowercase, spaces→dashes`).
  - `code` (string, unique, nullable) — a platform-facing category identifier, distinct from `slug`; the platform item push targets categories by this field (`category_code`).
  - `sort_order` **required and unique** across all categories (global manual ordering).
  - `level` — "0", "1", "2" hierarchy depth markers (stored as strings).
  - `has_sub` — category contains subcategories; `has_items` — category directly contains items.
  - `is_not_main` — hides the category from the POS main screen; **forced to 0 when level = 0** (root categories are always main).
  - A category may have multiple parents (used by the custom-meal structure).
- **Item fields:**
  - `name` required, `price` required numeric (**0 is allowed** — used for free components), at least one category required.
  - `slug` auto from name; `mapper_slug` unique nullable — the external-platform item identifier.
  - 🚩 **SPRINT 1 · 09-08-2026:** `sku` (unique, nullable, added 2026-07-29) is now **the identifier the mobile app orders by** — `mapper_slug` survives only as the key used to *match* an existing POS item during SKU sync, and for MYOM placeholders. `POST /pos/items/mobile/check-mapper-slugs` reconciles the two catalogs: for every platform item whose `mapper_slug` matches a POS item, it writes the platform's SKU onto that POS item and reports back what the POS holds.
  - 🚩 **SPRINT 1 · 09-08-2026 (2026-08-08):** `preparation_time` in minutes (default **10** — `Item::DEFAULT_PREPARATION_TIME`, dashboard create/update validation `1..1440`) — the kitchen-time input consumed by Module 4's per-order `order_time` calculation.
  - `is_custom` = 1 marks an item as a **custom-meal component** (excluded from normal category item listings).
  - Image optional (`png,jpg,svg,jpeg`); if none, default image `items/default-item.png` is assigned; "remove image" resets to the default.
  - Ingredients attach as recipe lines (`item_ingredients`) carrying `size` (amount of the ingredient in the item), `is_removable` and `sort_order` — see *Recipe lines* below.
  - Attributes: definitions live in `attributes` (`model_type = items`, type `single` or `multiple`); item values in `item_attributes`. Multiple-type attributes fan an item out into variants (e.g. sizes) when queried via `valueOfAttribute`.
- **Ingredient fields:** `name_en` & `name_ar` both required & unique; `unit` ∈ {pcs, gram, kg, ml, liter}; `sku` (string, nullable, **unique**); `ingredient_category_id` (nullable) — when given it must point to a **leaf** ingredient category.
  - Changing an ingredient's category is rejected (422) while the ingredient takes part in any replacement (as the original or as the replacement) — those replacements must be removed first.
  - Deleting an ingredient that is used in a recipe or as a replacement is rejected (422).
  - The ingredient resource returns `sku` and `ingredient_category_id`.
- **Ingredient categories** (`ingredient_categories`, separate from the menu `categories`): a tree mirrored from CostControl — level 0 *type* > level 1 *category* > level 2 *sub category* (`IngredientCategory::MAX_LEVEL = 2`). Fields: `cc_code` (CostControl id, nullable, unique), `parent_id`, `level`, `is_leaf`, `name_en`, `name_ar`.
  - `level` is derived from the parent (root = 0, child = parent + 1); a child cannot be created under a leaf category or below level 2.
  - The parent cannot be changed on update.
  - `is_leaf = 1` is rejected while the category has sub categories; `is_leaf = 0` is rejected while it holds ingredients. Only leaf categories hold ingredients.
  - A category can be deleted only when it has no sub categories and no ingredients (422 otherwise).
  - The list supports `leaf_only=1` (categories an ingredient can be filed under) and `parent_id` (one category's children).
  - CRUD is restricted to admin/superadmin holding any of the `ingredients.*` permissions.
- **POS reads:**
  - Main categories: `is_not_main=0 AND level IN (0,2)`.
  - Category item list: items of the category where `is_custom = 0` only.
  - Sub-category fetch returns 404-style message if a category has no children.
  - Item search: `LIKE %text%` on item name, across ALL items (no branch/category filter).
- **Custom-meal resolver** (`get-items-multiple`): given one non-custom (level-1) category and one custom (level-2) category, returns:
  - all level-2 categories **not** parented to the chosen level-1 category, containing custom items belonging to the chosen custom category, plus
  - the chosen category's own non-custom items (`is_custom_category = 0` links).
  This drives the "choose your components" screen.
- **Update semantics:** category parents and item categories/ingredients are diffed: links absent from the request are detached, new ones attached. **Sending an item update without `categories` detaches ALL of its categories** (same for ingredients — omitting `ingredients` clears the recipe). Changing an existing line's `size` updates it.
- **Recipe lines (dashboard create/update):** each line needs an existing ingredient `id` (no duplicates within the item — one line per ingredient) and a numeric `size` greater than 0 (stored with 3 decimals); `is_removable` (default false) marks an ingredient the customer may leave out; `sort_order` defaults to the line's position. The unit shown beside the quantity is the ingredient's own unit. Incomplete lines are rejected with 422. A line kept across saves keeps its id; its replacements are replaced by the submitted list; dropped lines take their replacements with them.
- **Replacements (per recipe line):** a recipe line may list ingredients that can be served instead of its ingredient. A replacement must share the original ingredient's **leaf ingredient category**; an ingredient with no category can have no replacements; an ingredient cannot replace itself; a replacement may not be listed twice on the same line. Each replacement carries its own `size` (defaults to the original line's size), `extra_charge` (AED, ≥ 0, default 0 — added to the meal line price when chosen) and `sort_order`.
- **Add-ons:** an add-on is any item linked to the menu category whose `code` is `ASP_CAT_1000` (`Item::ADDON_CATEGORY_CODE`) or to any category below it (all depths via `parent_categories`); there is no per-item add-on flag. A meal's add-ons are chosen only from such items, never the meal itself, no duplicates, at least one when `has_addons` is on; each carries a `sort_order`. When an item leaves the add-on category (dashboard item update or the platform item push), it is removed from every meal's add-ons and those meals' flags are recomputed. Deleting an item that is still an add-on of some meal is rejected (422).
- **Optional add-on group on update:** `has_addons` absent leaves the add-ons untouched, `0` clears them, `1` replaces them with the submitted `addons[]`.
- **Composition flags:** `items.has_replacement` (some recipe line has at least one replacement) and `items.has_addon` (the item has at least one add-on) are recomputed from the saved rows on every dashboard create/update; they are never set by hand.
- **Item composition response** (dashboard view/list, `GET /pos/items/view/{id}`): `has_replacement`, `has_addon`; `ingredients[]` = recipe lines (`id` = ingredient id, `item_ingredient_id`, `sku`, names, `unit`, `size`, `is_removable`, `sort_order`, `ingredient_category`, `replacements[]` with `id` = ingredient id, `replacement_id`, `sku`, names, `unit`, `size`, `extra_charge`, `sort_order`); `addons[]` at item level. POS menu endpoints that do not load the recipe keep the plain ingredient shape plus the two flags.
- **Lookup endpoints for the form:** replacement candidates are the other ingredients of the given ingredient's leaf category (none when it has no category), searchable by name or sku; add-on candidates are items of the add-on category, searchable by name, optionally excluding the meal being edited (returned as `id`, `sku`, `name`, `price`, `delivery_app_price`, `image`). Each lookup returns at most 50 results.
- Deletes are hard deletes. Menu categories have no referential checks; items are guarded only against being a live add-on; ingredients are guarded against recipe/replacement use (items already sold remain referenced by order lines via pivot rows; category/item deletion does not cascade to orders).

## Hidden Rules
- An item with `price = 0` **never counts toward the invoice "count of items"** (Module 4) — the count is a count of *paid* items.
- Items with `is_custom = 1` are only reachable through the custom-meal flow, never via normal category browsing or (in effect) direct sale.
- Because item search has no `is_custom` filter, **searching can surface custom components individually.**
- The item's `category_level` and `is_custom_category` pivot values (not the category's own fields) decide how the custom-meal resolver treats it.
- Category uniqueness of `sort_order` means inserting a category "between" two others requires renumbering others first (no auto-shift).
- DSR item reporting groups by the item's **first non-custom category** — items linked only to custom categories fall into a null category group.
- Add-on status is decided purely by menu-category membership: moving an item into or out of the `ASP_CAT_1000` subtree (on the dashboard or via the platform item push) changes whether it can be an add-on, and moving it out silently drops it from every meal that offered it.
- Replacement eligibility is decided by the ingredient category: re-filing an ingredient is blocked while it is in a replacement, so the same-leaf-category invariant cannot be broken after the fact.
- 🚩 **SPRINT 1 · 09-08-2026 (2026-08-13 → 08-15):** A "make your own meal" (MYOM) composed meal ordered from the mobile app arrives as lines carrying a `group` number. Each line is bound to a **seeded placeholder item `myom-N`** (matched by `mapper_slug`, N = the Nth composed meal within that group), but **the platform supplies the line's actual price and display name** — stored as `"<placeholder>-<line name>"` in the new column `order_items.custom_name` and shown instead of the item name everywhere the line appears. `groupId` keeps one meal's components together on the ticket. 10 placeholder items (`MYOM 1`…`MYOM 10`, price 0, category 49) are seeded by `TempMYOMMapping`. **Two consequences:** (1) the server-side "client cannot invent prices" guarantee (Module 4) does **not** hold for MYOM lines — the platform's price flows straight into the invoice and every revenue report; (2) a composed meal is **not traceable to its actual chosen components** — only the joined display name survives. This is explicitly a **temporary** mapping pending a real composed-meal model; do not delete/renumber the `myom-*` items, and a group with more than 10 meals is rejected.
- **Platform item push** (`POST /pos/items/mobile/items-creation`, handler `ItemCreationPlatform`): any platform authenticated by `auth.platform` (`PlatformAuthMiddleware`, which puts the token's platform on the request) may push items; no platform is refused. The calling platform's `slug` is the item's source (e.g. `asparagus_mobile_app`).
  - **Update-or-create keyed on `sku`.** A field left out or sent as `null` keeps the stored value; an empty `ingredients` / `addons` list clears the recipe / add-ons. Fields outside the payload contract are dropped.
  - **Field rules:** `sku` always required. `name`, `price` (numeric ≥ 0) and `category_code` (must exist in `categories.code`) are required only when creating. `slug` is optional for every platform; when sent it is stored as both `slug` and `mapper_slug` and must be unique on `items.mapper_slug`. Without a slug, a new item's `slug` is `Str::slug(name)` and `mapper_slug` stays null.
  - **Recipe and add-ons by SKU:** `ingredients[]` = `{sku (distinct, exists in ingredients.sku), size > 0, removable bool, sort_order int ≥ 0, replacements[] {sku (exists in ingredients.sku), size > 0?, extra_charge ≥ 0?, sort_order?}}`; `addons[]` = `{sku (distinct, exists in items.sku), sort_order?}`. SKUs are translated to ids and checked with the dashboard item form's composition rules (same-leaf-category replacements, add-ons only from the add-on category, no item as its own add-on); those errors are reported under `….sku` keys instead of `….id`.
  - **Create-only values:** `is_custom = 0`, `preparation_time = 10`, default image `items/default-item.png`, `created_by` = the user related to the calling platform (logged in by `auth.platform`), `created_from` = the calling platform's slug. An update never changes `created_by` or `created_from`.
  - A `category_code` syncs the item to exactly that one category; if the item ends up outside the add-on category it is removed from every meal's add-ons. Flags (`has_replacement`, `has_addon`) are recomputed.
  - **Response:** `{status, message}` with HTTP **200** whether it succeeds or fails (status is a body flag, not a status code); a validation failure adds `errors` (field → messages) and `message` is the first error. Everything runs in one transaction, so any failure stores nothing. Each push is logged on the `integration` channel tagged `[<platform slug> Item]` (e.g. `[asparagus_mobile_app Item]`).
  - **Re-posting an existing SKU overwrites a live, already-selling menu item** (name, price, category, recipe, add-ons) with no approval step.
- **Sized item push** (`POST /pos/items/cc/item-creation`, `ItemSizesCreationCC`): CostControl sends a meal with one recipe per meal size and its add-ons in full. Any `auth.platform` platform may call it; its slug is the source, as in the flat push.
  - **Only the `ready_meals` size is stored.** Its `ingredients[]` (same shape and rules as the flat push's `ingredients[]`, replacements included) become the meal's recipe; every other size (`maintain_weight`, `lose_weight`, …) and `size_type` are accepted and ignored. Without a `ready_meals` size the stored recipe is kept as it is; a `ready_meals` size with an empty `ingredients` list clears it.
  - **Add-ons are create-or-update by `sku`.** Each `addons[]` entry `{sku, name?, price?, category_code?, sort_order?, sizes[] {size, ingredients: [{sku, size, removable?, sort_order?}]}}` is saved as its own item first, through the flat push rules (an unknown sku is created and then needs name, price and a `category_code` in the add-on category; a known sku only has its sent fields updated). Its recipe is all the ingredients of its `ready_meals` size: each line needs a `sku` and a `size` > 0, is not removable unless `removable` is sent true, and takes `sort_order` from the payload or else its position in the list; add-on recipe lines carry no replacements. An empty `ingredients` list on the add-on's `ready_meals` size clears its recipe; an add-on without a `ready_meals` size keeps its stored recipe. The meal then offers exactly the listed add-ons (an empty list clears them; leaving `addons` out keeps them).
  - **`price` is also the delivery-app price** — for the meal and for each add-on, `delivery_app_price` is set to the sent `price`.
  - Errors are reported on the sized payload's own keys (e.g. `sizes.1.ingredients.0.sku`, `addons.0.sizes.1.ingredients.1.sku`). Same response shape, single transaction and `integration` logging (tagged `[<platform slug> Item Sizes]`) as the flat push.
- **Sized item push dry run** (`POST /pos/items/cc/item-creation-simulate`, `ItemCreationSimulateCC`): takes the sized payload; any `auth.platform` platform may call it and it runs as that platform (its slug is what `created_from` would get). The payload is validated and saved exactly as the sized push saves it — add-ons included — then everything is rolled back, so add-ons it would create are not stored either. Success returns `{status:true, message:"payload is valid, nothing was stored", action: create|update, payload: <validated sized payload, ready to POST to items/cc/item-creation>, item: <ItemResource preview of the meal incl. recipe lines, add-ons, flags>}`; failure returns the same error shape as the sized push. Logged tagged `[<platform slug> Item Sizes Simulate]`.
- **Recipe-line replacements are not returned by the item resources.** They are still stored, validated and used for pricing and order snapshots, and `has_replacement` is still returned, but each recipe line in `ItemResource` / `DBItemResource` carries no `replacements[]` — so the POS item endpoints, the dashboard item list/view and the simulate preview all omit them.
- The platform item endpoints (and `check-mapper-slugs`) are excluded from the POS item controller's `cashier|chief` role and `item.read` permission middleware; they are guarded only by `auth.platform`.

## Decision Tree
```
POS MAIN SCREEN:
Return categories WHERE is_not_main = 0 AND level IN (0,2)
+ all order types with their allowed payment types
+ next order number (see Module 4 numbering)

CATEGORY CREATE:
IF level == 0 → is_not_main := 0
ELSE is_not_main := (request.is_not_main == 1 ? 1 : 0)
IF parents provided → create parent links with parent_level

ITEM CREATE:
create item (is_custom = request.is_custom == 1 ? 1 : 0)
FOR each defined item attribute present in request:
   IF type == multiple → one item_attributes row per value
   ELSE → single row
IF image uploaded → store; ELSE → default image
attach categories (with category_level, is_custom_category)
save recipe lines (size, is_removable, sort_order)
FOR each line's replacements:
   replacement == original → 422
   listed twice on the line → 422
   original has no ingredient category → 422
   replacement's leaf category != original's → 422
   ELSE store (size := given ?? line size, extra_charge := given ?? 0)
IF has_addons == 1 → every add-on in ASP_CAT_1000 or below, not the item itself → store rows
recompute has_replacement / has_addon

ITEM UPDATE:
only provided scalar fields change
categories: diff-sync (absent ⇒ detach all)
ingredients: sync recipe lines, kept lines keep their id, replacements replaced (absent ⇒ clear recipe)
has_addons: absent ⇒ untouched, 0 ⇒ clear, 1 ⇒ replace
item no longer in the add-on category ⇒ removed from every meal's add-ons (meal flags recomputed)
recompute has_replacement / has_addon
IF remove_file == 1 → image := default
ELSE IF image provided → replace image

PLATFORM ITEM PUSH (items/mobile/items-creation):
platform := token's platform (auth.platform; any platform)
existing := item WHERE sku = payload.sku
validate (create: name, price, category_code required; slug optional, unique on mapper_slug)
IF slug sent → slug := mapper_slug := payload.slug
IF existing → update the non-null fields (created_by / created_from untouched)
ELSE → create (slug defaults to Str::slug(name), is_custom 0, preparation_time 10,
               default image, created_by = platform user, created_from = platform.slug)
IF category_code → sync to that single category
item outside the add-on category ⇒ removed from every meal's add-ons
ingredients / addons: null ⇒ keep, [] ⇒ clear, else replace (dashboard composition rules)
recompute has_replacement / has_addon
ANY error → rollback, {status:false, message, errors?}

SIZED ITEM PUSH (items/cc/item-creation / items/cc/item-creation-simulate):
platform := token's platform (auth.platform; any platform, simulate included)
validate structure (sizes[].size distinct; addons[].sku distinct)
FOR each addon:
   recipe := its sizes[ready_meals].ingredients
             (removable := sent ?? false, sort_order := sent ?? list position, no replacements;
              [] ⇒ clear recipe, no ready_meals ⇒ keep recipe)
   PLATFORM ITEM PUSH on the addon (create or update by sku), delivery_app_price := price
meal recipe := sizes[ready_meals].ingredients (no ready_meals ⇒ keep recipe)
meal addons := addons[].sku (absent ⇒ keep, [] ⇒ clear)
PLATFORM ITEM PUSH on the meal, delivery_app_price := price
ANY error → rollback, errors keyed on the sized payload
IF simulate → rollback (meal and add-ons), return action + validated sized payload + meal preview

ITEM DELETE:
IF item is an add-on of any meal → 422

INGREDIENT CATEGORY CREATE:
IF parent given:
   parent is leaf OR parent.level == 2 → 422
   level := parent.level + 1
ELSE level := 0

INGREDIENT CATEGORY UPDATE:
parent never changes
is_leaf := 1 while it has children → 422
is_leaf := 0 while it holds ingredients → 422

INGREDIENT CATEGORY DELETE:
IF has children OR has ingredients → 422
```

## Database Understanding
- `categories` + `parent_categories` — a DAG (not a strict tree): a category can have several parents so the same component group can appear under multiple meals.
- `items` + `item_categories` — items live in multiple categories; the pivot's `is_custom_category` flag records *in which role* the item belongs to that category (normal menu entry vs custom-meal component).
- `item_ingredients` (model `ItemIngredient`, `Item::recipeLines()`) — recipe lines: `item_id` (FK cascade), `ingredient_id` (FK restrict), `size` decimal(10,3), `is_removable` (bool, default 0), `sort_order`; unique (item_id, ingredient_id). **No stock/inventory logic exists** — ingredients are informational. The migration that adds these columns removes rows whose item no longer exists and strips thousands separators from `size` before converting it to decimal.
- `item_ingredient_replacements` (model `ItemIngredientReplacement`) — `item_ingredient_id` (FK cascade), `ingredient_id` (FK restrict), `size` decimal(10,3), `extra_charge` decimal(10,2) default 0, `sort_order`; unique (item_ingredient_id, ingredient_id).
- `item_addons` — `item_id` (meal, FK cascade), `addon_item_id` (FK restrict), `sort_order`; unique on the pair.
- `items.has_replacement` / `items.has_addon` — derived booleans maintained by `ItemCompositionService::refreshFlags`.
- `items.created_from` — string(191), nullable: where the item was created — `dashboard` (dashboard create) or the pushing platform's `slug` (platform push, e.g. `asparagus_mobile_app`). Null for items created before the column existed. Written only on create; updates never change it.
- `ingredient_categories` (model `IngredientCategory`) — self-referencing tree (`parent_id` FK restrict), `cc_code` unique nullable, `level` 0–2, `is_leaf`, `name_en`, `name_ar`.
- `ingredients.ingredient_category_id` — nullable FK to `ingredient_categories` (restrict); `ingredients.sku` — unique nullable.
- `attachments` — polymorphic file store; items keep exactly one `image`-typed attachment.
- `attributes` / `item_attributes` — EAV-style extensible item properties (e.g. size variants) without schema changes.

## State Machine
None — catalog entities have no lifecycle states, only hard create/update/delete.

## APIs (summary)
| Endpoint | Purpose | Key validation | Side effects |
|---|---|---|---|
| `GET /dashboard/categories/list` | Full tree for admin | — | — |
| `GET /dashboard/categories/parent-categories` | Categories with `has_sub=1` | — | — |
| `GET /dashboard/categories/item-categories` | Categories that can hold items (level 0/2 + custom lvl-1-parented set) | — | — |
| `POST /dashboard/categories/create` | New category | name unique, sort_order unique, parents exist | creates parent links |
| `PUT /dashboard/categories/update` | Edit category | id exists; name/sort_order unique except self | diff-syncs parents |
| `POST /dashboard/categories/delete` | Remove category | — | hard delete |
| `POST /dashboard/items/create` | New item (form-data) | name, numeric price, ≥1 category, image mime; `ingredients[n][id\|size\|is_removable\|sort_order]`; `ingredients[n][replacements][m][id\|size\|extra_charge\|sort_order]` (same leaf category); `has_addons` + `addons[n][id\|sort_order]` (add-on category only) | image, attrs, categories, recipe lines, replacements, add-ons; flags recomputed |
| `POST /dashboard/items/update` | Edit item | id exists; same composition rules as create | diff-sync; absent ingredient/category lists clear them; absent `has_addons` leaves add-ons untouched; leaving the add-on category prunes the item from other meals |
| `GET /dashboard/items/view/{id}`, `GET /dashboard/items/list` | Item detail / list | — | return `has_replacement`, `has_addon`, recipe lines (without their replacements), `addons[]` |
| `GET /dashboard/items/replacement-candidates` | Other ingredients of the same leaf category (`ingredient_id`, optional `search`); returns `ingredients` | — | max 50 |
| `GET /dashboard/items/add-ons` | Items of the add-on category; optional `category_id` narrows them to that category and the categories below it (422 unless it is the add-on category or below it); optional `exclude_item_id`, `search` | — | max 50 |
| `POST /dashboard/items/delete` | Remove item | not an add-on of any meal (422) | hard delete |
| `GET /dashboard/items/attributes/list` | Attribute definitions | — | — |
| `POST/GET /dashboard/ingredients/*` | Ingredient CRUD | bilingual unique names; unit whitelist; unique sku; leaf `ingredient_category_id`; category locked while in a replacement; delete blocked while used in recipe/replacement (422) | — |
| `GET /dashboard/ingredient-categories/list` | Flat list ordered by level (`leaf_only`, `parent_id`) | `ingredients.*` permission | — |
| `GET /dashboard/ingredient-categories/view/{id}` | Detail with parent/children | — | 404 if missing |
| `POST /dashboard/ingredient-categories/create` | New category | names; parent not leaf and level < 2 | level derived from parent |
| `PUT /dashboard/ingredient-categories/update` | Edit names / cc_code / is_leaf | parent not changeable; leaf-flag guards | — |
| `POST /dashboard/ingredient-categories/delete` | Remove category | no children, no ingredients (422) | hard delete |
| `GET /pos/categories/main` | POS boot menu | — | — |
| `GET /pos/categories/get-sub/{id}` | Drill-down | category exists | — |
| `GET /pos/categories/get-items/{id}` | Items of category | category exists | — |
| `POST /pos/categories/get-items-multiple` | Custom-meal component sets | expects one is_custom=0 and one is_custom=1 entry | — |
| `GET /pos/items/search` | Name search | — | — |
| `GET /pos/items/view/{id}` | Customise payload for one item (recipe lines without their replacements, add-ons, flags) | item exists | — |
| 🚩 `POST /pos/items/mobile/check-mapper-slugs` | Reconcile SKU↔mapper_slug across catalogs | platform items[] | matched POS items | platform token | 500 | writes platform SKU onto matched POS items |
| `POST /pos/items/mobile/items-creation` | Platform pushes an item (create or update by sku); any `auth.platform` platform; its slug becomes `created_from` | sku; name, price, category_code on create; optional slug (unique on mapper_slug); ingredients[]/addons[] by sku with dashboard composition rules | update-or-create on sku — overwrites a live item; sets `created_by`/`created_from` on create; prunes it from meals' add-ons if outside the add-on category; `{status, message, errors?}` HTTP 200 either way |
| `POST /pos/items/cc/item-creation` | CostControl pushes a meal with recipes per meal size and its add-ons in full (`ItemSizesCreationCC`) | sku; name, price, category_code on create; `sizes[].size` distinct; `ready_meals` ingredients and each add-on validated with the flat push rules; add-on `sizes[].ingredients[]` `{sku, size > 0, removable?, sort_order?}` | add-ons created/updated by sku first; meal stores only the `ready_meals` recipe; `delivery_app_price` := `price`; `{status, message, errors?}` HTTP 200 either way |
| `POST /pos/items/cc/item-creation-simulate` | Dry run of the sized push, run as the calling platform (`ItemCreationSimulateCC`) | same validation as `items/cc/item-creation`; any `auth.platform` token | nothing stored (meal and add-ons rolled back); returns `action`, validated sized `payload` (ready for `items/cc/item-creation`), `item` preview |
| 🚩 `POST /pos/ingredient/cc/ingredient-creation` | Draft · 2026-09-08 — platform pushes an ingredient | sku, name_en, name_ar, unit | success/failure flag | platform token; **no field validation** (name/sku presence untested before insert) | 200 w/ `status:false` on any exception | `updateOrCreate` keyed on sku; unit is remapped from short platform codes (`gm→gram`, `ltr→liter`, `pcs/ml/kg` pass through) — **an unrecognized unit code is an undefined-array-key warning in PHP, not a caught exception, so it can slip through as a null unit** |

## Edge Cases
- `get-items-multiple` assumes `categories[]` contains at least one custom and one non-custom entry — otherwise `[0]` index errors (500).
- Category "not found" responses return HTTP 404 **with `success: true`** (clients must check message, not flag).
- Item update with a `categories` array whose entries match existing links still runs the diff logic comparing an array to an id (`where('category_id', $category)` passes an array) — works by accident of Laravel query building.
- Duplicate item names are allowed (only slug derived; no unique rule on item name).
- If no menu category carries `code = ASP_CAT_1000`, no item qualifies as an add-on: the add-on lookup is empty and any `has_addons = 1` submission is rejected.
- An ingredient without an ingredient category has no replacement candidates, and any replacement submitted for it is rejected.

## Assumptions
- Menu is global — **all branches sell the same menu at the same prices** (no branch-specific price or availability anywhere).
- Item price includes VAT (see Module 4 math).
- Images live on the public disk and are publicly reachable.

## TODOs
- No pagination anywhere — full-table responses; menu growth will degrade POS boot.
- Deleting a category/item silently breaks the custom-meal graph (no guard).
- `CategoryTestFucntionController` exists as leftover test code (not routed).
- No branch-level menu control (likely future need).
- Ingredient categories are maintained manually on the dashboard; the CostControl sync that `cc_code` anticipates is not built.

## Open Questions
- Intended meaning of `category_level` on the item-category pivot vs the category's own `level` (both exist; resolver mixes them).
- Are attribute-driven variants actually used by the current frontend, or legacy?
- Replacement `extra_charge` (added to the meal price when a replacement is chosen) reverses the October decision-log rule "replacements never change the price" — flagged in the recipe schema PDF; needs PO decision.
- Catalog-level add-ons (`item_addons`, configured per meal) contradict the decision-log rule "add-ons have no catalog relation, they are linked in the cart only" — needs PO decision.
- Ingredient categories are filled manually today; when and how the CostControl sync for them is built is undecided.
- Do CostControl's real item field names match the sized push payload (`sku`, `name`, `price`, `category_code`, `sizes[].size/ingredients[].sku/size/removable/replacements`, `addons[].sku/name/price/category_code/sizes[].ingredients[].sku/size/removable/sort_order`)? The contract is defined on the POS side only.
- The sized push stores only `ready_meals`; when and where the other meal sizes (`maintain_weight`, `lose_weight`) and `size_type` are stored is undecided.
- With replacements hidden from the item resources, the POS can no longer offer them, yet `has_replacement` is still returned and order lines still accept replacements — is hiding temporary, and should `has_replacement` be hidden too?
- CostControl has no row in `platforms` or `PlatformSeeder`, so it cannot push items until it is given its own platform row and token; its items' `created_from` will be whatever slug that row is given.

## Evidence
- `app/Models/IngredientCategory.php`, `app/Http/Controllers/Dashboard/IngredientCategoryController.php`, `app/Http/Requests/IngredientCategoryStoreRequest.php`, `app/Http/Requests/IngredientCategoryUpdateRequest.php`, `app/Http/Resources/IngredientCategoryResource.php`
- `app/Models/Ingredient.php`, `app/Http/Requests/IngredientStoreRequest.php`, `app/Http/Requests/IngredientUpdateRequest.php`, `app/Http/Controllers/Dashboard/IngredientController.php`, `app/Http/Resources/IngredientResource.php`
- `app/Models/Item.php` (`ADDON_CATEGORY_CODE`, `recipeLines()`, `addons()`), `app/Models/ItemIngredient.php`, `app/Models/ItemIngredientReplacement.php`
- `app/Services/Dashboard/ItemCompositionService.php`, `app/Http/Requests/Concerns/ValidatesItemComposition.php`, `app/Http/Controllers/Dashboard/ItemController.php`
- `app/Http/Controllers/Pos/ItemController.php` (`show`, `ItemCreationPlatform`, `ItemCreationSimulateCC`, `ItemSizesCreationCC`, `storeSizedPayload`, `pushItem`, `validateItemPayload`, `storeItemPayload`, constructor middleware `except`)
- `app/Models/Item.php` (`created_from` fillable), `app/Http/Controllers/Dashboard/ItemController.php` (`store` sets `created_from = dashboard`), `app/Http/Middleware/PlatformAuthMiddleware.php` (merges the token's `platform` into the request)
- `tests/Feature/ItemCreationPlatformTest.php`
- `app/Http/Resources/ItemResource.php`, `app/Http/Resources/DBItemResource.php`, `app/Http/Resources/ItemRecipeLineResource.php`, `app/Http/Resources/ItemAddonResource.php`
- `routes/api/v1/dashboard.php` (`ingredient-categories/*`, `items/replacement-candidates`, `items/add-ons`), `routes/api/v1/pos.php` (`items/view/{id}`, `items/mobile/*`, `items/cc/item-creation`, `items/cc/item-creation-simulate`, `ingredient/cc/ingredient-creation`)
- `database/migrations/2026_10_22_000001_create_ingredient_categories_table.php` … `2026_10_22_000004_create_item_addons_table.php`, `database/migrations/2026_10_25_000001_add_created_from_to_items_table.php`
