# Purchases controlled → flexible procurement flow (state + backlog)

> updated: 2026-08-16 · status: **Phases 1–3 + 3.5 SHIPPED to `hazemdev2` + live on moonui2 `/app`** (not merged to `main`). Owner testing piece-by-piece.
> Code: BE `/home/moonui2/moon-erp-be` (`Modules/Purchases`, `Modules/Inventory`) · FE `/home/moonui2/public_html/moon-erp`. Test company = **4** (controlled mode).
> Design + trackers: [`plans/purchases-inventory-flexible-flow.html`](../plans/purchases-inventory-flexible-flow.html) (canonical design) · `plans/phase1-flexible-billing-plan.md` · `plans/phase2-lot-capture-plan.md` · `plans/phase3-expiry-visibility-plan.md`. Related: [`plans/purchases-controlled-flow-redesign.html`](../plans/purchases-controlled-flow-redesign.html) (the earlier P0–P3 foundation).

## What this is
A `purchases.procurement_mode = simple | controlled` flow (default simple = byte-for-byte old behavior), read by the `ProcurementPolicy` service. The earlier P0–P3 work built the **strict** controlled flow (grn_quality + receipt-recognition GR/IR + 3 guards + one-active-GRN-cycle / one-bill-per-GRN / restrict-standalone). During live testing the owner asked to relax it into **“flexible + accounting decides”** — hard blocks removed where the 3-way-match / GR/IR already guarantee correctness.

## Decisions (the philosophy shift)
- **Flexible, not hard-blocked:** open multiple paths; let the **3-way match** (caps billed ≤ received cumulatively) and **GR/IR** (nets to zero regardless of receipt-vs-bill order) enforce correctness. Any exception must be *explicit + permissioned + badged + reportable*; nothing silent/default-on.
- **Direct purchase bill** uses **invoice-first GR/IR** (DR GR-IR/CR AP at post → draft receipt → keeper approves → DR Inventory/CR GR-IR + stock).
- **Lot capture unified at keeper receipt approval** (not at GRN/quality): batches-first dialog, serials nested under batches inheriting dates. `production_date` is informational only. Expired-at-receipt = warn, not block.
- ~~**Expiry on balances = visibility, not FEFO.**~~ **⚠ SUPERSEDED (2026-08-01).** Phase 4 shipped: `inventory_lot_balances`, `inventory_issue_lot_allocations`, real FEFO allocation (`LotAllocationService`, `expiry_date` asc, switchable to FIFO via `inventory.issuing_order`), the ownership dimension, and manual lot pick — all with tests. **But it built a lot *ledger*, not a lot-based stock system:** `inventory_stock_balances` is still the sole authority for on-hand, lots are decremented afterwards best-effort, and the `shortfall` is silently discarded. `expired_issue_policy = block` **filters** expired lots out of FEFO — it does not refuse the line, so expired stock still sells. Sales returns restock with **no lot at all**. Measured on `moonui2_dev_be` 2026-08-01: **4 of 6 batch-tracked products violate the invariant the migration itself declares**, and 9 expired lots still carry a positive balance. Full evidence + the fix sequence: [`plans/pos-pharmacy-fitness-analysis.html`](../plans/pos-pharmacy-fitness-analysis.html) §5 and §9-أ.
- **`inventory_receipt_item_batches` (child table) is the lot source of truth for reporting.** `inventory_movements.batch_number/expiry_date` is an **informational first-batch mirror only** — never aggregate it for lot/expiry reporting (it silently drops 2nd+ batches on a split line). Reports + the stock-balance nearest-expiry APPROX read the child table (approved receipts), with the movements mirror kept only as a last-resort legacy fallback for pre-Phase-2 rows with no child data.

- **Partial purchase-request conversion (2026-08-10).** A request converts into orders **line by
  line and quantity by quantity**, repeatedly, deriving its own status until it closes itself;
  cancelling or deleting an order returns its quantities. Gated by
  `purchases.allow_partial_request_conversion` (**default OFF** = byte-for-byte today), read —
  like every setting in this topic — **only through `ProcurementPolicy`**, now with a drift-guard
  test that fails the build if anything else touches the key.
  **Two rules from this work bind anyone editing `Modules/Purchases` again:** (1) the conversion
  status has exactly ONE writer, `PurchaseRequest::recalculateConversionStatus()`, which derives
  it from the lines and only ever rewrites within {Approved, PartiallyConverted, Converted}; and
  (2) the lock order is one-directional — the order row first, then EVERY line of the request by
  `sort_order` — which is what keeps convert / cancel / edit / delete queueing instead of
  deadlocking. Also closed: `POST /purchases/orders` used to accept a `purchase_request_id` and
  burn the whole request with **no company scoping and no approval gate** (breaking change — that
  linkage is now refused outright), and `converted_at` had never existed as a column at all.
  Full record: [`plans/purchase-request-partial-conversion/RESUME.md`](../plans/purchase-request-partial-conversion/RESUME.md)
  · [`LEDGER.md`](../plans/purchase-request-partial-conversion/LEDGER.md)
  · design [`plans/purchase-request-partial-conversion.html`](../plans/purchase-request-partial-conversion.html).
  BE `c6cc57b47` → `a03d7a1ef`, FE `fa0c1bac4`. 65 new tests; module 11 failed / 531 passed vs an
  11/466 baseline — same 11 names, zero regressions.


- **Bill-from-order and the GRN cancel deadlock (2026-08-14 → 16).** A run of defects in the
  same seam, all now closed. Recorded here because three of them share ONE mechanism worth
  remembering: **two halves of a feature answering the same question with different arithmetic.**
  - **An approved GRN could not be cancelled, and neither could its receipt** — three doors, all
    shut, with the receipt deferring to the GRN and the GRN's `canCancel()` never including
    `Approved` (since the very first GRN commit, 2026-02-25). Cancelling now performs a full
    reversal (GR/IR entry → stock + receipt → the PO's received quantities), shaped on
    `CancelPurchaseBill`. The opposite hole was closed in the same change: `CancelReceipt` used
    to succeed on a GRN-linked receipt in bill-recognition mode, reversing stock while the GRN
    stayed `Approved`. Full diagnosis: [`plans/grn-cancel-deadlock.md`](../plans/grn-cancel-deadlock.md).
  - **A bill from an order came out with no items at all.** The create-bill dialog offered
    `remaining_bill_qty` (ordered − billed) while the server bills `received − billed` in the GRN
    modes — so on an unreceived order it listed everything, then saved a bill with zero lines.
    `PurchaseOrderItem::billableQuantity()` is now the single definition both read, exposed as
    `remaining_bill_qty_effective`. Diagnosis: [`plans/empty-bill-from-order.html`](../plans/empty-bill-from-order.html).
  - **Empty documents are a recurring family here.** Three closed this week — the empty request
    conversion, the lineless purchase order, and the empty bill. **Any "create X from Y" path
    must refuse when the plan comes out empty**, rather than saving a header. Check this first
    when a new one appears.
  - Also fixed in passing: `createFromOrder` ignored the caller's quantities entirely; editing a
    bill orphaned its lines from the order (so GR/IR never cleared); discounts were dropped; and
    `createFromGrn` threw a **500** on every call because `$user` was read inside a closure that
    never captured it — introduced by the currency work below and caught by its own test file.

- **One company currency (2026-08-10).** `companies.currency` is the single truth;
  `core.default_currency` is now a VIEW onto it, intercepted in `SettingsService::get()/set()`.
  Documents used to hardcode `'KWD'` (and the purchase-returns screen sent `'EGP'`). New reader
  `Modules/Core/app/Services/CompanyCurrency` — `code()` for the string documents, `id()` for the
  FK ones. Scope + evidence: [`plans/company-currency-single-source.md`](../plans/company-currency-single-source.md).
  **🔴 STILL OPEN and financially live:** `purchases.grni_account_id` points at an **expense**
  account on production (5403 «خسائر متنوعة») and at «رواتب مستحقة» on moonui2 — so every GR/IR
  accrual credits the wrong account and overstates profit. The validation only checks
  detail-vs-header, never the account's nature. Needs (a) a guard refusing a non-liability
  account and (b) an owner decision to create «بضاعة مستلمة غير مفوترة» under 2101; the seeder
  already defines it but skips any company that already has a value, however wrong.

- **The release-note pre-push hook is flaky** — `.githooks/pre-push` lines 26/28 pipe `git` into
  `grep -q` under `set -o pipefail`; `grep -q` exits on the first match, `git` takes SIGPIPE, the
  pipeline returns 141 and a push whose CHANGELOG bullet IS present is blocked. Measured ~8 in 10.
  `SKIP_CHANGELOG=1` was used three times on 2026-08-16 after verifying the bullets by hand. Fix
  is one word per line (`grep -cE … >/dev/null`); present in BOTH repos. Not applied — owner's call.

## Shipped (2026-07-10)
- **Phase 1 — flexible billing + direct purchase bill.** BE `f8d77d181`, FE `17371cb4d`. Cumulative bill-from-PO + per-GRN billing both available (removed one-bill-per-GRN hard blocks); direct no-PO bill (`is_direct`, permission `purchases.bills.create_direct`, invoice-first GR/IR, `PostDirectBillReceiptRecognition` + listener, edit-locked draft receipt, both cancel branches). 83 tests green; review fixed 1 CRITICAL (stale `is_direct` on update) + 1 IMPORTANT.
- **Phase 2 — lot/batch/serial capture at receipt approval.** BE `91cf7c634`, FE `7005989e9`. New `inventory_receipt_item_batches`; `production_date` on serials/receipt-items/movements; `ReceiptLotService`; `receipt-approval-dialog` FE component. Fixes 2 blockers: (a) serial product bought via PO→GRN can now be received; (b) per-serial expiry persists. 12 tests; review 0 critical + 3 IMPORTANT fixed.
- **Phase 3 — expiry visibility on stock-balances.** BE `20e06ee5b`, FE `b8625169e`. Batched nearest-expiry (no N+1) + red/amber badges + `~`-approx + drill-down; setting `inventory.near_expiry_days` (30). 8 tests. Visibility only, no FEFO.
- **Phase 3.5 — batch VISIBILITY (receipt view + unified "Expiring lots" report).** Fable-designed ([plans/phase3.5-batch-visibility-plan.md]). Owner gap: a batch-split receipt (GRN-000032: 4444+555) showed nowhere. Fixed 3 surfaces: (a) **receipt detail** — always-open lots sub-table (#·batch·prod·expiry·qty) + "N lots" badge + lots-total integrity line; (b) **reports** — reworked `InventoryReportController::expiringBatches` into a UNION (serial→`product_serials` basis *Available*; batch→`inventory_receipt_item_batches` approved-only basis *Received*; legacy mirror fallback), partitioned by `tracking_type` (no double-count), + qty-basis/on-hand/tracking columns + honesty banner; FE tab renamed **Expiring lots**, now actually calls `/reports/expiring-batches` (it never did before — why batch products were invisible). (c) `StockBalanceController::buildNearestExpiryMap` APPROX re-pointed at the child table (movements = last resort). +index migration `310002`. 6 batch tests (incl split-batch, draft-excluded, serial no-double-count) + 22 report/stock regress green.
- **Stock-card lot surface (2026-07-12, code done, NOT committed — awaiting owner review + /fullpush).** The product stock card (`/app/core/stock-card/:productId`) gained: (a) a **Lots section** (after Cost Layers, before Transactions) reusing `GET stock-balances/product/{id}/lots` with `warehouse_id=0` (= no warehouse filter → all warehouses) + the card's variantId — open lots only (on_hand>0), owner-grouped (own first; 🤝 consignment violet), expired/near chips, `~`estimated tooltip, unassigned-legacy note, and a **"التالي في الصرف / Next to issue" FEFO badge** on the first on_book lot with on_hand>0 (the API's order IS FEFO); soft inline error+retry, never blocks the card. (b) **Issue lot provenance in the movements table**: `StockCardController::stockCard` batch-loads (ONE query) `inventory_issue_lot_allocations` ⋈ `inventory_lot_balances` for the page's `inventory_issue` reference_ids scoped to `lb.product_id = product`, attaches `lot_allocations[{batch_number,quantity}]` per movement (exposed via `InventoryMovementResource` `when(non-empty)`); FE renders "من لوت: batch (qty)" chips under the Source cell. Verified live (company 4): movement 84 / issue 29 → `[{4444, 12}]`. New i18n: `INVENTORY.LOTS / FEFO_NEXT / NO_LOTS_PRODUCT / FROM_LOT` (ar+en). `ng build` green; bilingual CHANGELOG bullet under [Unreleased]. Files: FE `stock-card.component.{ts,html,scss}`, `inventory.model.ts`, i18n; BE `StockCardController.php`, `InventoryMovementResource.php`.

## 🔜 DEFERRED / BACKLOG (what to work on next)
1. **Phase 4 — per-lot on-hand balances + FEFO issuing** (biggest). **📄 FULLY ANALYSED — see [plans/phase4-per-lot-fefo.html](../plans/phase4-per-lot-fefo.html)** (Fable-designed UX + 3 independent code-research passes, 2026-07-10). Key finding: everything funnels through 2 `StockService` methods (increase/decreaseStock); cost_layers already have `remaining_quantity` consumed oldest-first BUT (a) one-per-line not per-batch, (b) reference the receipt header w/ no expiry, (c) **never consumed in weighted-avg mode (the default)** → can't be the lot ledger. **Decision: dedicated `inventory_lot_balances` (always-decrements, costing-agnostic) + `inventory_issue_lot_allocations` (for exact cancel-reversal), seeded per-batch at receipt, FEFO-ordered (expiry asc, no-expiry→received-date), manual override, expired blocked+permissioned.** Serial products derive lot view from `product_serials` (no duplication). **🔄 RE-ARCHITECTED 2026-07-11 to add an OWNERSHIP dimension** (owner: consignment/بضاعة أمانة + toll-manufacturing/التصنيع للغير). Finding: a consignment/toll subsystem already exists in Production (`consignment_material_ledgers`, `ConsignmentBorrow`, `MfgTollContract`, BOM `supply_source` own/customer) BUT it's a **drifting two-track silo**: customer material is written as REAL inventory (`CreateConsignmentReceipt`→`StockService::increaseStock`) yet indistinguishable from own stock, **valued into your on-hand/WAC** (the `is_consignment` exclusion was promised in a migration header but never built), toll consumption decrements stock but **never the ledger** (`recordIssue` is dead code → balance overstates forever), no owner tag on any lot, no return-to-customer action. **Unified decision: `owner_partner_id` (NOT NULL, 0=company) + `on_book` flag ON THE LOT TABLE only** (stock_balances stays owner-blind physical → no 15-module surgery); consignment ledger demoted to a financial memo reconciled against per-owner lot sums; FEFO scoped within one owner; new CRN/CRT consignment docs; per-owner count variance routing; borrow "Due-back" tray. Closes all 4 owner gaps (visible/countable/consumable/returnable) at the lot grain. Serial→derived. 6 sub-phases 4.0→4.5 (owner column day one; 4.2 wires `recordIssue` = closes the drift bug). Awaiting owner go + 7 scoping decisions (§14). See also [plans/inventory-consignment-stock-analysis.html](../plans/inventory-consignment-stock-analysis.html) (2026-07-08).
2. **RFQ / supplier-sourcing module** — supplier pricing portal (tokenized per-supplier link, Patient-Portal pattern) + product×supplier comparison matrix (green=lowest/red=highest) → generate draft POs; requires **purchase request → multiple POs** first (today a hard 1→1 lock). Fully designed in [flexible-flow §3b] + Fable consults; **not started** (needs owner go + email needs a Mailable+SMTP; WhatsApp via `wa.me`).
3. **Review fast-follows (small):** direct-bill `pendingReceipt()` N+1 on the bills list (bounded — batch or move to `show()`); duplicate-serial race returns a raw 500 instead of a friendly 422 (pre-existing pattern); ReceiptLotService defense — reject `batches[]` on a `none`-tracked line / `serials[]` on a `batch`-only line; expiry drill-down can land on an empty product-filtered list (the `reports/expiry` endpoint only lists ~30-days-ahead, excludes already-expired, no server-side product filter).
4. **Settlement/variance panel** (from the original controlled-flow plan, task 2.4) — never built.
5. **Phase 3.5 review fast-follows (deferred, from code-review):** (a) *tracking_type is mutable* (`UpdateProductRequest` allows editing it with no guard) — if a product's type changes after it has approved receipts, its historical lot rows silently drop out of the expiring-lots report (partition uses the *current* type). Guard tracking_type edits when receipt history exists, or document. (b) *Variant identity collapsed* in the unified expiring-lots report — the 3 sources don't group/select `product_variant_id`, so two variants sharing the same batch_number+expiry+warehouse would sum into one lot. Low frequency (batch numbers usually per-variant); add variant to group/select if variants go into active batch/serial use.
5. **Pre-existing test failures (not from this work, worth a cleanup):** `OpeningBalanceApiTest:207` (movement_type opening vs receipt) + `PurchasesSettingApiTest` double-seed (~9). These are the “~10 baseline” failures the suites carry.

## Open / to confirm with owner
- Whether to relax `single_active_grn_cycle` (allow >1 open receiving cycle per PO concurrently) — currently sequential; deferred pending a real need.
- Whether direct bills should ever be allowed to link a PO after creation (currently `update()` recomputes `is_direct` and re-gates — adding a PO makes it non-direct).
