# 🚩 **SPRINT 3 — UPDATES FROM 2026-10-17**

> # **⬛ DRAFT — NOT YET PO-REVIEWED ⬛**
> # **SPRINT 3 · 2026-10-17 → 2026-10-26**
>
> **Everything below is business behaviour that did not exist in the knowledge base before this period.**
> **Full business context for each item is folded into the per-module files (`docs/kb/00-…` to `10-…`), where the same items are listed under that module's `Pending PO review:` header bullets until a PO review pass confirms them.**
> **This register is a draft** — written from the code diff, with no business sign-off pass. Treat framing as best-effort until reviewed.

| | |
|---|---|
| **Sprint** | **Sprint 3 (draft)** |
| **Updates from** | **2026-10-17** |
| **Code state documented** | branch `dev`, `dbbe746`..`b71c156` (2026-10-17 → 2026-10-26) |
| **Documented on** | 2026-10-26 |
| **Previous baseline** | Sprint 2 draft, code through 2026-09-16/17 (`7c4d737`) — see `SPRINT_2_UPDATES_DRAFT_2026-09-17.md` |
| **Not yet on `main`** | Everything here lives on `dev`. `main` is still at `dbbe746`. |

**Headline:** this sprint is one large feature — **meal customisation** (ingredient replacements and removals) and **add-ons** — plus a **delivery-failure safety net** on the Noon side and a **unified catalog push** for external platforms. It adds 8 migrations, 6 tables/column groups, 13 endpoints and the first order-line snapshot of a recipe.

---

## S1 — A dead Noon delivery now rejects the order back to the customer
**Date:** 2026-10-17 · **Where:** `OrderService::rejectExternalOrderByProvider`, `NoonIntegrationService`, `NoonWebhookRequest` · **Modules:** M08, M05, M06

- A delivery Noon never accepts (create-task refused, or the call throws) **and** a delivery Noon cancels after accepting now both reject the order back to the platform: `status` and `operation_status` → `cancelled`, plus the queued `rejected` callback. Previously neither path touched the order — it stayed approved while the customer waited for food that was never coming.
- The tracker parks in the terminal literal state `error` carrying the reason (parsed Noon error, or Noon's cancellation text). `error`, `cancelled` (7) and `delivered` (6) are terminal: later webhooks for that task are skipped, so a late push cannot resurrect a dead task or undo a completed delivery.
- Because both triggers run in queue workers, **approving a delivery order is no longer a terminal decision** — it can flip to rejected asynchronously with no cashier action, until the delivery is delivered.
- Guards: an already-`cancelled` order is skipped; an order on a **closed day** keeps its status and only logs (known gap — it keeps showing as approved with a dead delivery).
- Remaining silent-failure path: a branch with no `noon_outlet_id` returns before any Noon call and writes no tracker.

## S2 — The `rejected` callback says who rejected the order
**Date:** 2026-10-24 · **Where:** `NotifyPlatformOrderUpdatedJob`, `OrderService::notifyPlatformOrderUpdated` · **Modules:** M05, M08

- New boolean `is_delivery_rejected` in the platform callback body, **only on the `rejected` event**: `true` when the delivery provider killed the delivery, `false` when the cashier rejected it from the POS. Non-rejection events pass `null`, which omits the key entirely rather than sending `false`.
- The mobile app can now tell a branch rejection from a delivery failure. It still cannot tell the two provider-side triggers apart, and the rejection **reason** is still local-only (tracker + log), never sent to the platform.

## S3 — Inbound platform orders are traced locally
**Date:** 2026-10-17 · **Where:** `OrderService::createMobileOrder` · **Modules:** M05

- Every platform order-creation entry point funnels through one method that logs to the `integration` channel: a summary line (platform slug, external order id/number, branch slug, type, order type, line count), the payload as sent, then either `Created` (POS order number, branch, items total, delivery fee) or `Failed` with the exception message.
- The `platform`, `branch` and `user` models that `PlatformAuthMiddleware` merges into the request are stripped from the logged payload.
- Before this, a platform complaining about a missing order left nothing to look at — the exception went back in the response body and was never recorded on our side.

## S4 — Ingredient categories (a second, separate category tree)
**Date:** 2026-10-22 → 2026-10-23 · **Where:** `ingredient_categories`, `IngredientCategoryController` · **Modules:** M03, M01, M10

- New tree mirrored from CostControl: **type (level 0) > category (level 1) > sub category (level 2)**, `cc_code` as the sync key, `is_leaf` marking the categories that may hold ingredients. Kept entirely apart from the menu `categories` table.
- `ingredients.ingredient_category_id` must point at a **leaf** category; `ingredients.sku` becomes **unique** (it was nullable and non-unique).
- Dashboard CRUD `dashboard/ingredient-categories/{list,view/{id},create,update,delete}`. Parent is not changeable after creation; delete is blocked while the category has children or ingredients (422).
- An ingredient's category is **locked** while that ingredient takes part in a replacement, and an ingredient cannot be deleted while it is used in a recipe or as a replacement (422).
- **Auth note:** the whole controller sits behind `admin|superadmin` **and** all four `ingredients.*` permissions at once (`api.permission` requires every listed permission), so a role holding only `ingredients.read` cannot even list the categories.

## S5 — Recipes become first-class: replacements per ingredient, add-ons per meal
**Date:** 2026-10-22 → 2026-10-23 · **Where:** `item_ingredients`, `item_ingredient_replacements`, `item_addons`, `items.has_replacement|has_addon` · **Modules:** M03, M10

- `item_ingredients` (the recipe) gains `is_removable`, `sort_order`, a **decimal** `size`, a unique (item, ingredient) pair and real foreign keys. The migration deletes orphan rows whose item is already gone and strips thousands separators from sizes such as `"1,000.00"`.
- **Replacements hang off a recipe line, not off the item:** `item_ingredient_replacements` (recipe line → replacement ingredient, `size`, `extra_charge`). A replacement must share the original ingredient's **leaf category**.
- **Add-ons are a menu category, not a flag:** an add-on is any item in `ASP_CAT_1000` or below, linked to meals through `item_addons` (`sort_order`). An item that leaves that category is pruned from every meal's add-ons.
- `items.has_replacement` / `items.has_addon` are **read flags recomputed on every save** — they tell the POS whether to open the customise screen; they are never set by hand.
- **Dropped from the never-merged 2026-10-22 design:** `items.is_addon`, `item_replacements`, `item_add_ons` with `max_per_meal`, the "default component" concept, `delivery_app_adjustment`, and `GET dashboard/items/default-components`.

## S6 — Dashboard item composition form and its lookups
**Date:** 2026-10-22 → 2026-10-25 · **Where:** `Dashboard\ItemController`, `ItemCompositionService`, `ValidatesItemComposition` · **Modules:** M03

- `POST /dashboard/items/create|update` accept `ingredients[n][id|size|is_removable|sort_order]`, `ingredients[n][replacements][m][id|size|extra_charge|sort_order]`, and `has_addons` + `addons[n][id|sort_order]`. Cross-field checks run only after every field rule passes.
- On update: an absent ingredient/category list **clears** it, an absent `has_addons` leaves add-ons untouched, `0` clears them, `1` replaces them.
- `POST /dashboard/items/delete` is blocked (422) while the item is an add-on of any meal.
- Lookups: `GET /dashboard/items/replacement-candidates?ingredient_id=` (other ingredients of the same leaf category) and `GET /dashboard/items/add-ons?category_id=&exclude_item_id=&search=` (add-on-category items; `category_id` narrows to that category and below, 422 if it is outside the add-on tree). Both cap at 50 rows.
- Item reads (`dashboard/items/view/{id}`, `/list`) return `has_replacement`, `has_addon`, recipe lines with a nested `replacements[]`, and `addons[]`.

## S7 — POS customise screen
**Date:** 2026-10-23 · **Where:** `GET /pos/items/view/{id}` · **Modules:** M03

- New POS endpoint returning one item's full customise payload: recipe lines with `is_removable`, `sort_order`, each line's `replacements[]` (with `size` and `extra_charge`), the item's `addons[]`, and the two read flags.

## S8 — Order lines carry customisation, add-ons and a recipe snapshot
**Date:** 2026-10-23 → 2026-10-25 · **Where:** `OrderLineService`, `ValidatesOrderLines`, `order_items.type|parent_order_item_id`, `order_item_ingredients` · **Modules:** M04, M06, M07, M09

- `order_items` gains `type` (`item` | `addon`) and `parent_order_item_id` (self-FK, cascade delete), plus an (`order_id`, `type`) index.
- A POS line may send `ingredients[]` (`ingredient_id`, `status` ∈ `default|replaced|removed`, `replacement_ingredient_id` when replaced) and `addons[]` (`id`, `qty` ≥ 1). A `default` entry, or a line left out, is served as in the recipe.
- Catalog checks: the ingredient must be in the item's recipe and changed at most once per line; `removed` only for `is_removable` lines; `replaced` only with one of that recipe line's configured replacements; each add-on must be one the meal offers and not listed twice.
- **Pricing:** meal unit price = item price + the chosen replacements' `extra_charge`; each add-on is its own line priced on its own item price; the payment surcharge rate applies to both; the `total_price` check includes extra charges and add-on lines. Removing an ingredient never lowers the price.
- Add-on lines sit immediately after their meal, count in `count_of_items` when priced > 0, and are excluded from the meals count / preparation-time estimate. A standalone `ASP_CAT_1000` item is an `addon` line with a null parent.
- **Every line gets a recipe snapshot** in `order_item_ingredients` (one row per recipe line: status, recipe ingredient, what was served, size, extra charge, and the names at sale time) so later recipe edits never rewrite past orders.
- `OrderItemResource` returns `order_item_id`, `type`, `parent_order_item_id` and `modifications[]` (replaced/removed lines only).

## S9 — Recipe-snapshot columns renamed
**Date:** 2026-10-25 · **Where:** `order_item_ingredients`, `OrderItemResource` · **Modules:** M04

- `original_ingredient_id` → **`ingredient_id`** (the recipe ingredient) and the old `ingredient_id` → **`replaced_ingredient_id`** (what was actually served). Foreign keys re-created, unique key now (`order_item_id`, `ingredient_id`). Values unchanged: a `default` row still has `replaced_ingredient_id` = `ingredient_id`, a `removed` row null. The name columns are not renamed.
- `modifications[]` uses the new keys, so **any API consumer reading the old keys must switch**.

## S10 — One catalog push for every platform, with item source and a dry run
**Date:** 2026-10-24 → 2026-10-25 · **Where:** `Pos\ItemController::ItemCreationPlatform|ItemCreationSimulateCC`, `items.created_from` · **Modules:** M03, M01, M10

- `ItemCreationMobile` is renamed `ItemCreationPlatform` and now serves **both** `POST /pos/items/mobile/items-creation` and `POST /pos/items/cc/item-creation`. The platform comes from the token, so a new platform needs no code change. (The 2026-10-24 `ItemSyncService` design was reverted in `2bcafea` and rebuilt in the controller.)
- Payload is keyed on **`sku`** (update-or-create). Only `sku` is always required; `name`, `price` and `category_code` are required on create; fields left out or sent as `null` keep their stored values; an empty list clears that part; composition (`ingredients[]`, `addons[]`) is addressed by SKU and validated with the dashboard rules. Validation returns `errors` per field alongside `status:false`, HTTP 200 either way. Unknown payload fields are dropped.
- New column **`items.created_from`**: the pushing platform's slug, or `dashboard` (null for older items). Platform-created items record `created_by` = the platform's related user instead of the hard-coded `1`. Updates never change either.
- New **dry run** `POST /pos/items/cc/item-creation-simulate`: validates and saves inside a transaction, rolls it back, and returns the validated payload plus an item preview with recipe, replacements and add-ons.
- **Auth surface:** these routes (and `check-mapper-slugs`) are excluded from the controller's `cashier|chief` + `item.read` middleware, so a valid platform token is the only gate — **any authenticated platform can write the whole catalog**.

## S11 — Mobile orders carry add-ons
**Date:** 2026-10-25 · **Where:** `MobileOrderStoreRequest`, `OrderService::mapMobileOrderToStoreRequest` · **Modules:** M05, M04

- `POST /pos/orders/mobile/create` lines accept `addons[]` (`sku`, `qty` ≥ 1), mapped by SKU to the POS line shape, checked against the meal's offered add-ons (a violation aborts the order with `items.N.addons.M.id: message`), and priced into the server-side items total as add-on lines.
- Replacements and removals are **not** accepted from the platform — platform lines still get all-`default` recipe snapshots.
- A line without `addons` totals exactly as before.

## S12 — POS lines may send the whole recipe
**Date:** 2026-10-25 · **Where:** `ValidatesOrderLines` · **Modules:** M04

- `items.*.ingredients.*.status` accepts `default` alongside `replaced` / `removed`. Before this, a POS client that sent every recipe line (unchanged ones as `default`) got `The selected items.N.ingredients.M.status is invalid.` and the order failed.

## S13 — Reporting consequences of add-on lines (documented this pass, no code change)
**Date:** verified 2026-10-26 · **Where:** `DailySalesRepository`, `ReportsRepository`, `DashboardChartsRepository` · **Modules:** M06, M07

- None of the three readers filters the new `order_items.type`, so **an add-on sold with a meal is reported as its own item**: its own Items DSR row under the add-on category, its own row in the branch item sales report, and its own qty in the top-items chart. It is never folded into the meal it was sold with.
- Because every figure reads the line's stored `item_price`, **a customised meal reports above its menu price** (the replacement `extra_charge` and the surcharge share are inside it), two lines of the same item in one window can carry different unit values, and nothing attributes the uplift to the replacement that caused it. A stripped-down meal still reports at full value.
- **Open for the PO:** should reports split meals from add-ons by `order_items.type`, and should replacement revenue be reportable on its own?

## S14 — Migrations split one-change-per-file (no behaviour change)
**Date:** 2026-10-26 · **Where:** `database/migrations/2026_10_22_0000{06,07,08}_*` · **Modules:** none

- Three alters were lifted out of the migrations that created their neighbours, into their own files: the `ingredients` alter (unique `sku` + `ingredient_category_id`), the `items` read flags (`has_replacement`, `has_addon`), and the `order_item_ingredients` table. Resulting schema and ordering are identical; each new file carries its own `down()`.

---

## Where each item landed in the KB

| Item | M00 | M01 | M03 | M04 | M05 | M06 | M07 | M08 | M09 | M10 |
|---|---|---|---|---|---|---|---|---|---|---|
| S1 dead delivery rejects order | | | | | ✔ | ✔ | | ✔ | | |
| S2 `is_delivery_rejected` | | | | | ✔ | | | ✔ | | |
| S3 inbound order tracing | | | | | ✔ | | | | | |
| S4 ingredient categories | ✔ | ✔ | ✔ | | | | | | | ✔ |
| S5 recipes, replacements, add-ons | ✔ | | ✔ | | | | | | | ✔ |
| S6 dashboard composition form | | | ✔ | | | | | | | |
| S7 POS customise screen | | | ✔ | | | | | | | |
| S8 order lines + recipe snapshot | ✔ | | | ✔ | | ✔ | ✔ | | ✔ | ✔ |
| S9 snapshot columns renamed | | | | ✔ | | | | | | |
| S10 platform catalog push | ✔ | ✔ | ✔ | | | | | | | ✔ |
| S11 mobile add-ons | | | | ✔ | ✔ | | | | | |
| S12 `default` ingredient entries | | | | ✔ | | | | | | |
| S13 reporting consequences | | | | ✔ | | ✔ | ✔ | | | |
| S14 migration split | | | | | | | | | | |

## Open decisions this sprint needs from the PO

1. **`extra_charge` on replacements** reverses the October decision-log rule that *replacements never change the price*. (M03, M04)
2. **Catalog add-ons (`item_addons`)** contradict the rule that *add-ons are linked in the cart only*. (M03, M04)
3. **The payment surcharge is charged on replacement extra charges**, although it is configured as a tender fee on menu prices. (M09)
4. **Reports and DSR do not separate meals from add-ons**, and replacement revenue is invisible inside the meal's value. (M06, M07)
5. **Any platform token can write the entire catalog** — no role, permission, or per-platform scope on the item-push routes. (M01)
6. **Ingredient-category CRUD requires all four `ingredients.*` permissions at once**, so a read-only ingredient role cannot even list the tree. (M01)
7. **An approved delivery order can flip to rejected asynchronously**, and an order on a closed day keeps showing as approved with a dead delivery. (M05, M08)

## Related files

- `../KB_CHANGELOG.md` — the dated entries behind every item above.
- `../FRONTEND_CHANGES_SPRINT_3_2026-10-26.md` — the same sprint written for the client teams: endpoints, request/response shapes, and what each screen has to do.
- `README.md` — per-module index with each file's current code baseline.
