# Phase 2 — Unify batch/serial/expiry capture at keeper receipt approval — Plan & Tracker

> **Owner-approved (2026-07-10).** Phase 2 of [purchases-inventory-flexible-flow.html](purchases-inventory-flexible-flow.html) §5. Fixes the two lot blockers found in §5: **(a)** serial-tracked product bought via PO→GRN is impossible to receive (`serial_count_mismatch` + edit-locked receipt); **(b)** per-serial expiry is dropped on submit. Unifies capture at the **keeper's inventory-receipt approval** (Fable): qty/cost/product/warehouse stay locked, the lot/batch/serial section opens; **batches-first dialog** (batch rows: lot·production·expiry·qty, sum=line qty; serials nest under each batch, inherit dates); pre-fill from quality; add `production_date` (informational). Autonomous execution (subagents + Fable design + code-reviewer). Owner tests at the end.

**Repos:** BE `/home/moonui2/moon-erp-be` · FE `/home/moonui2/public_html/moon-erp` · branch `hazemdev2`.

## Global constraints
- Reuse the **partial-unlock pattern already built in Phase 1** (`InventoryReceiptController::updatePurchaseBillReceipt` — qty/cost/product/warehouse locked, batch/serial editable) and generalize it to GRN-origin receipts.
- Backward-compatible: keep the existing `inventory_receipt_items.batch_number/expiry_date/serial_numbers` columns working; add the minimal new structure for per-serial dates.
- `production_date` is **informational only** — no logic depends on it; validate `production_date < expiry_date`. Expired/near-expiry at receipt = **warn, not block**.
- Simple mode + existing non-lot receipts unchanged. Bilingual. MoonStack CHANGELOG per phase. Fetch-first push. chown after root edits. No merge to main.

---

## Task 2.1 — BE: `production_date` column + flow-through
Migration: add `production_date` (date, nullable) to `product_serials` + `inventory_receipt_items` + `inventory_movements`. Thread it through `ApproveReceipt` (ProductSerial create) + `StockService::increaseStock` (movement stamp), mirroring how `expiry_date` is handled today. Validation `production_date < expiry_date`.
**Status:** ☐

## Task 2.2 — BE: capture lot/serial at approval (fix blockers a + b) — Fable-designed
**The approve call carries the lot data (one atomic call, no save-then-approve race):**
`POST /api/inventory/stock-receipts/{id}/approve` with body:
```json
{ "items": [ { "id": <receipt_item_id>, "batches": [
  { "batch_number": "LOT-A1", "production_date": "2026-05-01", "expiry_date": "2027-05-01", "quantity": 6,
    "serials": [ {"serial_number":"S1"}, {"serial_number":"S2","production_date":"...","expiry_date":"..."} ] } ] } ] }
```
- Lines are LOCKED — only lot data travels; `items[].id` = existing `inventory_receipt_items.id`.
- Serial `production_date`/`expiry_date` **omitted/null ⇒ inherit** from the batch: BE resolves `serial.x ?? batch.x` when writing `product_serials` (this is the **bug (b)** fix — per-serial dates never lost). `batch` products omit `serials`; `none` products omit the item.
- **Bug (a) fix:** move the `serial_count_mismatch` check from "pre-existing serials on receipt" to "serials present in the approve payload" — a GRN serial product is no longer a dead end (receipt stays draft while the dialog is open; serials arrive with approve).
- **DB (minimal):** NEW child table `inventory_receipt_item_batches` (`id, receipt_item_id FK, batch_number, production_date, expiry_date, quantity`) — N batches per line can't round-trip one column. **No new serial table** — `product_serials` + the new `production_date` column IS the per-serial store (one row/serial with effective dates + parent batch_number). **Legacy columns = denormalized mirrors written on approve:** `inventory_receipt_items.batch_number/expiry_date` = first batch's values; `serial_numbers` JSON = flattened serials — so every existing reader keeps working.
- Validation (BE authoritative, 422 keyed `errors.items.N.batches.M.serials.K`): Σbatch qty = line qty; serial count/batch = batch qty (serial products); batch_number required; production_date < expiry_date; no duplicate serials (within receipt + vs `product_serials` in DB); serial products = integer qty. Expired/near-expiry = **allowed (warn client-side, never block server-side)**.
- A `Save lots` path PUTs the same `items[].batches[]` to the draft-receipt update endpoint without approving. Generalize the Phase-1 partial-unlock (`updatePurchaseBillReceipt`) to GRN-origin receipts too.
- QC pre-fill: expose the source GRN line's quality `batch_number`+`expiry_date` on the receipt/GRN resource so the FE can pre-fill batch row 1.
**Status:** ☑ DONE (2026-07-10). Migrations ran; ReceiptLotService + child table + product_serials.production_date + denormalized mirrors + QC pre-fill. Phase2LotCaptureTest 12/12; 0 new full-suite failures. Reviewed (0 critical, 3 important fixed).

## Task 2.3 — FE: batches-first "Receive & Approve" dialog (extract `receipt-approval-dialog` component) — Fable-designed
Approve on a draft receipt opens the dialog **unless all lines are `none`** (then keep one-tap confirm). `p-dialog` 1000px, maximizable (auto &lt;768px). Per-line **card**: locked strip (product/qty/cost as *rendered text* + one lock icon + tooltip "fixed by the purchase document", green `10/10 ✓` / amber `6/10` chip) over an editable **batch-rows table** (batch no.·production·expiry·qty·SN-count·remove; live "Assigned 10 of 10 ✓ / Remaining 4 + [Use remaining]" balance). Default: ONE batch row pre-filled with full qty (+ QC batch/expiry for GRN, dismissable `ⓘ from QC` chip) → common case = zero keystrokes. `+ Add batch` pre-fills remaining. **Serials nest INLINE under each batch** (serial products only): scan-loop (autofocus LTR input, dup rejected loudly without mutating list) / paste-list / generate-range (reuse existing suffix logic); chips list with `6/6` counter; **serials inherit batch dates**, per-serial override via pencil (untouched ⇒ null in payload). `batch` products = batch rows only; `none` = locked strip + "no tracking" note. Validation inline but Approve stays enabled (scrolls to first error on click). Warn-not-block on expired. Footer: `Save lots` (draft persist) + `Approve` (atomic payload §2.2). RTL page but batch/serial/date fields forced `dir="ltr"` + Latin digits. Mobile: stacked field-groups, touchUI datepickers, HID-scanner friendly. Deprecate the old `openSerialDialog` create-form serial flow for receipts.
**Status:** ☑ DONE (2026-07-10, Opus FE implementer). New standalone `receipt-approval-dialog` component; `approve()` resolves tracking_type per line + gates all-`none`→one-tap else opens the dialog; `buildPayload` sends `items[].batches[].{...,serials[]}` with per-serial dates `overridden ? date : null` (inherit) — bug (b) structurally fixed; removed the old create-form serial dialog (bug (b) source); ~34 bilingual i18n keys; RTL page + LTR lot/date fields + Latin digits; mobile maximize/touchUI. **`ng build` clean (30.6s, 0 errors).** Endpoint assumption: `approve(id, body)` + `saveLots(id, body)` PUT — verify vs BE routes at integration.

## Task 2.4 — Deep test + review + deploy + push + CHANGELOG
**Status:** ☑ DONE (2026-07-10). Full Inventory+Purchases suite: 10 failed = pre-existing baseline (0 new), 702 passed incl. 12 Phase-2. Deployed BE local-deploy + FE→/app. Pushed BE `91cf7c634` + FE `7005989e9`. CHANGELOG Phase-2 bullet present.

### ✅ PHASE 2 COMPLETE (2026-07-10) — lot capture at receipt approval, live on /app + pushed. Next: Phase 3 (expiry visibility on stock-balances).

_orig:_ Pest tests: serial product via GRN can now be received (a); per-serial expiry persists (b); production_date persists; sum-mismatch rejected; locked fields still locked. code-reviewer pass. Deploy BE+FE, push, bilingual CHANGELOG bullet.
**Status:** ☐

---

## Progress ledger (append-only)
- 2026-07-10 — Phase 1 complete + shipped. Phase 2 started: Fable UI-design consult for the batches-first dialog dispatched; plan written.
- 2026-07-10 — **BE (2.1+2.2) DONE:** migrations ran [223] (production_date on product_serials/receipt_items/movements + new `inventory_receipt_item_batches`); `ReceiptLotService`, `ApproveInventoryReceiptRequest`, `InventoryReceiptItemBatch` model/resource; ApproveReceipt/StockService/InventoryReceiptController threaded; QC pre-fill on PurchaseGrnItemResource. **Phase2LotCaptureTest 12/12** (bug a: GRN serial product receivable; bug b: per-serial dates inherit/override + persist; production_date threads; Σ-mismatch/dup-serial/existing-serial/prod<expiry all 422; locked fields; save-lots; plain approve + legacy serial approve unchanged). Route `POST receipts/{id}/approve` MATCHES the FE `approve(id,body)`. CHANGELOG Phase-2 bullet added by the implementer (no conflict with Phase-1 bullets). (Had to TaskStop the implementer + its monitor — left a concurrent full-suite running again.) **FE (2.3) DONE + ng build clean.** Full Inventory+Purchases suite + code-reviewer (BE diff) now running → then deploy + push.
- 2026-07-10 — **code-reviewer: 0 CRITICAL** (stock/GL integrity, Σbatch=lineQty, locks all hold), 3 IMPORTANT **fixed**: (1) `show()` now eager-loads `items.batches`; (2) plain-receipt "Save lots" now persists batches via ReceiptLotService by line-id instead of silently dropping them; (3) `require_batch_tracking` gate → `required_without:items.*.batches` so a batches[]-only line isn't rejected. Phase2LotCaptureTest **still 12/12** after fixes. Minors (dup-serial race → raw 500 pre-existing; tracking_type-vs-payload defense) noted as fast-follows. Full suite gate running → deploy + push.
- 2026-07-10 — Fable delivered the full dialog design (locked strip + batch rows + inline nested serials + scan/paste/generate + atomic approve-with-batches payload + new `inventory_receipt_item_batches` child table + product_serials.production_date + denormalized legacy mirrors + QC pre-fill + warn-not-block). Recorded the contract in Tasks 2.2/2.3. Dispatched TWO Opus implementers in parallel (different repos, no test-DB clash): **BE** (2.1+2.2, acd2b2599) + **FE** (2.3 receipt-approval-dialog, a2f04649). Both build to the same payload contract. Awaiting reports → verify + code-reviewer + integration + deploy.
