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

> updated: 2026-07-12 · 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.**
- **`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.

## 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).
