# WP5 — cancelling an order returns its quantities (defect 3)

**Flags:** no migration · **advisor gate** (lifecycle semantics; reversal correctness).
**Repo:** BE `/home/moonui2/moon-erp-be`, `hazemdev2`. **Depends on:** WP3.

## Goal

Owner decision, already approved: **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 comes back.

## The defect, precisely

Neither exit touches the purchase request in any way:

- `PurchaseOrderController::cancel()` — `…/PurchaseOrderController.php:447-477`. Checks
  `canCancel()`, refuses if any line has `received_quantity > 0` or `billed_quantity > 0`
  (`:458-465`), then writes status/cancelled_by/cancelled_at/reason. The request is not even
  referenced in the method.
- `PurchaseOrderController::destroy()` — `:253-266`. Draft-only, plain soft `delete()`. Also
  never touches the request.

And because `purchase_requests.converted_to_order_id` has **no foreign key**
(migration `2026_02_25_100001…:31`), deleting the order leaves a **dangling pointer** on a
request frozen at `Converted` — which `canCancel()` and `isEditable()` both refuse to
unstick. The request becomes a dead record with no way out.

Today that is merely untidy. With partial conversion it is acute: cancel the first order and
the quantities it held would be **permanently unorderable**.

## Exact changes

1. In both `cancel()` and `destroy()`, inside the existing transaction (add one if there
   isn't), walk the order's items and for each with a `purchase_request_item_id`, decrement
   that request line's `converted_quantity` by the order line's quantity.
2. Then call WP3's **status recompute** method on the request — the one that derives status
   from the lines. Do not write a status literal here. The request may legitimately move
   `Converted → PartiallyConverted` or `PartiallyConverted → Approved`.
3. Clear or repoint `converted_to_order_id` if it pointed at the cancelled order — leaving a
   pointer to a cancelled/deleted order is what creates the dead record. Decide and document:
   repoint to the most recent surviving order, or null it when none remains.
4. **Idempotency.** Cancelling an already-cancelled order must not decrement twice. Guard on
   the order's current status before reversing, and lock the request's item rows
   (`lockForUpdate()`) while adjusting, exactly as WP3 does on the way up.
5. **Never let `converted_quantity` go negative.** If the arithmetic would, that is a bug
   worth surfacing loudly rather than clamping silently — but do not leave a negative in the
   database. Decide (clamp at zero + log, or throw) and say why in a comment.

## Acceptance criteria

1. Convert 500 of 1,200 → cancel that order → the line reads `converted_quantity = 0`,
   `remaining_convert_qty = 1,200`, and the request is back to `Approved`.
2. Convert 10 of 20 lines in order A and 10 in order B → cancel A → request is
   `PartiallyConverted`, A's 10 lines are available again, B's are not.
3. Cancel the same order twice → the second attempt changes nothing (no double decrement).
4. Deleting a draft order created from a request has the same reversal effect.
5. `converted_quantity` is never negative under any sequence.
6. The cancelled order still appears when listing the request's orders
   (`PurchaseRequest::purchaseOrders()`), and the request is no longer a dead record — it can
   be converted again.
7. An order with `received_quantity > 0` still cannot be cancelled at all (the existing guard
   at `:458-465` is untouched).
8. An order **not** created from a request cancels exactly as before.

## Tests

New file `Modules/Purchases/tests/Feature/PurchaseOrderCancelReturnsQuantityTest.php`
covering all eight criteria — criterion 3 (double-cancel) and 5 (never negative) are the
ones that catch a naive implementation.

⛔ Prefix every top-level helper with `pocrq…` (Pest one-process redeclare trap).

## Out of scope

Anything about the conversion endpoint itself (WP3), the `store()` back door (WP4), the UI
(WP6). Do not change the received/billed cancellation guard.

## Finish

`pint` touched files · `chown moonui2:moonui2` · `local-deploy.sh` · **one bilingual
`[Unreleased]` CHANGELOG bullet** — this is user-visible: cancelling a purchase order now
frees its quantities so they can be ordered again, and a request no longer gets stuck.
Conventional commit on `hazemdev2` · no push, no merge.
