# RESUME — purchase request → partial conversion to purchase orders

> **Status: ✅ COMPLETE. All 6 work packages shipped to `hazemdev2` and deployed to `/app`.**
> Awaiting the owner's `/fullpush` (sync + merge to `main`) and his browser test.
> Updated 2026-08-10.

## What was built, in the owner's terms

A purchase request with 20 lines can now be turned into a purchase order using only some of
those lines — and only part of a line's quantity. The request stays open, showing what is still
unordered, until everything has been ordered, at which point it closes itself. Cancelling or
deleting one of those orders puts its quantities back on the request. All of it sits behind a
company setting that is **off by default**, so installs that don't want it see no change at all.

**The owner's acceptance test:** open a purchase request with 20 lines, convert 10 of them (and
a partial quantity on at least one) into a purchase order; the request stays open showing what
is left. Convert the remainder into a second order and the request closes by itself. Cancel one
of those orders and its quantities become available to convert again. With the setting **off**
— the default — the screen behaves exactly as it does today.

**Where to turn it on:** Purchases settings → **الميزات** → «السماح بالتحويل الجزئي لطلبات الشراء»
(`purchases.allow_partial_request_conversion`).

## Owner-approved decisions (settled — not open)

1. Partial by **quantity**, not just by line.
2. Cancelling an order **returns its quantities**; the cancelled order stays in the history.
3. Setting **defaults off**; with it off, behaviour is byte-for-byte today's.

## What shipped, per package

| WP | What | Commit | Tests |
|----|------|--------|-------|
| WP1 | schema + `PartiallyConverted` status + `purchaseOrders()` relation (+ the dead `converted_at` fixed) | BE `c6cc57b47` | 12 new |
| WP2 | the setting and its single reader (`ProcurementPolicy`) | BE `e0618d5f9` | 7 new |
| WP3 | the feature: new FormRequest, `items[]` selection, locked over-convert guard, status derivation | BE `9c298a523` | 16 new |
| WP4 | closed the unguarded `store()` back door (**breaking**) | BE `0b04258bf` | 15 new |
| WP5 | cancel/delete return quantities + the edit-after-convert seam | BE `a03d7a1ef` | 15 new |
| WP6 | the screen | FE `fa0c1bac4` | build + tsc |

**Final measurement:** `pest Modules/Purchases` = **11 failed / 531 passed** (2,794 assertions,
926s). Baseline before any code was **11 failed / 466 passed** — so passed grew by exactly the
65 tests this feature added, and the failure set is the **same 11 by name**, verified
individually: 8 `PurchasesSettingApiTest` (the double-seed problem), 2 `StandaloneReceiptGateTest`
(403-vs-422), 1 `PurchaseReturnApiTest`. **Zero regressions.**
FE: `npx tsc --noEmit` clean, `npx ng build` green, deployed to `/app` as `main-EKFPBBOZ.js`.

## Three live defects this work also fixed

All three were found while researching and confirmed in code; none was known before.

1. **An unguarded second conversion path.** `POST /purchases/orders` accepted a
   `purchase_request_id` and burned the whole request — no line correlation, no approval check,
   and an `exists:` rule that was not company-scoped, so another tenant's request matched. (WP4)
2. **`converted_at` never existed.** Both call sites wrote it; the column was in no migration and
   the key was not fillable, so Eloquent discarded it silently. There was no conversion timestamp
   anywhere in the system. (WP1)
3. **Cancelling an order stranded its request.** Neither `cancel()` nor `destroy()` touched the
   request, and with no foreign key a deleted order left a dangling pointer on a request frozen
   at `Converted` that could be neither cancelled nor edited — a dead record. (WP5)

Plus two more found at the review gates, which no brief had predicted:

4. **The empty conversion.** A conversion plan that filtered down to nothing fell through to
   `PurchaseOrder::create()` and minted a **lineless draft order with a burned sequence number**.
   Reachable by the loser of a race between two whole-request conversions, and by a request with
   no lines. Now a 422 thrown before the sequence is drawn. (found at WP3's gate)
5. **The edit-after-convert seam.** `update()` → `syncItems()` deletes and recreates every order
   line from a payload that never carried a `purchase_request_item_id`, so an ordinary edit wiped
   every back-link while `converted_quantity` stayed booked — after which the cancel reversal
   would have returned **zero**, burning those quantities out of the request permanently. The
   same edit could raise a line 500 → 5,000, since the cap only existed at convert time. (found
   at WP4's gate)

## Invariants to preserve if you touch this again

- **One writer for the status.** `PurchaseRequest::recalculateConversionStatus()` is the only
  place a conversion status is written. It DERIVES from the lines and is deliberately narrow —
  it only rewrites a status already in {Approved, PartiallyConverted, Converted}, so it can never
  resurrect a Cancelled, Rejected or Draft request. Never write a status literal beside it.
- **Two writers for `converted_quantity`**, both under the same lock: the `+` in
  `convertFromRequest()` and the signed delta in `applyRequestQuantityDeltas()`.
- **Lock order is one-directional: the order row first, then EVERY line of the request ordered by
  `sort_order`.** Convert takes only the request-item set; cancel / edit / destroy take both, in
  that order. That is what makes the graph acyclic — they queue instead of deadlocking.
- **Recompute the status only if you changed a quantity.** Pre-WP1 orders carry a request link on
  the header but NULL on every line; recomputing on them would demote a legitimately `Converted`
  legacy request to `Approved` and re-open lines a sibling legacy order still covers.
- **Only conversion may create a back-link.** In `syncItems()` the link is written *after* the
  `...$item` spread precisely so a caller can never supply one.
- **`ProcurementPolicy` is the sole reader** of `purchases.allow_partial_request_conversion`, and
  a drift-guard test fails the build if anything else touches the key.

## Open questions for the owner (surface these at handoff)

1. **How should a buyer close a request he has decided not to finish ordering?** A partially
   converted request whose orders are already received or billed, and whose remainder nobody
   wants, has no exit — it stays `PartiallyConverted` forever. Not a regression (today's
   `Converted` is equally frozen) and out of the approved scope. When the orders are merely
   unreceived there IS now a way out: cancel them, the status derives back to `Approved`, and
   `Approved` is cancellable.
2. **Regenerate the API docs?** `php artisan scribe:generate` was not run, so the published docs
   may still advertise `purchase_request_id` on `POST /purchases/orders`, which WP4 now refuses.
3. **Legacy requests stay stuck.** Orders raised before WP1 have no line-level attribution, so
   cancelling one returns nothing. Un-sticking them needs a backfill migration that reconstructs
   the links — out of the approved scope.
4. **A same-product edit can give a false 422** (two request lines of the same product/variant/
   unit converted onto one order, then edited down). Fails in the safe direction — nothing is
   corrupted and cancel-and-reconvert works — but the message will read as wrong. The real fix is
   a `purchase_request_item_id` on the edit payload, which is an API change.
5. **`converted_at` stays stamped** after every order is cancelled and the request returns to
   `Approved`. Cosmetic; the status is what gates behaviour.

Full detail, per-package review verdicts and the deviation record are in
[`LEDGER.md`](LEDGER.md). Source plan:
[`../purchase-request-partial-conversion.html`](../purchase-request-partial-conversion.html).

## Next

Nothing is pushed and nothing is merged. **The owner runs `/fullpush`** for sync + merge to
`main`; the code is live on the moonui2 dev BE and on `/app` for his browser test now.
