# Multi-Branch System (الفروع) — analysis & generalization

> **Status: 🔬 ANALYSIS PHASE (started 2026-06-20).** 3 parallel agents are mapping branches across the codebase (foundation + transactions + LIS/Accounting/HR/Manufacturing). This file is the owner-requested KB record ("where we reached + what we'll do + the existing logic"). The HTML study + the implementation plan are appended once the agents return. Codex will verify the implementation behind us.

## The goal (owner, 2026-06-20)
Make **branches a first-class, generalized concept across ALL core modules** — Core, Accounting, Purchases, Inventory, Sales — and fix HR + Manufacturing. Requirements:
- A **branch** has **one or more treasuries (خزنة/cash-boxes)**, **one or more warehouses**, and **employees**. An **employee can belong to more than one branch**.
- There must be a **main branch + main warehouse + main treasury**.
- When a **user logs in / is bound to a branch**, transactions should **default that branch's warehouse + treasury** (at least as a preferred default) instead of the user **picking them on every single invoice**.
- **Proper isolation** so each branch's data/defaults are correct.

## The big problem the owner hits
- **Creating a purchase bill/invoice has NO warehouse specified at all** — and more generally, any transaction that needs a **warehouse** or a **treasury** doesn't get a branch-bound default, forcing a manual pick every time (or leaving it empty → downstream bugs, e.g. the sales-invoice no-warehouse → no-stock-deduction bug already fixed, see [[setup-wizard-settings]]).

## Existing logic to reuse (the LIS/lab pattern)
There is **already branch logic built for the lab** — the reference pattern to generalize:
- **Branch cash routing resolves from the logged-in user's `primaryBranch()`** (not a query param). Lab payments route to a **per-branch cash box**: `payments/routing` resolves every method to a live GL account, and branch cash boxes (e.g. كاش جدة, كاش المدينة 2) are tied to a branch. Treasuries/payment-methods carry per-instance GL `account_id`s (see the elmadina treasury work in MEMORY + [[lis-receivable-header-account-trap]]).
- So the lab already: (a) knows the user's branch, (b) picks the branch's cash box automatically. **Generalize this to Sales/Purchases/Inventory** (and add the warehouse equivalent).

## International / standard practice (to confirm in the study)
Typical multi-branch ERP: a user has a **default/home branch**; documents default `branch_id` + the branch's **default warehouse** + **default cash/bank account**, overridable per-document; branch is a **posting dimension** on the GL; warehouses & cash-boxes **belong to a branch**; reports filter by branch; optional **branch isolation** (a user only sees their branch(es)). Employees↔branches is many-to-many.

## Plan
1. ✅ Launch 3 analysis agents (foundation / transactions / LIS+Acc+HR+Mfg).
2. ⏳ Synthesize → **HTML study** (served at moonui) describing the CURRENT state per module + gaps + the target design.
3. ⏳ Implementation plan: branch-bound **default warehouse + default treasury** resolved from the user's branch, applied across Sales/Purchases/Inventory (mirroring the LIS routing); main-branch/main-warehouse/main-treasury flags; HR multi-branch employees; fix purchase-bill-no-warehouse.
4. ⏳ **Codex verifies** the implementation.

## 🔨 Progress (Phase 1 — started 2026-06-20, on `hazemdev`, deployed to dev BE)
- ✅ `warehouses.is_default` flag (migration `2026_06_20_120000`, run + verified) + **`Warehouse::defaultIdForBranch($branchId,$companyId)` / `defaultIdForUser($user)`** resolver (flagged default → branch's sole warehouse → company default). Commit `bb6f22217`.
- ✅ **Purchase-bill no-warehouse fix + the silent GL-vs-stock divergence CLOSED** (commit `c77294047`, after Codex review HIGH findings):
  - `syncItems()` + `createFromOrder()` fill a missing line `warehouse_id` from the **bill's branch** default (`defaultIdForBranch`, not the user's primary — they can differ).
  - **GUARD** in `PostPurchaseBill::handleDirectModeStock()`: in direct mode, **reject posting any bill with a tracked line missing a warehouse** (throws inside the post transaction → rolls back the JE too) — so the inventory GL is never debited without receiving stock. Bilingual msg `bill_line_missing_warehouse`. This is the authoritative backstop (creation-time default is the convenience layer).
- ✅ **`is_default` toggle on the Warehouses screen** (BE `c5c99d22f`: Store/Update requests + `WarehouseController::enforceSingleDefault` one-per-branch + resource; FE: toggle + hint). Owner flags THE default warehouse per branch.
- ✅ **Cross-document branch-default warehouse** (Codex-reviewed **LGTM**): BE backstop fills a missing header `warehouse_id` from the branch default in `SalesInvoiceController` + `PurchaseOrderController` store(); FE `BranchContextService.primaryBranchId()` + `defaultWarehouseId()` **prefill** the warehouse on `openNew()` in sales-invoice, purchase-order, the 5 inventory editors + warehouse-transfer (FROM only) — set only when blank, never on edit. `BranchResource` now exposes pivot `is_primary` for the FE. **Codex fix applied:** FE `primaryBranchId()` was `is_primary→is_main→[0]`; corrected to `is_primary→first-by-id` to mirror BE `User::primaryBranch()`.
  - 📝 **Note (Codex):** `defaultIdForBranch` falls back to a **company-wide** flagged default if the branch has multiple warehouses + none flagged → can prefill a warehouse from another branch. Intentional (KB design) + harmless (prefill only, overridable); tighten under Phase 2 isolation if needed.
- ✅ **First-warehouse fallback** (owner: even with no flagged default, pick the branch's FIRST warehouse — better than blank). `Warehouse::defaultIdForBranch` + FE `defaultWarehouseId` now: flagged default → **first branch warehouse by id** → company default → null. Commits FE `ebd93d5`, BE `5a09d3b28`.
- ✅ **Sales ORDER got a header warehouse field** (was missing from the FE entirely though the BE had `warehouse_id`). Added + prefilled + payload. Commit `3a6a04e`. **Verified live (headless):** new order for the Jeddah admin pre-fills `مخزن جده`.
- ✅ **Stale-setting trap FIXED** (`4a730db`): `sales/purchases.default_warehouse_id` on this fleet holds `'1'` whose warehouse **doesn't exist** → the picker bound to a non-existent option = BLANK. Sales-invoice + purchase-bill now use the setting **only if it points to a real loaded warehouse**, else the branch default. **This was the owner's "no warehouse on the invoice" symptom.** Verified live (invoice pre-fills `مخزن جده`).
- ✅ **Bill prefill made consistent with the invoice** (`9d72975`): bill used `warehouses()[0]` (any branch) while invoice used the branch default → different warehouses. Bill now uses `BranchContextService.defaultWarehouseId()` too.
- ⏳ **Remaining Phase 1:** per-branch **treasury** routing (mirror LIS `LabPaymentController::routing` + the document-form-unification `<treasury-picker>` + `defaultTreasuryAccountId`); fix broken `$user->branch_id` fallbacks (`SalesReturnController:125`, `PurchaseReturnController:159`). Then Phases 2-5 (isolation `DataScope` · GL branch dim on JE · HR `employee_branch` pivot · Manufacturing stamp+filter).
- **Test users (dev BE `moonui_dev_be`, co 4):** `admin@moonerp.com`/password = user 3, primary branch **5 (جده)** which has 2 warehouses (WH10 مخزن المصنع الرئيسي, WH12 مخزن جده), none flagged `is_default` → resolver returns the first (WH10) / after the owner flags one, that. `hazem@gt4it.com` = user 6, primary branch 8 (الرياض) with NO warehouse → resolves null.
- **All Phase-1 commits on `hazemdev`** (FE moonui `/app` + BE local-deploy'd) — **NOT released**; moontest needs a release. Releases 3.0.13/3.0.14 were cut earlier this session (setup + product unit/search).

## Findings (3-agent code sweep, 2026-06-20) — VERIFIED file:line

### The generalizable kit ALREADY in Core (proven in LIS, just needs lifting)
- **`branch_user` pivot** (`database/migrations/2026_02_20_100000_create_branch_user_table.php`) — `branch_id, user_id, is_primary`. This migration **dropped the old single `users.branch_id`** + backfilled → the system already evolved User from single-branch → **many-to-many**.
- **`User::primaryBranch()`** (`app/Models/User.php:80`) — resolves pivot `is_primary` → first assigned → company `is_main` branch → first branch. **Never null.**
- **`Branch.is_main`** (`app/Models/Branch.php`) = the main/default-branch flag; `Branch::mainFor($companyId)`.
- **`Modules\Core\Support\DataScope`** — the reusable engine: `apply($query,$user)` filters reads by role `data_scope` (all/branch/own) + optional `X-Branch-Id`/`?branch_id`; **`operatingBranchId($user)` = `primaryBranch()->id` to STAMP writes**. `LisDataScope` is now a deprecated shim extending Core. **BUT `DataScope::` is currently CALLED ONLY in LIS** (+ FE `X-Branch-Id` sent only on `/lis/` URLs via `auth.interceptor.ts`).

### Two distinct mechanisms (key mental model)
- **STAMP (write):** new records always get `operatingBranchId` = the user's **primaryBranch** (regardless of any filter). Used in LIS, POS held-orders, purchase requests, MRP.
- **FILTER (read):** an explicit `?branch_id` or the `X-Branch-Id` header (FE selector) — **LIS-only** today.

### Warehouse ↔ Branch / Treasury ↔ Branch
- `warehouse.branch_id` exists (nullable; `null` = shared). **NO `is_default` warehouse-per-branch flag.** `type=main` is a company-wide classification, not per-branch.
- **Treasury = Accounting `PettyCash`** (cash box). `petty_cash.branch_id` exists; **NO default flag**. Bank accounts use a `bank_account_branch` pivot (inconsistent model).
- **`*.default_warehouse_id` settings exist for sales/inventory/purchases/pos (scope `branch`) but are DEAD — NO BE reads them**; only 2 FE forms prefill (sales-invoice, purchase-bill).

### 🔴 The big problem — purchase BILL silent GL-vs-stock divergence (root cause)
- `PurchaseBill` header has **NO `warehouse_id` column** (`Models/PurchaseBill.php:25`, migration `…400001`). Warehouse is on **items**, **nullable** (`…400002:18`, `StorePurchaseBillRequest.php:35`). FE has a header field that prefills + propagates to lines, but the **BE drops the header**. If the prefill resolves null → lines saved `warehouse_id=null` → on post `PostPurchaseBill.php:208` **SKIPS null-warehouse lines (no stock received) but `:96-99` STILL debits the Inventory GL** → **books say stock up, warehouse says no.** (GRN-mode is immune: GRN header warehouse is NOT NULL.)

### Per-module verdict
| Module | Branch on docs | Stamped on create | Read-scoped by branch | GL/cost per branch | Verdict |
|---|---|---|---|---|---|
| **LIS** | yes | yes (`operatingBranchId`) | yes (`DataScope`+`X-Branch-Id`) | yes (cash→branch PettyCash GL) | **REFERENCE / complete** |
| **Sales/Purchases/Inventory** | header has `branch_id` (mostly) | only purchase-bill (`PurchaseBillController:146`) + purchase-request; rest no | **NO** | no | branch is **decorative**; no default wh/treasury; **manual pick every doc** |
| **Accounting** | vouchers/expenses/checks/petty-cash yes; **JE no** | partial | vouchers yes; JE/reports no | **NO — GL branch-blind** (cost_center ≠ branch) | branch lost at posting |
| **HR** | `Employee.branch_id` SINGLE FK (not even nullable, required) | manual | employee+payroll yes | payroll per-branch | **single-branch — can't put a staff member in >1 branch (no `employee_branch` pivot)** |
| **Manufacturing** | `branch_id` columns everywhere (116 refs) | **NO → NULL** | **NO** | no (NULL kills it) | **field exists, logic missing — orders born branch-less + unfiltered** |

### The LIS reference cash-routing chain (to generalize)
`user → primaryBranch() → branch PettyCash box → for is_cash methods remap account → receiving_account_id on payment → JE debits the branch cash GL`. Endpoint `LabPaymentController::routing()` (`:42-104`): `$branchId = $user->primaryBranch()?->id` → `$boxes->firstWhere('branch_id',$branchId)` → override `is_cash` methods' account. (⚠️ `firstWhere` = silent pick if a branch has 2 boxes; no cross-branch override.)

## Decisions (owner, 2026-06-20) — BAKED INTO THE PLAN
1. **Operating branch = the user's `primaryBranch()`** (writes always stamped to it; simplest; matches LIS). A user in 2 branches has one `is_primary`.
2. **Real isolation via `data_scope`** (generalize LIS's): a restricted (`branch`) user sees only their branch(es)' data; **a user assigned to ALL branches (or role `data_scope=all`) sees everything.**
3. **`warehouses.is_default` flag per branch** is the source of "the branch's default warehouse" (NOT the dead setting). Owner sets it on the Warehouses screen.

## Implementation plan (phased — backend-first, Codex verifies each)
> Full rationale + per-module detail in the [HTML study](https://moonui.elbaset.com/branches-study.html).
1. **Phase 1 — branch-bound DEFAULT WAREHOUSE + TREASURY (fixes the daily pain + the bill divergence).** Add a real "default warehouse per branch" source (add `warehouses.is_default` per branch **or** finally READ the dead `*.default_warehouse_id` setting). In every Store controller, default a missing `warehouse_id` from `primaryBranch()` (like `PurchaseBillController:146` already does for branch). **Bill fix:** fill each line's missing `warehouse_id` from the branch default before save so `PostPurchaseBill:208` never skips. Route `receiving/paying_account_id` from the branch cash box (mirror LIS). Fix the broken `$user->branch_id` fallbacks (`SalesReturnController:125`, `PurchaseReturnController:159`).
2. **Phase 2 — generalize `DataScope` (stamp + read-scope) to Sales/Purchases/Inventory/Production** + send `X-Branch-Id` beyond `/lis/` + a global branch selector (owner-gated isolation).
3. **Phase 3 — Accounting GL branch dimension:** `branch_id` on JE (header + line), propagate from source docs at posting, add branch filter to `GeneralLedgerService` + reports → branch P&L / balance sheet.
4. **Phase 4 — HR multi-branch employees:** `employee_branch` pivot mirroring `branch_user`; `Employee::branches()`/`primaryBranch()`; `PayrollService` queries all assigned branches.
5. **Phase 5 — Manufacturing:** stamp `operatingBranchId` on production-order create + branch filter on index/statusCounts + FE selector.

---
_Related: [[setup-wizard-settings]] (the sales-invoice no-warehouse→no-stock bug), [[lis-receivable-header-account-trap]] (treasury/GL account mapping per branch)._
