# WP1 — `PUT /production/boms/{bom}` must persist components and operations

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** no · **FE change:** none

## The defect (verified in code, do not re-derive)

Editing a BOM's components or operations reports "saved" and changes **nothing**.

- `src/app/features/production/boms/boms.component.ts` builds a full payload containing
  `components[]` (each with a nested `substitutes[]`) and `operations[]`, then calls
  `BomService.update()` → `PUT /production/boms/{id}` (`bom.service.ts` has **no** sync method at all).
- `Modules/Production/app/Http/Requests/UpdateBomRequest.php` declares **neither** `components` nor
  `operations`.
- `Modules/Production/app/Http/Controllers/BomController.php:128` does
  `$bom->update($request->validated())` — both arrays are already gone.

The dedicated endpoints exist (`PUT boms/{bom}/components`, `PUT boms/{bom}/operations`,
routes at `Modules/Production/routes/api.php:65-66`) and **no FE caller ever hits them**.

**Second, related trap:** `BomController::show()` (line ~110) loads
`'components.product', 'components.unit'` but **not** `components.substitutes`. `update()` and both
sync methods replace components wholesale (`syncComponents` deletes substitutes then re-creates from
the payload). So load → edit → save silently **wipes every substitute** — which this WP is about to
make reachable from the main save button. It must be fixed in the same WP.

## What to build

1. **Declare the line arrays on `UpdateBomRequest`** as `sometimes` — using the **same rules** as
   `syncComponents`/`syncOperations`. **Do not copy-paste the rule arrays.** Extract them once
   (e.g. a `Concerns\BomLineRules` trait or static rule providers) and have `UpdateBomRequest`
   **and** the two controller sync methods consume the same source. Two divergent copies of these
   rules is exactly the class of defect this WP is fixing.
2. **Make `update()` persist them, atomically.** `$request->validated()` will now contain the
   arrays, and `$bom->update()` must not receive them (they are not columns). Shape:
   - pull `components` / `operations` out of the validated array,
   - wrap header update + line replacement in **one** `DB::transaction`,
   - replace lines **only when the key is present** — an absent key must leave existing lines
     untouched (a header-only PATCH from any other caller must not wipe the BOM),
   - reuse the existing `saveComponents()` / `saveOperations()` private helpers and the same
     substitute-deletion order `syncComponents` uses.
3. **Fix `show()`** to eager-load `components.substitutes.product` (matching what `update()` and the
   sync methods already load on their way out).

## Load-bearing fields — do NOT alter their semantics

- `supply_source ∈ {factory,own,customer,split}` — `factory` = "unstated, follow the order";
  `own` = "force factory-supplied even on a toll order". They are **not** interchangeable, and a
  blank must not become `own`.
- `customer_qty_per_unit` is only meaningful for `split` (the FE already zeroes it otherwise).
- Do not add defaults, coercions, or normalisation the sync path does not already perform.

## Acceptance criteria

1. **Red-before-green, mandatory.** Write a test that seeds a BOM with 2 components + 1 operation,
   `PUT`s a modified `components[]`/`operations[]` through `PUT /production/boms/{id}`, and asserts
   the DB changed. **Run it BEFORE the fix and paste the failing output** — it must fail proving the
   arrays are discarded today. Then fix, re-run, paste the pass.
2. Editing components through `PUT /boms/{id}` changes `bom_components` (added / removed / edited
   rows all reflected) and `bom_operations`.
3. **Substitutes survive a round trip**: `GET /boms/{id}` → resend the returned components verbatim
   → `PUT /boms/{id}` → substitutes are still there (this fails today for two reasons; both must be
   fixed).
4. A `PUT` with **no** `components` key leaves the existing components untouched (header-only update).
5. A `PUT` whose `components[]` fails validation returns 422 and **the header is not saved either**
   (transaction proves it).
6. The existing `PUT /boms/{id}/components` and `/operations` endpoints keep working identically —
   their tests stay green.
7. `supply_source` / `customer_qty_per_unit` round-trip unchanged; add an assertion pinning that a
   component sent with `supply_source: 'factory'` is stored as `factory`, not `own`.

## Environment / rules

- Tests: `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='…'`
  (the default `php` is php-cgi → "Undefined constant STDOUT").
- **Scoped tests, not the full suite:** run your new test file + `pest Modules/Production`.
  Baseline is in `../baseline.md` — green means zero NEW failures.
- ⛔ **Pest loads every test file into ONE process.** Prefix every top-level helper function with
  its file's subject (e.g. `makeBomUpdateFixture()`), never a bare `makeBom()`. A duplicate
  top-level function = fatal redeclare, exit 255, **zero output**. This has happened 5 times here.
- `./vendor/bin/pint` on **touched files only**. `chown moonui2:moonui2` every file you edit.
- After BE edits: `bash local-deploy.sh`.
- ⛔ Never `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` (not binlogged).
- Commit on `hazemdev2`, conventional (`fix(production): …`). **Do not push, do not merge.**
  Unpushed commits from earlier work exist — leave them alone.
- moonui2 ONLY — never touch `/home/moonui`.

## Out of scope

WP2's permission work · any FE change · the column-layout WPs · making the production-order
material grid editable · repairing existing BOM data.
