# Purchase request → partial conversion to purchase orders — LEDGER

> **Source plan (owner-approved):** [`plans/purchase-request-partial-conversion.html`](../purchase-request-partial-conversion.html)
> Approved verbatim: «نفذ بكل توصياتك في التلاتة وابدأ التنفيذ» (2026-08-10).
> Repos: BE `/home/moonui2/moon-erp-be` · FE `/home/moonui2/public_html/moon-erp` · branch `hazemdev2`.
> Dev DB `moonui2_dev_be` — ⛔ never `migrate:fresh`/`refresh`/`db:wipe`.

## Owner's acceptance test

The owner opens a purchase request with 20 lines, converts 10 of them (and a partial
quantity on at least one) into a purchase order, and the request stays open showing what
is left. He converts the remainder into a second purchase order and the request closes by
itself. He then cancels one of those orders and sees its quantities become available to
convert again. With the new setting **off** — the default — the screen behaves exactly as
it does today.

## Decisions (owner-approved — do NOT re-litigate)

1. **Partial by QUANTITY, not just by line.** Converting 500 of 1,200 of one item is
   allowed; a whole line is the special case (all of it, or zero).
2. **Cancelling a purchase order returns its quantities to the request's available pool**
   and recomputes the request's status. The cancelled order stays visible in the request's
   history — only the quantity returns.
3. **Setting `purchases.allow_partial_request_conversion` defaults to `false`.** With it
   off, behaviour is byte-for-byte today's.

## Standing constraints

- moonui2 ONLY. Never touch `/home/moonui`.
- ⛔ NEVER `git checkout` / `restore` / `stash` on `src/assets/i18n/ar.json` or `en.json`
  (destroyed once that way). Additive edits only; every new key in BOTH files.
- Tests: `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest …`
  (bare `php` is php-cgi → "Undefined constant STDOUT").
- ⛔ Pest loads every test file into ONE process — prefix every top-level helper with its
  file's subject. A duplicate top-level function is a fatal redeclare: exit 255, zero output.
- `./vendor/bin/pint` on touched files only. `chown moonui2:moonui2` every file edited as root.
- `bash local-deploy.sh` after BE edits. Migrations run on dev in the SAME step as the code.
- Bilingual `[Unreleased]` CHANGELOG bullet per user-visible capability.
- **NO deploy, NO merge to `main`.** The owner runs `/fullpush`.

## Work packages

| WP | Scope | Repo | Depends | Review | Migration | Status | Commits / notes |
|----|-------|------|---------|--------|-----------|--------|-----------------|
| WP1 | Schema + status + relation: `purchase_request_items.converted_quantity` · `purchase_order_items.purchase_request_item_id` · index on `purchase_orders.purchase_request_id` · real `converted_at` column (+ `$fillable`) · `PurchaseRequestStatus::PartiallyConverted` · `PurchaseRequest::purchaseOrders(): HasMany`. | BE | — | advisor APPROVE · Codex blocked (sandbox) → direct read + code-review | **yes** | ✅ **done** 2026-08-10 | `c6cc57b47` · migration `2026_08_10_100000_add_partial_conversion_columns_to_purchases_tables`, applied to `moonui2_dev_be` and proven reversible by a rollback/re-apply round trip · tests **46 passed** (12 new + 34 `PurchaseOrderApiTest` untouched) + **33 passed** across `PurchaseRequestApiTest`/`ActionTest`/`EnabledGateTest` · pint pass |
| WP2 | Setting `purchases.allow_partial_request_conversion`: seeder definition + `UpdatePurchasesSettingsRequest` rule + ar/en lang keys + read via `getBool(..., default: false)` | BE | WP1 | Codex blocked (sandbox) → direct read | no | ✅ **done** 2026-08-10 | `e0618d5f9` · reader `ProcurementPolicy::allowPartialRequestConversion()` + key added to `PROCUREMENT_POLICY_GOVERNED_KEYS` (7→8) so the drift guard enforces single-reader from day one · new tests **7 passed** incl. the missing-definition-row case · full `Modules/Purchases` **11 failed / 485 passed = baseline exactly, same 11 names** |
| WP3 | The feature: new `ConvertPurchaseRequestToOrderRequest` (the route has **no validation at all** today) + `items[]` subset/quantities + over-convert guard with in-transaction row lock + status recompute + stamp `converted_at` | BE | WP2 | advisor APPROVE (2 checks) · Codex blocked (sandbox) → direct read of the lock + status derivation + a field-by-field diff of the rewritten `PurchaseOrder::create` block against its pre-image | no | ✅ **done** 2026-08-10 | `9c298a523` (amended from `5f4129e65` to carry the empty-plan guard) · **16/16** in `PartialConversionTest.php`, plus 34 `PurchaseOrderApiTest` + 3 `PurchaseRequestActionTest` + 4 `PurchaseTrailTest` green after WP4 (57 combined). ⚠️ The full-module confirmatory run was **started and then killed**: it was launched at 11:11 and WP4 committed at 11:26, so it was executing WP3-era tests against a part-WP4 tree and could not be trusted either way. **The module-wide baseline comparison is deferred to the Phase C sweep**, after the last BE package — do not record a number for WP3 that was never measured |
| WP4 | **Defect 1** — close the unguarded back door: `PurchaseOrderController::store()` burns the whole PR from `purchase_request_id` with no line correlation, no `assertApprovedForPost`, and a non-company-scoped `exists:` rule | BE | WP3 | advisor **APPROVE** · Codex blocked (sandbox) → direct read of both FormRequests + the deleted controller block | no | ✅ **done** 2026-08-10 | `0b04258bf` · **15/15** new + 34 `PurchaseOrderApiTest` + 3 `PurchaseRequestActionTest` + 4 `PurchaseTrailTest` + 16 `PartialConversionTest` = 57 green · shape **(a) — refuse the linkage**, decided on evidence: the FE declares `purchase_request_id?` in `CreatePurchaseOrder` (`purchase-order.model.ts:102`) but **never populates it** anywhere under `features/purchases/`, and no BE test posts it to `/api/purchases/orders` (every hit is a factory create or the convert route). Nothing uses the path ⇒ the brief's rule selects (a). Extended mechanically to `UpdatePurchaseOrderRequest:26`, which carries the same non-company-scoped rule and would otherwise let a caller re-link an existing order to another tenant's request |
| WP5 | **Defect 3** — return quantities when a purchase order is cancelled or deleted (`cancel()` / `destroy()` touch the request in no way today; with no FK, a deleted order leaves a dangling pointer on a request frozen at `Converted`) **+ the edit-after-convert seam found at WP4's gate** | BE | WP3 | advisor **APPROVE** · Codex blocked (sandbox) → direct read of the lock discipline, the clamp, the cap, and the `syncItems()` link carry | no | ✅ **done** 2026-08-10 | `a03d7a1ef` · **15/15** new (109 assertions) + 69 green across the four related files · edit seam closed with shape **(i)** |
| WP6 | FE: new convert dialog (copy of the create-GRN dialog) + converted/remaining columns + the new status in **5 places** + `items[]` in the service + the dead NgRx path + `PARTIALLY_CONVERTED` in ar/en | FE | WP3 | Codex blocked (sandbox) → direct read of the setting gate, both convert paths, both button conditions and the i18n files | no | ✅ **done** 2026-08-10 | `fa0c1bac4` · `tsc --noEmit` exit 0 · `ng build` green (only the repo's pre-existing CommonJS-bailout warnings) · i18n verified symmetric: **301 `PURCHASES` keys in each of ar/en, zero one-sided keys**, both files re-parsed · run in PARALLEL with WP4/WP5: different repo, no shared file, and the WP3 contract it consumes is frozen. WP4 (shape (a)) removes a payload key the FE never sent, so it cannot land FE fallout |

**Defect 2** (`converted_at` written to a column that never existed — silently discarded by
Eloquent at both call sites, and not in `$fillable`) is folded into WP1, since it is the
same migration.

## DAG / serialization

Strictly sequential: WP1 → WP2 → WP3 → {WP4, WP5} → WP6. WP4 and WP5 are logically
independent of each other but both write `PurchaseOrderController.php`, so they are
serialized — **one writer per repo at a time**, always. WP6 is the only FE package and must
follow WP3 because it consumes that endpoint's contract.

## Interfaces (the contract between packages)

- **WP1 → all:** `purchase_request_items.converted_quantity` decimal(15,3) default 0 ·
  `purchase_order_items.purchase_request_item_id` nullable FK → `purchase_request_items` ·
  `purchase_requests.converted_at` nullable timestamp · `PurchaseRequestStatus::PartiallyConverted = 'partially_converted'`.
- **WP2 → WP3/WP6:** `purchases.allow_partial_request_conversion` (boolean, company scope,
  default `false`), read **only** through `SettingsService::getBool($key, $companyId, false)`.
- **WP3 → WP6:** `POST /api/purchases/requests/{id}/convert-to-order` accepts
  `{ supplier_id, warehouse_id?, expected_delivery_date?, items?: [{ purchase_request_item_id, quantity }] }`.
  `items` omitted ⇒ today's convert-everything behaviour. Response 201 with the created order.
  The request resource must expose per-line `converted_quantity` and `remaining_convert_qty`.
- **WP5 → WP6:** after an order is cancelled the request's status may go back from
  `Converted`/`PartiallyConverted` to `PartiallyConverted`/`Approved`.

## Baseline (pre-existing failures — do not blame this work for them)

**Measured 2026-08-10 on `hazemdev2` before any code: `pest Modules/Purchases` →
11 failed, 466 passed (2,466 assertions), 847s.** Any run that ends with more than these
eleven has introduced a regression; these eleven are not this feature's fault.

| # | Test | Suspected cause |
|---|------|-----------------|
| 1 | `it requires supplier_id and branch_id when no bill_id` | purchase-payment validation |
| 2–9 | `PurchasesSettingApiTest` — list defaults · correct defaults · update · persist · grn_mode enum · approval_workflow enum · payment_terms range · per-company scope | the documented **double-seed** problem (8 tests) |
| 10–11 | `it blocks update of a GRN-linked receipt with receipt_locked_by_grn` · `…destroy…` | returns 403 where the test expects 422 |

⚠️ WP2 edits purchases settings, so failures 2–9 sit in exactly the file it touches. Re-read
this table before blaming — or absolving — yourself there.

> Side finding worth recording: rows 10–11 are the same failures seen during the
> product-deletion work earlier the same day, whose baseline comparison was stopped by the
> owner to save time. Their pre-existence is now measured, not assumed.

Existing conversion coverage is **two tests only** —
`Modules/Purchases/tests/Feature/PurchaseOrderApiTest.php:575` (happy path, **single-line
request** — so the multi-line copy loop has never been exercised) and `:618` (draft PR → 422).

## Deferrals

1. **A partially-converted request whose remainder is no longer wanted has no exit.**
   `canCancel()` is false for `PartiallyConverted` (WP1's deliberate decision — real purchase
   orders exist against it). After WP5 there IS a way out when the orders are merely
   unreceived: cancel them, the status derives back to `Approved`, and `Approved` is
   cancellable. **The genuinely stuck case is orders already received or billed + a remainder
   nobody wants** — that request stays `PartiallyConverted` forever with no close-remainder or
   void-remainder action. Not a regression (today's `Converted` is equally frozen) and out of
   the approved scope. **Surface to the owner at feature end** as: "how should a buyer close a
   request he has decided not to finish ordering?"
2. **Legacy dead records stay dead.** An order raised before WP1 carries a `purchase_request_id`
   on its header but NULL on every line, so there is no line-level attribution to hand back.
   Cancelling one repoints `converted_to_order_id` (pointer hygiene) but returns no quantity and
   deliberately does **not** recompute the status — see the WP5 log entry for why recomputing
   would actively corrupt them. Pre-existing requests frozen at `Converted` therefore stay
   frozen. Fixable only by a backfill migration that reconstructs the links by matching order
   lines to request lines; not in the approved scope. (WP5)
3. **Same-product identity matching can give a false 422 on an edit.** Two request lines of the
   SAME product/variant/unit converted onto one order, then edited to keep only the larger, pop
   the first queued link and can trip the cap. **Fails in the safe direction** — nothing is
   corrupted and cancel-and-reconvert works — but the message will read as wrong to the buyer.
   Inherent to identity-based matching; the real fix is a `purchase_request_item_id` on the edit
   payload, which is an API change. (WP5)
4. **`converted_at` stays stamped after a full reversal.** Cancel every order and the request
   correctly derives back to `Approved`, but still carries the timestamp of a conversion that no
   longer exists. Cosmetic — the status is what gates behaviour — and the brief did not ask for
   it. (WP5)
5. **`php artisan scribe:generate` was NOT run.** The generated API docs may still advertise
   `purchase_request_id` as an accepted body param on `POST /purchases/orders`, which WP4 now
   refuses. A regen touches many files — owner-side call. (WP4)
6. **The purchase-request status filter's labels are hardcoded Arabic, not i18n keys.** WP6
   followed that local convention rather than half-migrating the list. Separate task. (WP6)

## Constraints discovered mid-flight

- **WP3 without WP4 is not shippable.** Once WP3 lands, `PurchaseOrderController::store()`'s
  back door still calls `canConvert()` — which WP1 widened to include `PartiallyConverted` —
  and hard-writes `Converted`. So between WP3 and WP4 a caller can burn a partially-converted
  request to fully converted through the unguarded path. Irrelevant if the feature ships
  whole; it matters because this owner has interrupted with `/fullpush` mid-work. If work
  stops between those two packages, say so explicitly.
- **`index()` + `constrained()` in one blueprint yields a DUPLICATE index on MySQL.** Laravel
  appends the `foreign` command the instant `constrained()` is called, so the compiled SQL adds
  the constraint *before* the index; InnoDB finds no usable index at that moment and creates its
  own. Split into column → index → FK as three separate `Schema::table` statements and InnoDB
  adopts the existing one. WP1's migration does this — copy it, don't re-derive it.
- **MySQL auto-creates a backing index for every FK; SQLite does not.** So an index on an FK
  column must be conditional, or every client database carries a permanent duplicate B-tree.
- **`local-deploy.sh` does NOT re-seed setting definitions when the package has no migration.**
  It skips straight past the seeding step, so a new `setting_definitions` row never reaches
  `moonui2_dev_be` and the setting is invisible on dev while the tests pass happily on sqlite.
  Seed it explicitly:
  `php artisan db:seed --class="Modules\Core\Database\Seeders\SettingDefinitionSeeder" --force`.
  WP2 hit this; every later migration-less package will too.

## Phase C — final sweep (2026-08-10)

- **Whole-feature diff:** `git diff c6cc57b47^..HEAD -- Modules/Purchases docs/moonstack/CHANGELOG.md`
  = 22 files, +2,834 / −51. Cross-WP seams hunted specifically (the per-WP gates already covered
  each diff on its own):
  - **one writer for the status** — `grep PurchaseRequestStatus::` over the controller and model
    shows the only conversion-status write is inside `recalculateConversionStatus()`
    (`PurchaseRequest.php:170-172`). No literal anywhere in `PurchaseOrderController`.
  - **two writers for `converted_quantity`** — the `+` at `PurchaseOrderController.php:673`
    (convert) and the signed delta at `:942` (cancel/delete/edit). Both under the same lock. A
    module-wide grep finds no third writer.
  - `PurchaseRequestStatus`'s docblocks were re-read end to end: `isEditable()` and `canSubmit()`
    deliberately exclude `PartiallyConverted`, `canConvert()` is the single method it widens, and
    `canCancel()` documents *why* it stays excluded — the enum anticipates Deferral 1 in prose.
  - WP4's CHANGELOG promise ("orders already linked stay linked and stay editable as before") was
    checked against WP5: it is now true, so **no bullet correction is owed**.
- **Full suite:** `pest Modules/Purchases` → **11 failed / 531 passed** (2,794 assertions, 926s).
  Baseline was 11 / 466, and the feature added 12+7+16+15+15 = **65** tests: 466 + 65 = 531
  exactly, failures unchanged. The failure **set** was then verified by name rather than inferred
  from the total — the three baseline files were re-run on their own: `PurchasesSettingApiTest`
  8 + `StandaloneReceiptGateTest` 2 = 10, `PurchaseReturnApiTest` 1 = **11, identical names**.
  Zero regressions.
- **FE:** `npx tsc --noEmit -p tsconfig.app.json` clean · `npx ng build --base-href /app/` green
  (only the repo's pre-existing CommonJS-bailout warnings).
- **Deployed to `/app`** per the standing owner authorization (owner tests on live `/app`; this is
  explicitly not a client ship and not a `main` merge): stale chunks cleared, bundle
  `main-EKFPBBOZ.js`, `chown -R moonui2:moonui2`, and `app/assets/config.json` confirmed still
  pointing at the moonui2 dev `apiUrl`. The dev BE already ran WP1–WP5 live via `local-deploy.sh`,
  so leaving `/app` on the old FE would have been the inconsistent state.
- **Not done, by rule:** no push, no merge to `main`. The owner runs `/fullpush`.

## Log

- **2026-08-10** — Phase A done: LEDGER + 6 briefs + measured baseline (11 failed / 466 passed).
- **2026-08-10** — WP2 ✅ `e0618d5f9`. Full-module confirmatory run: 11 failed / 485 passed —
  identical failure set to the baseline, itemised per file. Two carry-forwards recorded above.
- **2026-08-10** — WP5 ✅ `a03d7a1ef`. Advisor APPROVE. The way back down.
  **Lock discipline is the thing to preserve here if anyone ever touches this file again:** the
  order row FIRST, then **every** line of the request ordered by `sort_order` — the same set
  `convertFromRequest()` takes on the way up. One lock set taken one way ⇒ acyclic ⇒ convert /
  cancel / edit / delete queue instead of deadlocking. `cancel()`, `update()` and `destroy()`
  each gained a transaction they did not have. Idempotency is that lock, **not a flag**:
  `canCancel()` / `isDraft()` / `isEditable()` are read off the route-bound model with nothing
  held, so each path re-reads the order under `lockForUpdate()` and re-checks; the loser of a
  race gets the same 422 an ordinary double-cancel gets, having changed nothing.
  `cancel()` writes the status BEFORE returning the quantities, so the `converted_to_order_id`
  repoint already sees this order as cancelled and cannot re-point at the order being killed.
  Clamp-and-`Log::warning` rather than throw on a would-be negative: a throw makes the
  cancellation un-completable and strands the order in a status its own documents contradict.
  The status recompute runs **only when a quantity actually moved** — that guard is what stops a
  pre-WP1 order (request link on the header, NULL on every line) from demoting a legitimately
  `Converted` legacy request to `Approved` and re-opening lines a sibling legacy order still
  covers. Pointer hygiene runs unconditionally; the two are deliberately separate.
  **Edit seam closed with shape (i)** — carry the link through and re-derive. `syncItems()`
  re-matches each new payload line to a line being replaced on product + variant + unit (the
  identity conversion copied off the request line) through a pop-once queue, and the link is
  written **after** the `...$item` spread so a caller can never supply one — WP4's rule that only
  conversion may create a link still holds. The books are then corrected by the DIFFERENCE the
  order makes, under the same lock, capped at the request line's quantity (422 + full rollback),
  so an edit can never book what the conversion endpoint would refuse. A payload line with no
  counterpart gets no link: an independent purchase, and the request keeps that quantity as
  outstanding — the safe direction. The whole path is skipped when `purchase_request_id` is null,
  i.e. for every order `store()` creates.
  Because of this, WP4's promise that "orders already linked to a request stay linked and stay
  editable as before" is now TRUE — **no CHANGELOG correction is owed.** WP5's own bilingual
  bullet read and verified whole. Negative-checked, not merely green: removing the clamp fails
  criterion 5, removing the link carry fails 5 tests.
- **2026-08-10** — WP6 ✅ `fa0c1bac4`. The screen. Gate = acceptance criterion 1 (setting OFF ⇒
  unchanged) read directly in the code, not taken on report: `partialConversionEnabled` is a
  signal defaulting to **false** and set to false on any read error, so it is false before the
  async `getByKey` resolves AND on a failed read. With it false, `openConvertDialog()` returns
  before the `getById` — **no extra round-trip at all** — the width stays `450px`, the table and
  hint are skipped, the old button label is used, and `confirmConvertToOrder()` builds
  `{ supplier_id }` with no `items` key. The view dialog's two new columns sit inside the same
  `@if`, with the tfoot colspan switching 5↔7. 422s from the new server-side refusals go through
  the screen's shared `showError(err)`, so the backend's messages actually reach the user.
  Three implementer deviations, all accepted:
  1. **Five columns, not the brief's three.** The brief describes the GRN pattern generically;
     the owner-APPROVED mockup has # / الصنف / الوحدة / المتبقي / الكمية المحوَّلة plus the
     zeroing hint and a line count on the submit button. The approved design governs.
  2. **Cancel button now EXCLUDES `partially_converted`** (`html:113` gained a third clause).
     The brief said to touch the line but not which way. Resolved from the backend rather than
     guessed: `PurchaseRequestStatus::canCancel()` deliberately excludes `PartiallyConverted`
     ("not cancellable once real orders exist against it") and the controller 422s on
     `! canCancel()`, so leaving the button would have offered an action the API always refuses.
     Consistent with Deferral 1: after WP5, cancelling the orders derives the request back to
     `Approved`, which IS cancellable — so the exit exists again by the intended route.
  3. **Immutable quantity write-back instead of the GRN dialog's in-place mutation.** The GRN
     pattern mutates `grnItems()[i].quantity`, which never notifies the signal; the live
     selected-line count needed for the button label and disabled state forced a
     `setConvertQty()` + `computed`. Same interaction, actually reactive.
  Also added beyond the brief, and right: when every line is zeroed the submit is refused
  client-side, because an empty `items` array means "convert everything" to the backend — the
  exact opposite of what a buyer who zeroed every line asked for.
  **Tech debt recorded** (do not fix here): the status filter list in `requests.component.ts`
  uses hardcoded Arabic labels rather than i18n keys; the new entry follows that local
  convention rather than half-migrating the list. Migrating it is a separate task.
- **2026-08-10** — WP4 ✅ `0b04258bf`. Advisor APPROVE. Shape **(a)**, chosen on evidence, not
  preference: the implementer re-verified my two lines and added a third —
  `grep -rn purchase_request_id Modules app database --include=*.php` returns **zero** hits
  outside `Modules/Purchases`, so no seeder, action or sibling module builds a store payload
  with it. `store()`: the key is `prohibited` (absent/null/`""` pass — Laravel's
  `ConvertEmptyStringsToNull` and `prohibited`'s empty-check see to that — while `0` and any
  real id are refused), and the controller's conversion block is **deleted**, not merely
  starved, so no second copy of the status logic survives. `update()`: the link is **immutable**
  — absent passes, present-and-equal passes (int-cast, so `"5"` and `5` both do), anything else
  422s, including an explicit null unlink (which would orphan the `converted_quantity` already
  booked) and attaching a request to a previously unlinked order. `exists:` was dropped there on
  purpose: the equality check subsumes it, since the only passing value is one the convert route
  already wrote. Route param confirmed `order` (`apiResource('orders', …)`), so the check really
  does read the bound model — if it had been misnamed, every converted order would have become
  uneditable. Criterion 5 verified **empty, not skipped**: `PurchaseOrderApiTest:597/611` is the
  convert route, not the permissive store path, so no existing test needed updating and nothing
  was deleted. Bilingual `!`-breaking CHANGELOG bullet read and verified whole.
  Deferred by the implementer, deliberately: `php artisan scribe:generate` was NOT run, so the
  generated API docs may still advertise `purchase_request_id` on `POST /purchases/orders`. A
  regen touches many files — owner-side call.
- **2026-08-10** — WP3 ✅ `9c298a523`. The feature itself. Review gate found **one real defect**
  the 14 implementer tests did not cover: `planRequestConversion()` had **no empty-plan guard**,
  so a plan that filtered down to nothing fell through to `PurchaseOrder::create(...)` and minted
  a **lineless draft order with a burned sequence number** — the empty-document family of bugs
  already seen on the إذن إضافة side. Reachable two ways: the loser of a race between two
  whole-request conversions (it passed `canConvert()` on the stale route-bound model, then woke
  from the lock with zero remaining), and a request carrying no lines at all. Fixed in-package
  as a 422 (`pr_convert_nothing_remaining`, ar+en) thrown **before** the sequence is drawn, plus
  two tests → 16.
  Field-by-field diff of the rewritten `PurchaseOrder::create` block against its pre-image:
  every header field is identical except (a) `supplier_id` is now required instead of falling
  back to `items->first()?->preferred_supplier_id` — the contracted 500→422 fix, in the commit
  message; (b) `expected_delivery_date` moved from `input(..., $needed_by)` to
  `validated(...) ?? $needed_by`, which differs **only** when a client sends an explicit null
  (old: null, new: `needed_by`) — accepted; (c) `currency_code => 'KWD'` is **hardcoded and was
  hardcoded before** — a preserved wart, not introduced here. Worth its own ticket: an order
  raised from a request is always stamped KWD regardless of the supplier's currency.
  ✅ **That ticket was opened and closed the same day** — the owner hit it independently
  («العملة في المشتريات مش مظبوطة»). Fixed in `f6e67cb34`; the wart is gone and the order now
  takes the company's currency. Scope + evidence: [`company-currency-single-source.md`](../company-currency-single-source.md).
  Implementer deviations, all accepted: cross-company now 403s from the FormRequest's
  `authorize()` (no test pinned the old error ordering); the race test container-binds a
  `withValidator`-noop FormRequest subclass to prove the post-lock check is the one that holds.
  Their third deviation — "a zero-line request ends `Converted` via a vacuous `every()`" — is
  **superseded** by the empty-plan guard: such a request now 422s and stays `Approved`.
  Acceptance criteria: all 10 mapped to named tests (#6 → «a pending approval log blocks the
  second conversion too», #8 → «a line belonging to a different request is refused»).
  CHANGELOG bullet verified present and complete on both halves of `{{ar}}`.
- **2026-08-10** — WP1 ✅ `c6cc57b47`. Advisor APPROVE. Codex review **could not run** — the
  host's bwrap sandbox refuses to enter the BE repo (`Can't access /newroot/proc/sysrq-trigger`),
  a limit already recorded in the KB for the controlled-flow work; substituted with a direct
  read of the full migration by the orchestrator plus the advisor gate. On the advisor's prompt,
  the three `PurchaseRequest*` test files nobody had run were run: 33 passed.
  Note on wording: the commit says "zero behaviour change", but `converted_at` now genuinely
  persists at both existing call sites — that is the contracted defect-2 fix, not a violation.
