## Context

`LunchMenuController::store()` builds a map `lower(trim(name)) → id` of all non-deleted `lunch_items`, then loops over `custom_items`:

- no match → `LunchItem::create(['price' => $ci['price'] ?? default_item_price, ...])`
- match → only reuses the id; `$ci['price']` is dropped (the bug)

Price lives only in `lunch_items.price`. The menu–item pivot (`lunch_booking_menu_item`) has no price column. `lunch_orders.price` is a snapshot copied from `lunch_items.price` when an employee places or changes an order (`LunchOrderController` store/update/admin update).

The whole `store()` currently runs without a transaction.

## Goals / Non-Goals

**Goals:**
- A `custom_items` price sent for an existing item updates that item's price.
- Omitted/null price on an existing item leaves its price unchanged.
- Conflicting duplicates in one request are rejected with 422.
- No partial writes (repriced items without a menu) on failure.

**Non-Goals:**
- Per-menu pricing (price on the pivot).
- Accepting `price` in `items[{id}]`.
- Changing the update-menu endpoint (it does not accept `custom_items`).
- Changing how orders snapshot price.

## Decisions

**1. Update the global `lunch_items.price` (option A), not a per-menu price.**
The data model already treats price as a property of the item, and orders snapshot it. A per-menu price would need a migration, pivot changes, and changes to all three places that copy the price into orders, plus resources and reports. Rejected as too large for a bug fix.

**2. Only update when `price` is non-null and actually differs.**
`array_key_exists` + `!== null` check; skip the write when `(float) $item->price === (float) $ci['price']` to avoid needless `updated_at` churn. Load matched items as models (`id, name, price`) instead of plucking ids only, so the comparison and update need no extra query per item.

**3. Duplicate check runs after `validate()` and before any write.**
Group `custom_items` by `mb_strtolower(trim(name))`; within a group, normalize each price to `null` or `(float)` and compare. On a conflict, throw `ValidationException::withMessages(['custom_items.<index>.price' => ...])` for the first conflicting entry, which gives the standard Laravel 422 response, the same as the existing `validate()` errors. An omitted price counts as `null`, so `{price: 40000}` + `{}` conflicts. This was preferred over silently picking the first or last entry because the admin's intent is ambiguous. Same-name entries with equal prices are accepted and deduplicated by the existing `unique('id')`.

**4. Wrap the write section in `DB::transaction()`.**
This covers new-item creation, price updates, `LunchBookingMenu::create`, `handleDisplayName`, the `items()`/`accounts()` sync and `updateStatus`. `getAccountsByRequest()` is read-only and can stay before or inside. `DB` is already imported in the controller.

## Risks / Trade-offs

- [Other open menus containing the same item start charging the new price for orders placed after the change] → Accepted business trade-off (price belongs to the item). Existing orders are unaffected because of the snapshot.
- [Clients that previously sent conflicting duplicates now get 422] → Low likelihood; the UI sends one row per dish. The error points at the offending entry.
- [Price comparison on floats] → Prices are whole VND amounts; a `(float)` cast is sufficient.
