# Phase 3.5 — Batch/lot VISIBILITY (receipt detail + unified expiring-lots report) — Fable-designed

> **Owner-reported gap (2026-07-10):** a batch-tracked receipt split into 2 batches (e.g. 4444: 50/exp 07-29 + 555: 50/exp 07-31) is captured in `inventory_receipt_item_batches` but shows NOWHERE — not the receipt detail, not the reports. Root: display/report surfaces read the line mirror (first batch only) / `product_serials` (serials only) / movements (first-batch mirror), never the accurate child table. **Genealogy visibility only — NO ledger rebuild (per-lot on-hand + FEFO stays Phase 4).**

**Repos:** BE `/home/moonui2/moon-erp-be` · FE `/home/moonui2/public_html/moon-erp` · `hazemdev2`. Test: company 4, product 17159 (GRN-000032, 2 batches).

## Decisions (Fable)
- **Movements untouched** — the `inventory_movements.batch_number/expiry_date` mirror is a display SUMMARY (first batch only); the **child table `inventory_receipt_item_batches` is the reporting source of truth.** Reworking movements to per-batch = Phase 4, not now.
- Honest framing everywhere: batch quantities are **"Received" (genealogy)**, serial quantities are **"Available" (live)** — never label genealogy "stock by batch".

## Task A — Receipt detail: show the batches (FE only; `show()` already returns `items.batches`)
`features/stock-receipts/stock-receipts.component.html` detail dialog: under each lot-carrying line, an **always-open indented sub-table** (# · batch no. · production · expiry · qty). A neutral badge `N لوط / N lots` on the mirror batch cell when `batches.length>=1` (don't show the misleading single mirror alone). Footer `مجموع اللوطات: 100 / 100` (amber on mismatch). Serial lines: same sub-table + a `N سيريال` count chip per batch (don't inline serials). Legacy line (no batches) → keep today's single mirror. RTL: batch no. `dir="auto"`; dates/qty LTR + Latin digits; headers via `INVENTORY.BATCH_NUMBER/PRODUCTION_DATE/EXPIRY_DATE/QUANTITY`. (An inline-chips first cut already exists — upgrade it to the sub-table + badge + total.)
**Status:** ☐ (chips cut done; upgrade pending)

## Task B — Unified "Expiring lots" report (BE rework `expiringBatches` + FE)
Rework `reports/expiring-batches` (keep the route) to **UNION**, partitioned strictly by `product.tracking_type` (no double-count — serials also write child batch rows):
- **serial** products → `product_serials` grouped (product,variant,warehouse,batch,expiry), `status=available`, qty=COUNT → basis **"Available"**.
- **batch** products → `inventory_receipt_item_batches` JOIN `inventory_receipt_items` JOIN `inventory_receipts` (status=**approved** only — drafts' saved lots must not show), grouped (product,warehouse-from-header,batch,expiry), qty=SUM(quantity) → basis **"Received"**.
- **legacy fallback:** batch line with zero child rows but a mirror batch_number+expiry → one row from the mirror, basis **"Received (legacy)"**.
- Columns: product(code+name) · batch no. · expiry (red if expired / amber if ≤ `inventory.near_expiry_days`) · days-left (badge, RTL-safe "منتهي منذ N" for negatives) · qty · **qty basis badge** · **product on-hand (all lots)** from `stock_balances` (sanity anchor) · warehouse (received-into, tooltip caveat) · tracking badge. Sort expiry asc, expired first. Params: `before`, `warehouse_id`, near-expiry threshold reuse.
- FE `inventory-reports.component`: rename the tab to **«اللوطات قاربة الانتهاء / Expiring lots»** (headline); keep `reports/expiry` (per-serial) as a "view serials" drill-down, not a co-equal tab. **Persistent info banner** (bilingual): batch qty = as received, issues not deducted per batch until Phase 4; serial rows = currently available.
- Verify `inventory_receipt_item_batches` has an index on `expiry_date` (migration `2026_07_10_310001`); add if missing.
**Status:** ☐

## Task C — Fix Phase 3 stock-balance nearest-expiry APPROX source (BE small)
`StockBalanceController::buildNearestExpiryMap` APPROX fallback reads movements (first-batch mirror) → wrong when a later-entered batch expires earlier. Re-point APPROX to `MIN(expiry_date)` from `inventory_receipt_item_batches` (via receipt join, approved); movements only as the LAST fallback for legacy no-child-row data.
**Status:** ☐

## Task D — tests + review + deploy + push + CHANGELOG + KB
Pest: batch product's 2 batches both appear in the report (incl the earlier-expiring 2nd batch); serial product from product_serials; draft-receipt lots excluded; legacy mirror fallback; no double-count; nearest-expiry APPROX picks the earliest child batch. code-reviewer. Deploy BE+FE, push, ONE bilingual CHANGELOG bullet ("see received batches in the receipt view + a unified expiring-lots report covering batch AND serial products; visibility, not per-lot stock"). KB: note the movements-mirror-is-display-only decision in `topics/purchases-controlled-flow.md`.
**Status:** ☐

## Ledger
- 2026-07-10 — Fable design received; plan written.
- 2026-07-10 — **ALL TASKS DONE (A–D).** Implemented directly (deep context). BE: `expiringBatches` reworked to unified UNION (serial/batch/legacy, tracking-partitioned, basis+on-hand+banner), `buildNearestExpiryMap` APPROX → child table, index migration 310002. FE: `getExpiringLots` service + `ExpiringLotsReport` types, tab 3 → "Expiring lots" (now calls the endpoint — it never did), banner + basis/days-left/on-hand/tracking columns, receipt-detail lots sub-table + "N lots" badge + total. i18n EN+AR. Tests: 6 batch (split-batch GRN-000032 scenario, draft-excluded, serial no-double-count) + 22 regress green. Live-verified company 4 / product 17159 → both 4444+555 surface. Deployed BE (local-deploy + chown) + FE (/app). CHANGELOG bullet added.
- 2026-07-10 — **Code-review (code-reviewer agent): 1 CRITICAL fixed** — `buildNearestExpiryMap` APPROX (both 2a child-table + 2b movements) lacked the `tracking_type` partition, so a depleted-serial product could surface a stale batch-container expiry; added the batch-only / not-serial filter + regression test (serial all-consumed → nearest_expiry null). 2 lower findings (mutable tracking_type; variant collapse) → backlog. **PUSHED:** BE `bd9be1127`, FE `45ad4fd87` on `hazemdev2` (not merged to main). SHIPPED.
