# LEDGER — Production Material Issue via Warehouse Approval (ISS-2026-9103) [FIN]

**Date:** 2026-07-15 · **Branch:** hazemdev2 (both repos) · **Dev DB:** moonui2_dev_be (forward-only)
**Portal:** ISS-2026-9103 · scope frozen (analysis step 3465) · in_implementation
**Owner acceptance:** with the setting OFF → material issue is byte-for-byte the current immediate flow (no regression). With it ON → clicking «صرف» creates a DRAFT إذن صرف that WAITS for the warehouse keeper; keeper edits qty/add/remove then approves; only then does stock deduct, WIP post, consumed_quantity update, and the order go in_process — all at the ACTUAL approved quantities. Warehouse selectable per-issue (default = order source warehouse). Partial issue + re-issue remaining. Single keeper approval (permission).

## Frozen decisions (do NOT re-litigate)
- **D1 — Warehouse:** default = `order.source_warehouse_id`; **selectable/overridable per issue**.
- **D2 — Partial:** keeper may issue LESS → order is partially issued; remaining can be issued later.
- **D3 — Approval:** single warehouse-keeper approval, permission-gated (NOT the multi-level engine).
- **D4 — Setting default OFF** = current immediate behavior, zero regression.

## Phases (WP table)
| WP | Scope | Repo | Depends | Review | Migration | Status | Commits |
|----|-------|------|---------|--------|-----------|--------|---------|
| WP1a | Setting `production.material_issue_requires_approval` (boolean, default false) added to ProductionSettingDefinitionSeeder + seeded on dev (reads false). | BE | — | trivial | ✅ | seeder edit, seeded moonui2_dev_be |
| WP1b | [FIN] CORE done. Extracted IssueMaterials post-approval block → public `applyEffects(order,issue,resolved,userId)` (helpers stay private, called by applyEffects). `execute`: OFF→ApproveIssue inline+applyEffects (byte-identical); ON (setting)→createDraft+return null. New listener `ApplyMaterialIssueOnApproval` on InventoryIssueApproved (guard reference_type=ProductionOrder + setting ON + idempotent MfgMaterialIssue-exists) rebuilds resolved from approved items keyed off issued_quantity → applyEffects. Registered in Production EventServiceProvider. **64 production tests green (OFF invariant held) + new ON test.** execute return type → ?MfgMaterialIssue (controller ignores return, OK). | BE | WP1a | code-reviewer running | — | ✅ | pending review |
| WP2 | [FIN] Keeper edits draft → reflect ACTUAL issued onto order. **DELIVERED BY WP1b's design** (listener rebuilds resolved off issued_quantity). Proven: partial-approval test (20→8 → consumed 8, WIP 40, stock 92). Keeper edits lines via the generic InventoryIssueController::update (draft-only). | BE | WP1 | done in WP1b | — | ✅ | 308be2e37 |
| WP3 | ✅ Surface production order number in the stock-issues screen: InventoryIssueResource `reference_number` (soft cross-module lookup of ProductionOrder.order_number) + FE stock-issues list/detail show it. | BE+FE | WP1 | pending final | — | ✅ | |
| WP4 | ✅ Per-issue warehouse override: execute(...,?int $warehouseId) + createDraft uses it; applyEffects draws lots from `$issue->warehouse_id` (override) but releases the RESERVATION on `$order->source_warehouse_id` (where the hold lives). Controller validates warehouse_id. FE: warehouse selector on the issue dialog (default order source). Test: issue from wh2 → stock leaves wh2 (50→30), source untouched. | BE+FE | WP1 | pending final | — | ✅ | |

## Serialization
One writer per repo at a time. WP1 is the [FIN] core — must land + be reviewed before WP2/3/4. WP3/WP4 are independent of each other (different surfaces) but both depend on WP1.

## Baseline (2026-07-15)
Material-issue invariant suite = **21 passed** (ProductionIssueCancelTest + ProductionMaterialOwnershipTest + ProductionTollMaterialChargeTest). These MUST stay green through WP1b (setting OFF path). Fable = EMERGENCY-ONLY (owner, token cost) → [FIN] gate satisfied by code-reviewer/Codex.

## ✅ ALL WPs DONE (2026-07-15)
WP1a·WP1b·WP2·WP3·WP4 complete. BE commits 308be2e37 (WP1a/b/2) + 2e6939211 (WP3/4); FE 193eafad0. **63 production+inventory tests green** (sacred OFF invariant held). Deployed /app main-ICERRXCO.js. NOT merged to main (owner /fullpush). FE-tree resolved: www/moon-erp is a symlink → public_html/moon-erp (one tree).
**Small follow-up (NOT WP1-4 scope):** expose `production.material_issue_requires_approval` in a FE settings screen — there is no production-settings UI today; admin toggles via the settings API. Independent [FIN] review passed after fixing CRITICAL(backflush)+HIGH(stale-order)+TOCTOU+unique-index.

## RESUME POINT (2026-07-15)
WP1a ✅ · WP1b ✅ (committed BE 308be2e37, ~85 production tests green) · WP2 ✅ (delivered by WP1b design). **Independent review found + FIXED: CRITICAL (backflush was being gated → now exempt via requireApprovalGate=false + $applyingInline static flag so the listener skips inline paths), HIGH (stale-order approval → listener now THROWS to roll back stock), TOCTOU (applyEffects idempotent at top), + unique index migration. All re-tested.**
**Remaining: WP3 (surface production-order data in the stock-issues UI) + WP4 (warehouse selector at issue) — BOTH FE, BLOCKED on verifying the FE tree (public_html/moon-erp vs www/moon-erp).** Not committed/pushed to main yet (owner /fullpush). Setting NOT yet exposed in FE settings screen — an admin can enable via API; FE toggle is a small follow-up (part of WP3 or a settings task). CHANGELOG bullet: HOLD until FE pieces land (feature not fully user-facing).

--- (old note) ---
WP1b was (the [FIN] listener refactor — big, delicate). Read IssueMaterials.php fully (execute :71-263 + helpers resolveLines/ownershipLegs/allocateLegLots/resolveBatchAllocations/autoAllocateFefo/createDraftInventoryIssue) + PostSaleCogsOnIssueApproved.php (template) + IssueReferenceType (ProductionOrder case). Move the block to the listener rekeyed to issued_quantity; gate IssueMaterials auto-approve on the setting; keep 21 tests green; add ON-path tests (draft held, no effects till approval, then approval applies at issued qty). Register listener in Production EventServiceProvider.

## Invariant to protect (SACRED)
Setting OFF ⇒ IssueMaterials path byte-for-byte unchanged; existing production/inventory tests green. No double WIP posting; consumed_quantity never drifts from actual issued; cancel-after-approval still reverses cleanly.

## Trace findings (code-verified, opus) — the deferral plan

**Core design (hazard #1 resolution): ONE code path.** Move ALL production-side effects from `IssueMaterials.php` inline (lines 112-260) into a NEW listener `Modules\Production\Listeners\ApplyMaterialIssueOnApproval` on the existing `InventoryIssueApproved` event (fired synchronously inside `ApproveIssue.php:281`), mirroring `Modules/Sales/app/Listeners/PostSaleCogsOnIssueApproved.php` (reference_type guard + idempotency). Register in `Modules/Production/app/Providers/EventServiceProvider.php` `$listen` (:34, shouldDiscover=false).
- **Setting OFF (default):** `IssueMaterials` creates draft InventoryIssue → calls `ApproveIssue->execute()` inline (as today) → listener fires in the SAME transaction → identical behavior, just relocated. SACRED invariant holds.
- **Setting ON:** `IssueMaterials` stops after creating the draft (no ApproveIssue). Keeper edits (InventoryIssueController::update, draft-only, :175-214) + approves (approve endpoint :389) → listener does the production work.

**Deferred set (IssueMaterials.php:120-260 → listener), REKEYED to `InventoryIssueItem.issued_quantity`+stamped `unit_cost` (actual moved, not requested):** MfgMaterialIssue header+lines (:120-191) · batch allocated_pending holds + per-leg lot allocation + consignment recordIssue (:161,:204) · reservation release (:220-228) · ProductionOrderMaterial.consumed_quantity/actual_cost (:235-238) · order.actual_material_cost accumulation (:242-247) · Released→InProcess flip + actual_start_date (:250-253) · MaterialIssued dispatch → WIP JE (:260). Ownership legs (own/customer) must re-derive from issued_quantity too.

**Setting:** add `production.material_issue_requires_approval` (boolean, default 'false', scope company) to `ProductionSettingDefinitionSeeder::getDefinitions()` (pattern: production.allow_negative_wip :78-88). Read via `SettingsService::getBool($key,$companyId)`.

**Key refs:** IssueMaterials.php (execute :71, createDraftInventoryIssue :469, warehouse :473) · ApproveIssue.php:281 (event) · InventoryIssueApproved.php · InventoryIssueController (update :175, approve :389) · ProductionOrderMaterial.php (:235 reflect-back, remainingQuantity :64) · ProductionOrderStatus.php:74 canIssue · CancelMaterialIssue.php · ProductionOrderController::issueMaterials :583.

## [FIN] HAZARDS (must guard)
1. **Double-WIP:** listener MUST guard reference_type===ProductionOrder + be idempotent (skip if MfgMaterialIssue exists for this inventory_issue_id). Exactly-once (inline OR listener, never both) → single path via listener solves it.
2. **consumed_quantity drift:** reflect-back off issued_quantity (actual), not requested. Fixes pre-existing partial-issue drift too.
3. **Cancel interactions:** an unapproved draft has NO MfgMaterialIssue → cancel via Inventory draft destroy/reject, NOT CancelMaterialIssue. FE must not offer "cancel material issue" for unapproved drafts.
4. **Status-flip timing:** Released→InProcess must move to the listener (else order goes in-process on a pending issue).
5. **Warehouse override vs reservation:** hold sits on source_warehouse_id (release). Override → StockService::release finds no hold. Rule needed (WP4).

## ⚠️ FE TREE UNVERIFIED
Trace said production FE is under `/home/moonui2/www/moon-erp` but ALL session FE work was in `/home/moonui2/public_html/moon-erp` (built+deployed OK). MUST verify which tree is live before WP3/WP4 (FE). WP1 is BE-only.

## Deferrals
(none yet)
