# LEDGER — ISS-2026-9166: per-line warehouse + consignor on production material issue (Phase 1)

**Date:** 2026-07-19 · hazemdev2 both repos · dev DB moonui2_dev_be (forward-only) · **Opus organizes+reviews; opus sub-agents implement (Codex blocked by cPanel/CageFS).**
**Source:** Portal ISS-9166 analysis + client scope_freeze step 4668 «أخذ بالتوصية ×3». Architect design gate (2026-07-19) → this LEDGER.

**Owner/client acceptance:** On the production-order material issue, each material LINE can pick its source warehouse (own OR consignment/امانات) and, for consignment, the specific customer (`owner_partner_id`) — free per line, decoupled from the toll contract. A consignment line = CUSTOMER material: off-book, updates that customer's consignment ledger, does NOT hit WIP. Own lines hit WIP as today. OFF-path (no per-line override) = byte-for-byte unchanged.

## Frozen decisions (client + architect)
- **D-owner precedence:** per-line `owner_partner_id`: NULL ⇒ derive as today from order material `supply_source`/own_qty/customer_qty (auto-split); `0` ⇒ whole line OWN (WIP); `>0` ⇒ whole line CUSTOMER for that partner (off-book + ledger, no WIP). The override REPLACES the auto-split for THAT line only; untouched lines keep the split.
- **D-warehouse:** per-line `warehouse_id`: NULL ⇒ use the issue-level warehouse (byte-for-byte). Consignment line requires `Warehouse.is_consignment` + a valid partner; own line requires a company warehouse.
- **D-accounting:** consignment (customer) material never hits WIP — same as today's toll/customer leg. A per-line consignor == today's toll leg with the customer substituted.
- **Architect ambiguity defaults (my calls — client said «خد بالتوصية»; surface the ★ ones in verification):**
  1. ★ Homogeneous per line — an overridden line is wholly own OR wholly one customer (no intra-line own+customer split); untouched lines keep the supply_source auto-split.
  2. Overriding own→customer leaves the order's own reservation to be released at close (accepted).
  3. ★ Consignment shortage → HARD-BLOCK via `InventoryLotBalance::forOwner($owner)->live()` pre-check (clean 422). No allow-negative for consignment.
  4. Explicit `owner=0` reclassifies a customer/split material to OWN (pushes that qty to WIP) — user owns the decision.
  5. Keep the dialog-level warehouse picker as the per-line DEFAULT/fallback (not removed).
  6. Inherited: a production issue from a non-source own warehouse checks gross stock (pre-existing WP4 behavior) — accepted.

## WP table
| WP | Scope (one dense line) | Repo | Depends | Review | Migration? | Status | Commits |
|----|------------------------|------|---------|--------|-----------|--------|---------|
| WP1 | [migration] add columns + fillable. | BE | — | opus | yes | ✅ done | `fa902d4ce` · migration ran on dev (cols confirmed) · InventoryIssueApiTest 27 + Ownership 13 pass · schema-only, no review needed |
| WP2 | [FIN] ApproveIssue per-item warehouse. | BE | WP1 | code-reviewer(opus) | no | ✅ done | `b206daff1` · 5 seams per-item ($wh=item.wh ?? issue.wh) · regression 33 pass + PerItemWarehouseIssueTest 2 · review APPROVE (0 crit/high, NULL-path byte-for-byte). **2 MEDIUM folded forward:** M1 lock sort-key needs +$wh (→WP3); M2 COGS credits issue-wh account (sales stay single-wh, WP3 production-only → safe); LOW Warehouse::find not company-scoped (→WP4 validation). |
| WP3 | [FIN] IssueMaterials per-line owner+warehouse routing (+ WP2-M1 lock-key fold). | BE | WP2 | code-reviewer(opus) | no | ✅ done | `a158c5cea` · 70 pass (incl 3 new PerLineConsignor) · review WARNING: inline+OFF-path byte-for-byte CLEAN (0 crit), **1 HIGH latent** — keeper-approve drops consignor owner (listener rebuild omits owner_partner_id; applyEffects reads owner from $line not $item) → WIP leak on gated path. NOT live (controller strips fields). **Fix in WP5 (owner-from-item) BEFORE WP4 exposes fields.** MEDIUM: add gated-keeper consignor test. |
| WP4 | [FIN] validation + guards + consignment availability pre-check. | BE | WP3,WP5 | code-reviewer(opus) | no | ✅ done | `7554164a6` · controller validation (company-scoped; owner=0 sentinel via closure) + assertPerLineRouting (is_consignment guards + forOwner->live() ≥ qty → 422) + i18n · 6 new + 66 regression pass |
| WP5 | [FIN] keeper-approve owner-from-item fix + gated test + held echo. | BE | WP3 | code-reviewer(opus) | no | ✅ done | `13ade724d` · review APPROVE (0 crit/high/med, closes WP3-HIGH at source, OFF-path byte-for-byte). LOW: held-echo first-wins routing lossy for a material with MIXED per-line routing → WP6 handles (reject mixed, or carry per-line). |
| WP6 | FE: per-row warehouse + consignor pickers. | FE | WP4,WP5 | code-reviewer(opus) | — | ✅ done | `1a295b6ca` · per-row wh + consignor pickers (consignor shows on is_consignment) · sends per-line routing only when set (OFF-path unchanged) · held re-issue from row state (avoids WP5-LOW echo) · i18n · ng build green · review APPROVE (0 crit/high/med, 1 LOW no-clear-btn). Also WP4-MEDIUM (variant pre-check) fixed `54f0fc82e`. |

## DAG / serialization
BE chain: WP1 → WP2 → WP3 → **WP5 → WP4** (WP5 fixes the keeper owner-drop before WP4 exposes the fields) → WP6. One writer per repo. WP2 (shared ApproveIssue) and WP5 (listener rebuild) = highest risk → extra review (architect re-consult). Execute strictly sequential on BE.

## Baseline (2026-07-19, sqlite CLI php — clean tree, no 9166 code)
- ProductionMaterialOwnershipTest + ProductionOverIssueApprovalTest = **35 passed / 0 failed**.
- InventoryIssueApiTest + PartialIssueApprovalTest + ConsignmentFoundationTest + ConsignmentReconcileTest = **46 passed / 1 FAILED**.
- **PRE-EXISTING failure (NOT mine — clean tree):** `ConsignmentFoundationTest` → "settle-buy under DIRECT grn_mode does NOT double-increase physical…". Must NOT be blamed on any WP; still-red at end = fine (pre-existing). Everything else MUST stay green (esp. own-issue + toll leg + partial-issue + ApproveIssue sales-GDN invariants).

## Deferrals
- Phase 2 (standalone "create stock issue → pick production order → issue against it" wired to IssueMaterials/WIP) — OUT of this scope; separate ticket/phase.
- Reservation-at-non-source and gross-stock-check edges (§7-2, §7-6) — inherited, not addressed here.

## Notes
Big [FIN] feature touching the shared ApproveIssue + the ISS-9105-reworked IssueMaterials. Opus owns ledger + gates. code-reviewer per WP; architect re-consult on WP2/WP3/WP5. Fable EMERGENCY-ONLY. No merge/main/deploy-to-client (owner /fullpush); deploy /app for testing. Update ISS-9166 verification when done. Decisions/comms via the ticket ([[decisions-via-ticket-not-chat]]). Implementer = opus sub-agents (Codex not runnable on this cPanel/CageFS host — see memory [[opus-organizes-codex-implements]]).

## STATUS: ✅ COMPLETE (2026-07-19) — all 6 WPs done+reviewed (APPROVE). BE fa902d4ce/b206daff1/a158c5cea/13ade724d/7554164a6/54f0fc82e · FE 1a295b6ca. Whole-feature sweep 74 pass (0 new failures; ConsignmentFoundationTest settle-buy still pre-existing red). Deployed /app main-KMJW5L64.js. CHANGELOG bilingual bullet added. Awaiting owner /fullpush + client test. Phase 2 (create-issue→pick-order) deferred.

## Refinement R (client feedback step 4767, 2026-07-19): per-line routing on the STOCK-ISSUES screen too
Client edits the draft إذن صرف (GDN-000043, from a production order) on `/core/stock-issues` → «تعديل إذن الصرف» and the per-row warehouse/consignor controls aren't there (WP6 put them only on the production-orders issue dialog). The keeper manages the draft here → add the per-item pickers to this screen.
- **R1 (BE) ✅ `390d50570`:** Store/Update accept + persist items.*.warehouse_id + owner_partner_id (company-scoped; owner=0 sentinel via closure) + InventoryIssueItemResource echoes them; 5 tests (32 pass).
- **R2 (FE) ✅ `af494d7a0`:** per-row warehouse + consignor pickers on the stock-issues «تعديل إذن الصرف» dialog (consignor on is_consignment; re-hydrates on edit; sends only when set). ng build green. **Both reviewed APPROVE (0 findings).** Deployed main-QMJYU6KY.js.
- Approval routing already works (ApproveIssue WP2 + applyEffects WP3/WP5 read per-item from persisted items). WP4 clean-422 pre-check is production-path-only; on the keeper approve, LotAllocationService is the hard guard (throws on shortage, rolls back). Acceptable.

### Refinement R3 (client feedback step 4775, 2026-07-19): per-ROW on-hand badge
- Client: warehouse-per-row works now, **but** the «المخزون» availability badge must reflect the row's warehouse (and for a consignment row, the consignor's held qty), not the dialog main warehouse.
- Root cause: `loadStockForProduct(productId)` filtered by the DIALOG warehouse + keyed `stockQtyMap` by product/variant (shared across rows), no owner lens.
- **R3 (FE):** `stock-balance.service.listFiltered` now forwards `owner` (null→physical `available_quantity`, >0→customer lens `owner_quantity`). `stock-issues.component`: `loadStockForRow(index)` resolves per row = effective warehouse + owner lens, `stockQtyMap` re-keyed by ROW INDEX, `getRowStockQty(i)`. Reload wired on product/variant/row-warehouse/consignor/dialog-warehouse change + `removeItem` full-reload (index shift). Display-only — no submit/approval-path change. `ng build` green. CHANGELOG bullet (9166) extended. **Review APPROVE (0 in-scope defects).** Deployed `main-4WY74576.js`.
- **Deferral (LOW, pre-existing, out of scope):** sibling index-keyed maps `rowSelectedSerials` + `rowLotAllocations` are NOT reindexed on mid-list `removeItem` (consciously accepted per existing comment ~L1149) — R3 reindexed only the display badge map. These two feed the submit payload, so mid-list removal can misalign serials/lot-allocations with rows. Flag to owner as a separate follow-up (not part of 9166).
