# Purchases Controlled Flow — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL — use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Design rationale lives in the HTML explainer `knowledge-base/plans/purchases-controlled-flow-redesign.html`; do not re-derive it.

**Goal:** Turn Moon ERP's purchasing cycle into the owner's controlled flow (PR → PO → one-active GRN → quality sets qty+expiry+batch → locked approve → warehouse-keeper-approved stock-addition → bill-by-received with variance settlement → one traceable bill), activatable by ONE setting, without breaking the current default behavior.

**Architecture:** Additive + opt-in. A new single setting `purchases.procurement_mode = simple|controlled` (default `simple` = byte-for-byte today) is resolved by a new `ProcurementPolicy` service that becomes the ONLY reader of the governed setting keys. `simple` passes through; `controlled` returns a hard-coded preset (grn_quality + receipt-recognition + the 3 quantity guards + new one-active-cycle guards) as BEHAVIOR, minting no new setting rows. Phases are independently shippable.

**Tech Stack:** Laravel 12, nwidart modules (`Modules/Purchases`, `Modules/Inventory`, `Modules/Core`), Pest 3 / PHPUnit 11 (SQLite `:memory:`, `RefreshDatabase`), Spatie permissions, existing `SettingsService` + `SequenceService` + GR/IR machinery (`PostGrnGrniAccrual`, `PostPurchaseBill`).

## Global Constraints

- **Default install is untouched.** `procurement_mode` defaults to `simple`; every new guard/behavior is inert unless `controlled` (or the pre-existing individual toggle) is on. A regression test must assert simple-mode is byte-identical.
- **On `hazemdev2` working tree.** Do NOT push, deploy to `/app`, or merge to `main` without the owner. Run `bash local-deploy.sh` after BE edits; `chown moonui2:moonui2` any file edited as root; format with `./vendor/bin/pint` before done.
- **Tests are the gate.** Every task ends with green Pest tests. Run `php vendor/bin/pest <file>` with the CLI php. Baseline pre-existing failures = 8 in `PurchasesSettingApiTest` (double-seed) + 1 `PurchaseReturnApiTest` — do NOT add new failures.
- **`QTY_EPSILON = 0.0005`** is the established float tolerance; reuse it, don't invent another.
- **Bilingual.** Every user-facing message key gets `en` + `ar` (`Modules/Purchases/lang/{en,ar}/purchases.php`).
- **MoonStack fleet:** any change to the `controlled` preset definition is a behavior change shipped by code update → add a `docs/moonstack/CHANGELOG.md` bullet (EN + `{{ar}}`) per phase.

---

## Phase 0 — Quick wins (bug fixes + quality fields + permission split)

*Independently shippable. Fixes the empty-receipt data bug, lets quality own batch/expiry, and separates the QC permission. No dependency on the `procurement_mode` switch. Fully code-specified below.*

### Task 0.1: Fix BUG A — mode-aware GRN approval + empty-receipt circuit breaker

**Files:**
- Modify: `Modules/Purchases/app/Enums/PurchaseGrnStatus.php:29-32` (`canApprove` signature)
- Modify: `Modules/Purchases/app/Models/PurchaseGrn.php:92-95` (delegate signature)
- Modify: `Modules/Purchases/app/Http/Controllers/PurchaseGrnController.php:363` (pass qualityRequired) and `:447-476` (circuit breaker)
- Modify: `Modules/Purchases/lang/en/purchases.php` + `Modules/Purchases/lang/ar/purchases.php` (1 new key `grn_no_receivable_quantity`)
- Test: `Modules/Purchases/tests/Feature/GrnApprovalGuardTest.php` (new)

**Interfaces:**
- Produces: `PurchaseGrnStatus::canApprove(bool $qualityRequired): bool` and `PurchaseGrn::canApprove(bool $qualityRequired): bool`.

- [ ] **Step 1 — Write the failing test.** Create `GrnApprovalGuardTest.php` with `uses(RefreshDatabase::class)`. Model the helpers on `Modules/Purchases/tests/Feature/GrniRecognitionTest.php` (company, warehouse, supplier, product with `track_inventory`, an approved PO with one item, and a GRN with one item linked to the PO item). Four cases:
  - `test('quality mode blocks approving a Draft GRN')`: set `purchases.grn_mode=grn_quality`; create a Draft GRN (no quality run); `POST grns/{id}/approve` → assert **422** and no `InventoryReceipt` row created.
  - `test('quality mode approves QualityApproved GRN and receipt is not empty')`: run submit-quality + quality-check (accepted_quantity = full) → status QualityApproved; approve → **200**, the linked `InventoryReceipt` has `items()->count() === 1`, stock increased.
  - `test('approve aborts when all accepted quantities are zero')`: quality-check with `accepted_quantity=0` for every line (approved=true) → approve → **422** with key `grn_no_receivable_quantity`; assert NO `InventoryReceipt` persisted (transaction rolled back).
  - `test('plain grn mode still allows Draft approve')`: `purchases.grn_mode=grn`; Draft GRN with `quantity>0`; approve → 200, receipt has items.

- [ ] **Step 2 — Run it, watch it fail.** `php vendor/bin/pest Modules/Purchases/tests/Feature/GrnApprovalGuardTest.php` → Expected: FAIL (Draft approve currently succeeds and yields an empty receipt).

- [ ] **Step 3 — Change the enum signature.** In `PurchaseGrnStatus.php`:
```php
public function canApprove(bool $qualityRequired): bool
{
    return $qualityRequired
        ? $this === self::QualityApproved
        : in_array($this, [self::Draft, self::QualityApproved]);
}
```
`QualityRejected` stays non-approvable by design (a fully-rejected GRN is cancelled via the existing `canCancel()`).

- [ ] **Step 4 — Update the model delegate.** In `PurchaseGrn.php`:
```php
public function canApprove(bool $qualityRequired): bool
{
    return $this->status->canApprove($qualityRequired);
}
```

- [ ] **Step 5 — Update the controller call + add the circuit breaker.** In `PurchaseGrnController::approve()`:
  - Compute the mode BEFORE the guard, and pass it: replace `if (! $grn->canApprove())` (line 363) with
```php
$qualityRequired = $this->getGrnMode() === GrnMode::GrnQuality;
if (! $grn->canApprove($qualityRequired)) {
```
  - In the receipt-item loop (`:447-469`), keep the per-line `if ($stockQty <= 0) continue;`, then AFTER the loop and `recalculateTotals()`, add the breaker before approving the receipt:
```php
if ($inventoryReceipt->items()->count() === 0) {
    throw ValidationException::withMessages([
        'items' => __('purchases::purchases.messages.grn_no_receivable_quantity'),
    ]);
}
app(ApproveReceipt::class)->execute($inventoryReceipt, $userId);
```
  (Replaces the existing `if ($inventoryReceipt->items()->count() > 0)` conditional at `:474`.) Because this runs inside the `DB::transaction`, the throw rolls back the empty receipt. Add `use Illuminate\Validation\ValidationException;` if absent.

- [ ] **Step 6 — Add the lang key.** `grn_no_receivable_quantity` → EN: `"No receivable quantity — quality rejected all lines or accepted quantities are zero."` AR: `"لا توجد كمية قابلة للاستلام — الجودة رفضت كل الأصناف أو الكميات المقبولة صفر."`

- [ ] **Step 7 — Run tests green.** `php vendor/bin/pest Modules/Purchases/tests/Feature/GrnApprovalGuardTest.php` → PASS. Then run `GrniRecognitionTest`, `GrnModeResolutionTest`, `PurchaseMatchGuardsTest` together → still green (no regression from the signature change).

- [ ] **Step 8 — Pint + chown + commit.** `./vendor/bin/pint` the touched files; `chown moonui2:moonui2` them; `bash local-deploy.sh`; commit `fix(purchases): mode-aware GRN approval + empty-receipt circuit breaker`.

### Task 0.2: Quality check captures batch number + expiry date

**Files:**
- Modify: `Modules/Purchases/app/Http/Controllers/PurchaseGrnController.php:294-302` (validation) + `:324-330` (update)
- Test: `Modules/Purchases/tests/Feature/GrnQualityBatchExpiryTest.php` (new)

**Interfaces:**
- Consumes: existing `purchase_grn_items.batch_number` (string, nullable) + `expiry_date` (date, nullable) columns.

- [ ] **Step 1 — Failing test.** New file, `uses(RefreshDatabase::class)`, quality-mode GRN in PendingQuality. `POST grns/{id}/quality-check` with `items[0]` carrying `accepted_quantity`, `batch_number => 'B-2026-07'`, `expiry_date => '2027-01-01'`. Assert the `purchase_grn_items` row now has those values; then approve and assert the created `inventory_receipt_items` row carries the same `batch_number` + `expiry_date` (approve already copies them at `:464-465`).

- [ ] **Step 2 — Run, watch fail** (batch/expiry ignored today).

- [ ] **Step 3 — Extend validation** (`qualityCheck`):
```php
'items.*.batch_number' => ['nullable', 'string', 'max:100'],
'items.*.expiry_date' => ['nullable', 'date'],
```

- [ ] **Step 4 — Persist in the update loop** (`:325-329`): add to the `update([...])` array:
```php
'batch_number' => $itemData['batch_number'] ?? $grnItem->batch_number,
'expiry_date' => $itemData['expiry_date'] ?? $grnItem->expiry_date,
```
(coalesce so omitting them preserves the receiver's original entry — `$grnItem` is already available via `$grnItemsById->get($itemData['id'])` in the pre-check loop; capture it inside the transaction loop from `$grn->items()->where('id',$itemData['id'])->first()` or reuse the keyed collection).

- [ ] **Step 5 — Run green; pint + chown; deploy; commit** `feat(purchases): quality check sets batch/expiry`.

### Task 0.3: Split `purchases.grns.quality_check` permission from `approve`

**Files:**
- Modify: `Modules/Purchases/app/Http/Controllers/PurchaseGrnController.php:44-49` (middleware map)
- Modify: `Modules/Core/database/seeders/RolePermissionSeeder.php` (register `purchases.grns.quality_check`; grant to every role that currently holds `purchases.grns.approve`)
- Test: `Modules/Purchases/tests/Feature/GrnQualityPermissionTest.php` (new)

- [ ] **Step 1 — Failing test.** Two users: (a) holds `purchases.grns.quality_check` but NOT `approve` → can `quality-check` (200/redirect past 403) but `approve` → 403; (b) holds `approve` but NOT `quality_check` → `quality-check` → 403. Use the module's existing permission-test pattern (grep `PurchaseMatchGuardsTest` / any `*ApiTest` for how it assigns permissions to a test user).

- [ ] **Step 2 — Run, watch fail** (both actions currently share `approve`).

- [ ] **Step 3 — Update controller middleware.** Change `:48` so `qualityCheck` (and `submitQuality`) use the new permission:
```php
new Middleware('permission:purchases.grns.quality_check', only: ['submitQuality', 'qualityCheck']),
new Middleware('permission:purchases.grns.approve', only: ['approve']),
```

- [ ] **Step 4 — Seed the permission (non-breaking).** In `RolePermissionSeeder.php`, add `'purchases.grns.quality_check'` to the permission registry following the file's existing format, and in the same run grant it to every role that already has `purchases.grns.approve` (so nobody loses capability on upgrade). Admins narrow it afterward per the role matrix in the roles HTML.

- [ ] **Step 5 — Run green;** re-seed on moonui2 (`php artisan db:seed --class="Modules\\Core\\Database\\Seeders\\RolePermissionSeeder" --force`); pint + chown; commit `feat(purchases): separate GRN quality-check permission`.

### Task 0.4: Cleanup command for legacy empty receipts / draft-approved GRNs

**Files:**
- Create: `Modules/Purchases/app/Console/Commands/CleanupEmptyGrnReceipts.php`
- Test: `Modules/Purchases/tests/Feature/CleanupEmptyGrnReceiptsTest.php` (new)

- [ ] **Step 1 — Failing test.** Seed an approved GRN whose linked `InventoryReceipt` has zero items (simulating the pre-fix bug). Run the command `--dry-run` → reports 1; run without → the empty receipt is cancelled/removed and the GRN reverted to `QualityApproved` (or flagged), no stock touched.

- [ ] **Step 2-4 — Implement** a read-then-act command: find `InventoryReceipt` rows with `reference_type=Purchase` and `items()->count()===0`; for each, log GRN number, void the receipt, and reset the GRN so it can be re-approved correctly. Default to `--dry-run`; require an explicit flag to mutate.

- [ ] **Step 5 — Run green; pint + chown; commit** `chore(purchases): cleanup command for empty GRN receipts`. Run once on moonui2 dev data.

**Phase 0 exit check:** full Purchases suite → only the known pre-existing failures remain; changelog bullet added; deployed on `hazemdev2` (not pushed).

---

## Phase 1 — The one-button switch (`procurement_mode` + `ProcurementPolicy`)

*Independently shippable after P0. Introduces the single setting and the resolver; migrates read sites; adds the drift-guard architecture test and the switch validation. Detailed task breakdown; each task's exact code is written at phase start against the then-current tree.*

### Task 1.1: Seed the `procurement_mode` setting + `ProcurementMode` enum
- Create `Modules/Purchases/app/Enums/ProcurementMode.php` (`Simple`/`Controlled` + `resolve(mixed): self` default `Simple`, mirroring `GrnMode::resolve`).
- `SettingDefinitionSeeder`: add `purchases.procurement_mode` (enum `simple|controlled`, default `simple`).
- Test: resolve() null/unknown → Simple; seeded default present.

### Task 1.2: `ProcurementPolicy` service — the single reader
- Create `Modules/Purchases/app/Services/ProcurementPolicy.php`. Constructor injects `SettingsService`. Typed getters: `mode()`, `grnMode()`, `recognitionPoint()`, `enforceReceivingLimit()`, `receivingTolerance()`, `enforceThreeWayMatch()`, `billingTolerance()`, `requirePoForBill()`, and the new behavior flags `singleActiveGrnCycle()`, `oneBillPerGrn()`, `restrictStandaloneReceipts()` (last three: `true` iff controlled).
- **Contract:** in `simple`, every getter returns the raw individual setting (byte-for-byte today). In `controlled`, getters return the preset constants; the new flags return `true`.
- Interfaces (Produces): the getter signatures above — every downstream task consumes these instead of `SettingsService->get('purchases.<key>')`.
- Tests: a `simple`-mode matrix asserting each getter equals the raw setting; a `controlled`-mode matrix asserting each getter equals the preset constant.

### Task 1.3: Migrate read sites to `ProcurementPolicy`
- Replace direct `settingsService->get/getBool('purchases.grn_mode' | 'inventory_recognition_point' | 'enforce_receiving_limit' | 'receiving_tolerance_percent' | 'enforce_three_way_match' | 'billing_tolerance_percent' | 'require_po_for_bill', …)` reads in: `PurchaseGrnController`, `PostPurchaseBill`, `PostGrnGrniAccrual`, `CancelPurchaseBill`, `PurchaseBillController`, `UpdatePurchasesSettingsRequest`, `PurchaseReportController` (grep the 7 keys first — the list is short and concentrated). Inject `ProcurementPolicy` via constructor.
- Test: re-run P0 + GR/IR + guards suites → all green with the indirection (behavior unchanged in simple mode).

### Task 1.4: Drift-guard architecture test
- `Modules/Purchases/tests/Architecture/ProcurementPolicyIsSoleReaderTest.php`: assert no file outside `ProcurementPolicy` reads the 7 governed keys directly (Pest arch test or a grep-based assertion). This is the regression insurance for Task 1.3 and all future work.

### Task 1.5: Switch validation + settings-UI lock state
- `UpdatePurchasesSettingsRequest::withValidator`: when flipping to `controlled` (or setting recognition=receipt), **block** with an actionable 422 if `grni_account_id` / `price_variance_account_id` are unset (list the missing accounts). Grandfather in-flight docs: scan for POs with >1 open GRN or >1 draft bill and return the list in the response `meta` (do not block).
- FE (`moon-erp/src/app/features/settings`): in `controlled` mode render the governed toggles read-only with a lock badge + "managed by Controlled mode" and their effective values; keep them editable in `simple`.
- Tests: flip-to-controlled with missing accounts → 422; with accounts → 200 + grandfather list.

### Task 1.6: Changelog + regression matrix
- `docs/moonstack/CHANGELOG.md` bullet; extend `GrnModeResolutionTest`/`GrniRecognitionTest`/`PurchaseMatchGuardsTest` with a "simple mode == byte-identical" assertion matrix.

**Phase 1 exit:** one setting activates grn_quality + receipt-recognition + the 3 guards; simple stays identical; drift test guards the invariant.

---

## Phase 2 — Controlled-mode guards + billing + traceability + warehouse-keeper gate

*After P1. The controlled-mode behavior that has no individual setting: one-active-cycle guard, one-bill-per-GRN, bill-from-received, settlement panel, trail, standalone-receipt policy, and the separate إذن-إضافة approval. Task breakdown; code at phase start.*

### Task 2.1: One-active-receiving-cycle guard (GAP F)
- **Definition:** a PO's receiving cycle is OPEN while a GRN exists with `status NOT IN (cancelled)` AND (`status != approved` OR its linked `inventory_receipt` is not yet approved). Closes when the إذن إضافة is approved → next GRN allowed for the remaining qty.
- **Three rings:** (1) `StorePurchaseGrnRequest` pre-check → friendly 422 naming the blocking GRN (gated by `ProcurementPolicy::singleActiveGrnCycle()`); (2) authoritative: inside the create/approve `DB::transaction`, `PurchaseOrder::lockForUpdate()` on the PO row, then re-check — this is the concurrency-correct ring; (3) hardening (optional): a MySQL generated column `open_guard = IF(status IN ('draft','pending_quality','quality_approved'), purchase_order_id, NULL)` + unique index (NULLs don't collide).
- Tests: second GRN while one open → 422; after the receipt is approved → next GRN allowed; concurrency test hitting the lock; simple mode → no guard.

### Task 2.2: One-bill-per-GRN + bill traceability (GAP E/F)
- Migration: add `purchase_bills.purchase_grn_id` (nullable FK) + **unique index** (doubles as the one-bill-per-GRN guard). Model relation `PurchaseBill::grn()`.
- Action-layer: "at most one draft bill per PO at a time" in controlled mode.
- `GET purchases/orders/{id}/trail` (+ inverse from a bill): walk PR→PO→GRN→receipt→bill via existing forward FKs + the new `purchase_grn_id`; return a timeline (doc type, number, status, actor, timestamp). One FE `TrailTimeline` component on PO/GRN/bill pages. **No** spatie/activitylog.
- Tests: second bill for the same GRN → unique violation surfaced as 422; trail returns the full ordered chain.

### Task 2.3: Bill from received/accepted qty + `createFromGrn` (GAP C)
- `PurchaseBillController::createFromOrder`: when `ProcurementPolicy::grnMode() !== Direct`, prefill per-line qty = `accepted-received-to-date − billed` (fallback `received` where quality off); keep `ordered − billed` only for simple+direct.
- Add `createFromGrn(PurchaseGrn $grn)`: lines from GRN accepted quantities at PO prices; stamps `purchase_grn_id` + `purchase_order_id`.
- Tests: bill prefilled from accepted qty; over-accept path blocked by existing 3-way; simple+direct unchanged.

### Task 2.4: Settlement summary panel (accounting visibility)
- FE component on the bill (pre-post): per line ordered / received / accepted / billed, PO price vs bill price, computed PPV, and the GL accounts to be hit. BE: a read endpoint assembling these from existing data (no new accounting — GR/IR + PPV already post correctly).
- Tests: endpoint returns correct per-line variance figures for a known fixture.

### Task 2.5: Separate warehouse-keeper approval for إذن الإضافة (GAP G)
- In controlled mode, `PurchaseGrnController::approve` creates the `InventoryReceipt` in **Draft** (do NOT auto-`ApproveReceipt`); the warehouse-keeper approves it via the existing `inventory.receipts.approve` (existing `InventoryReceiptController` approve path). PO "addition approved" chip derives from the receipt's approved state. Simple mode keeps today's auto-approve.
- New permission `inventory.receipts.create_manual` (seed + middleware): manual/standalone receipts in controlled mode require it; purchase-type receipts remain system-created only.
- Tests: controlled → GRN approve leaves receipt Draft, stock not yet moved; keeper approves → stock moves + PO chip flips; simple → unchanged; standalone manual receipt without the new permission → 403 in controlled.

### Task 2.6: PO status chips
- Derive UI chips ("GRN created" / "received" / "addition approved" / "billed") from the existing receive_status/bill_status + open-cycle + receipt-approved state. **Do not** add new PurchaseOrderStatus enum values. Changelog bullet.

**Phase 2 exit:** the full controlled flow enforced end-to-end; guards ship to every controlled client on update (virtual preset).

---

## Phase 3 — Expiry/batch persistence on stock movements

*After P2. Independent, healthcare-visible. Captures inbound genealogy without a full lot-balance redesign.*

### Task 3.1: Carry batch/expiry onto stock movements + cost layers
- Migration: add `batch_number` + `expiry_date` to `inventory_movements` (and cost-layer table if it anchors expiry).
- `StockService::increaseStock` (+ `ApproveReceipt` call path): pass batch/expiry from the receipt line through to the movement row. No balance-model change.
- Tests: approving a receipt with batch/expiry writes them onto the movement row.

### Task 3.2: "Received batches expiring before date D" report
- `GET purchases/reports/expiring-batches` (or inventory report): query movements for inbound batches with `expiry_date <= D`, grouped by product/warehouse. FE surfacing.
- Tests: fixture with two batches, one expiring → report returns it.

**Phase 3 exit:** expiry captured, traced, and reported. **Explicitly NOT delivered:** per-lot on-hand balances, FEFO issuing, expired-stock blocking — those are Phase 4.

---

## Phase 4 — Full lot/FEFO stock (SEPARATE INITIATIVE — out of this plan)

A cross-module project (issuing across Sales/POS/Production/LIS, transfers, adjustments, counts, valuation) on the scale of the GR/IR work. Write as its own KB topic + plan; build its lot-balance table backfillable from Phase 3 movement history. **Do not fold into the above.**

---

## Self-review notes

- **Spec coverage:** the 7 problems (A–G) in the redesign HTML map to tasks — A→0.1, B→0.2, quality-perm→0.3, empties→0.4, one-button→1.x, F→2.1, E→2.2, C→2.3, accounting-visibility→2.4, G→2.5, D→3.x.
- **Type consistency:** `canApprove(bool $qualityRequired)` used identically in enum + model + controller; `ProcurementPolicy` getters are the single vocabulary for phases 2–3.
- **Fidelity boundary (honest):** Phase 0 is code-complete and executable now. Phases 1–3 are task-level; their exact code/tests are written at each phase's start against the then-current tree (writing P2 code before P0/P1 land would be speculative). Each phase is a shippable unit — per the writing-plans scope rule, they can each become their own full plan when reached.
