# WP3 — the feature: partial conversion

**Flags:** no migration · **advisor gate** (lifecycle/status semantics + first validation on
a previously unvalidated write path). **Repo:** BE `/home/moonui2/moon-erp-be`, `hazemdev2`.
**Depends on:** WP1 (columns + `PartiallyConverted`), WP2 (the setting + its reader).

## Goal

Let a purchase request be converted into a purchase order **line by line and quantity by
quantity**, more than once, and let the request close itself when everything has been
ordered. With the setting off, the endpoint must behave byte-for-byte as it does today.

## What exists today (read this before writing anything)

`PurchaseOrderController::convertFromRequest()` —
`Modules/Purchases/app/Http/Controllers/PurchaseOrderController.php:502-575`.

Three things about it matter:

1. **It has no validation at all.** The signature takes a bare `Illuminate\Http\Request`
   (`:502`); `supplier_id`, `warehouse_id`, `expected_delivery_date` are read raw via
   `$request->input(...)` (`:527-529`). The supplier falls back to
   `$purchaseRequest->items->first()?->preferred_supplier_id` which can be **null** against
   the non-nullable `purchase_orders.supplier_id` FK — that is a raw database integrity
   error today, not a 422. Fix it while you are here.
2. **It copies every line at full quantity** in the loop at `:539-555`, writing no
   `purchase_request_item_id` (the column did not exist until WP1), no tax, no discount.
3. **Two gates already guard it** and must be preserved: `canConvert()` at `:508-512` → 422
   `pr_must_be_approved`, and `assertApprovedForPost($purchaseRequest, ApprovalDocumentType::PurchaseRequest)`
   at `:516` (from `Modules/Core/app/Support/DrivesApprovalWorkflow.php:145-165`). These must
   run on **every** conversion, not just the first.

## Exact changes

### 1. New `Modules/Purchases/app/Http/Requests/ConvertPurchaseRequestToOrderRequest.php`

The first validation this route has ever had.

- `supplier_id` — **required**, must exist and be company-scoped. Look at how other
  purchases FormRequests scope `exists:` to the company (there are examples in the module);
  a bare `exists:business_partners,id` leaks across tenants.
- `warehouse_id`, `expected_delivery_date` — nullable, validated for type/existence.
- `items` — `sometimes|array`. **Omitted or empty ⇒ convert everything** (today's behaviour).
- `items.*.purchase_request_item_id` — required with `items`, must belong to **this**
  request. Do not settle for `exists:purchase_request_items,id`; scope it to the request.
- `items.*.quantity` — required, numeric, `> 0`, and not more than that line's remaining
  quantity. Validating the cap here gives a clean 422; the authoritative check is still the
  in-transaction one below (see the race note).

### 2. `convertFromRequest()`

- Resolve `$partialAllowed` through WP2's reader. **When false, the `items` key must be
  rejected or ignored so the outcome is exactly today's** — decide which, and say so in a
  comment. (Recommendation: ignore it and convert everything, so an old client that starts
  sending `items` can never silently half-convert.)
- Build the lines from the selection. Each created `purchase_order_items` row now carries
  `purchase_request_item_id`.
- Increment `purchase_request_items.converted_quantity` by the converted amount.
- Stamp `purchase_requests.converted_at` (WP1 made this real).
- Recompute the request's status from its lines — see below.
- Keep `converted_to_order_id` pointing at the **most recent** order for backward
  compatibility (the resource exposes it at `PurchaseRequestResource.php:45`), and note in a
  comment that `purchaseOrders()` is now the real answer.

### 3. Status recomputation — put it on the model, not in the controller

Add a method to `PurchaseRequest` (mirroring `PurchaseOrder::recalculateReceiveStatus()` at
`Models/PurchaseOrder.php:199-222`) that sets:

- every line fully converted → `Converted`
- some quantity converted, some remaining → `PartiallyConverted`
- nothing converted → leave `Approved`

WP5 calls this same method after a cancellation, so it must be able to move a request
**backwards**. Write it as "derive the status from the lines", never as "advance the status".

### 4. The over-conversion race

Two concurrent conversions of the same request must not both pass the remaining-quantity
check. Inside the existing `DB::transaction`, lock the request's item rows
(`lockForUpdate()`) **before** reading `converted_quantity`, and re-verify the cap there.
The FormRequest check is a friendly early 422; this one is the truth. There is an existing
precedent for `lockForUpdate` on a purchase order in this module — find it and follow it.

### 5. Expose the remaining quantity

`Modules/Purchases/app/Http/Resources/PurchaseRequestItemResource.php` (and the request
resource that embeds items) must emit `converted_quantity` and a computed
`remaining_convert_qty`. WP6's dialog is built on these two fields; without them the UI has
no data source. Mirror the naming already used on purchase orders —
`remaining_receive_qty` / `remaining_bill_qty` in `PurchaseOrderItemResource`.

## Acceptance criteria

1. **Setting off ⇒ byte-for-byte today.** The two existing tests
   (`PurchaseOrderApiTest.php:575`, `:618`) pass untouched, and a request converted with the
   setting off still ends `Converted` with every line copied at full quantity.
2. A 20-line request, setting on: convert 10 lines → order has 10 lines, request is
   `PartiallyConverted`, the other 10 still show their full remaining quantity.
3. Convert the remaining 10 → request becomes `Converted` on its own.
4. Partial **quantity**: convert 500 of 1,200 on one line → that line reads
   `converted_quantity = 500`, `remaining_convert_qty = 700`, request `PartiallyConverted`.
5. Over-conversion is refused with 422 — both 1,300 of 1,200 in one call, and 700 after 500
   of 1,200 was already converted.
6. Both existing gates still fire on the **second** conversion: a request whose approval
   cycle has a pending log cannot be converted again.
7. `supplier_id` missing ⇒ **422, not a database error**.
8. A `purchase_request_item_id` belonging to a different request ⇒ 422.
9. Every created order line carries the right `purchase_request_item_id`.
10. `converted_at` is set.

## Tests

Extend `Modules/Purchases/tests/Feature/PurchaseOrderApiTest.php` **or** add
`Modules/Purchases/tests/Feature/PartialConversionTest.php` (preferred — keeps the new
surface together). Cover every numbered criterion above; criteria 1, 5 and 6 are the ones
that protect existing installs, so do not skimp there.

⛔ Prefix every top-level helper with `pconv…` — Pest loads all test files into one process;
a duplicate top-level function is a fatal redeclare (exit 255, zero output).

Run the touched files plus `Modules/Purchases` — no new failures versus the baseline in
`LEDGER.md`.

## Out of scope

- `PurchaseOrderController::store()`'s back door — that is **WP4**, deliberately separate.
- Anything about cancellation returning quantity — that is **WP5**.
- All frontend work — **WP6**.

## Finish

`./vendor/bin/pint` touched files only · `chown moonui2:moonui2` · `bash local-deploy.sh` ·
**one bilingual `[Unreleased]` CHANGELOG bullet** — this is the user-visible capability;
describe it as the buyer experiences it (a request can now be ordered in stages and stays
open until everything is ordered; off by default). Conventional commit on `hazemdev2`.
No push, no merge. Commit as soon as tests are green.
