Files
ERP-core/docs/BRANCH_ISOLATION_PLAN.md
2026-09-08 12:13:49 +05:30

11 KiB
Raw Permalink Blame History

Branch Isolation Plan

Summary

Today Branch exists only inside HRM (docs/12-BACKEND-HRM.md C.1) — a bare org unit (BranchId, Code, Name, Address?, Status) referenced by Department and, optionally, PayrollRun. It has no relationship to inventory, procurement, or sales. docs/00-CORE.md §1 explicitly declares the system single-tenant, and none of the 42 Phase‑1 entities (docs/10-BACKEND-PHASE1.md Part C) or the Sales entities (docs/14-BACKEND-SALES-API.md) carry a branch_id.

The ask: make branches a real operational boundary. Requested split —

  • Stay global (shared, one copy for the whole company): Category, SubCategory, Brand, Uom, Item.
  • Everything else must be branch-isolated — each branch sees and operates on its own rows: warehouses/bins, vendors, GRNs, purchase orders, requisitions/RFQs, stock (layers/ledger/transfers/adjustments/counts), sales invoices/slips, purchase invoices, customers.

This doc is a plan, not a spec — same status as docs/SALES_MODULE_PLAN.md before its Phase 1 shipped. Nothing here is implemented yet. Once agreed, the entity-level detail moves into 10-BACKEND-PHASE1.md/14-BACKEND-SALES-API.md in place (per 01-DOC-GUIDE.md §5 — one source of truth, no forked copies) and this file is retired or reduced to a changelog pointer.


Why the item catalog stays global

Item, Category, SubCategory, Brand, Uom define what a product is, not where it is. Splitting the catalog per branch would mean the same SKU could mean different things in different branches, and every catalog edit (price, tax class, UOM conversion) would need replaying across branches. Keeping them global matches how ITEM_REORDER already works today: it is the join that ties a global item to a specific warehouse_id for reorder policy — the same pattern extends naturally to branches. Vendor and Customer are not in the "stays global" list per the request above, even though some ERPs share them company-wide; see Open Questions §7 for the trade-off.


Anchor mechanism: Warehouse carries the branch

Rather than stamping branch_id onto every one of the ~30 remaining entities, the plan anchors branch scoping at Warehouse, then lets everything that already references a warehouse inherit its branch transitively:

WAREHOUSE(warehouse_id PK, branch_id FK→BRANCH [NEW, required], code, name)

Entities that already carry warehouse_id (directly or via their lines) need no new column — their branch is warehouse.branch_id, one join away:

  • ITEM_REORDER, GRN (header), PURCHASE_RETURN, STOCK_LAYER, STOCK_LEDGER, STOCK_TRANSFER (src/dest — see §5 for the cross-branch-transfer question), STOCK_ADJUSTMENT, STOCK_COUNT.

Entities that have no warehouse at all today, or only carry one at line-level while the header is branch-relevant on its own (numbering, listing, approval routing), get an explicit branch_id:

  • REQUISITION, RFQ, PURCHASE_ORDER, VENDOR, CUSTOMER, SALES_INVOICE, SALES_SLIP, PURCHASE_INVOICE (the AP entity introduced alongside GRN payments — see Backend/ERPCore PurchaseInvoiceService), BIN (via its warehouse — no new column needed, listed here only for completeness).

PO_LINE currently carries its own warehouse_id, independent of anything on PURCHASE_ORDER. Once Warehouse.branch_id exists, a PO whose lines target warehouses in two different branches becomes a real modeling conflict — see Open Questions §1.


Entity classification

Group (per 10-BACKEND-PHASE1.md Part C) Entity Branch scope
C.1 Master Data Category, SubCategory, Brand, ItemType, Uom, Item Global
C.1 Master Data ItemReorder Branch-scoped (via warehouse_id)
C.1 Master Data Vendor Branch-scoped (new branch_id)
C.1 Master Data Warehouse, Bin Branch-scoped (Warehouse gets the anchor branch_id; Bin inherits via Warehouse)
C.1 Master Data ProductConfig Global (singleton feature-gate config; not a data record)
C.2 Procurement Requisition, RFQ, VendorQuotation, PurchaseOrder, PurchaseReturn Branch-scoped (Requisition/RFQ/PurchaseOrder get new branch_id; VendorQuotation inherits via RFQ; PurchaseReturn inherits via warehouse_id)
C.3 Goods Receipt GRN, GRN_LINE Branch-scoped (via warehouse_id)
C.4 Batch/Serial Batch, Serial Branch-scoped (via the Item+StockLayer they're received into — see Open Questions §3)
C.5 Stock Core StockLayer, StockLedger Branch-scoped (via warehouse_id)
C.6 Stock Transactions StockTransfer, StockAdjustment, StockCount (+ lines) Branch-scoped (via warehouse_id)
C.7 Cross-cutting User, AuditLog, JournalEntryStub Global (a user's access can be branch-restricted later via RBAC — see §6 — but the shadow records themselves aren't per-branch data)
C.7 Cross-cutting NumberSequence See §5 — becomes branch-scoped if per-branch doc numbering is wanted
C.7 Cross-cutting ReasonCode Global (a reason like "Damaged in transit" applies everywhere)
Sales (14-BACKEND-SALES-API.md) Customer Branch-scoped (new branch_id)
Sales SalesInvoice, SalesSlip, bundle sales Branch-scoped (via warehouseId, already present at header per types/sales.ts SalesInvoiceSummary.warehouseId)
Purchase Invoice (AP) PurchaseInvoice Branch-scoped (new branch_id, or inherited from the linked GRNs — a purchase invoice consolidates GRNs per PurchaseInvoiceService.LoadAndValidateGrnsAsync, so it should reject GRNs from mixed branches)
HRM (12-BACKEND-HRM.md) Branch itself, Department, Employee, PayrollRun Already branch-aware — out of scope, no change needed

What this doesn't solve by itself: access enforcement

Adding branch_id columns and filtering queries by "current branch" makes the data model branch-isolated. It does not, on its own, stop a user from switching their client-side "current branch" to one they shouldn't see — 00-CORE.md §1 states RBAC is deferred and 10-BACKEND-PHASE1.md C.8 confirms the existing role system is sidebar-visibility only, not per-endpoint enforcement. Real isolation (user A physically cannot fetch branch B's GRNs even by guessing an ID) needs a per-request branch claim checked server-side, which is a bigger lift than this plan alone. Recommend treating this plan as Phase 1 (data scoping + UI filtering) and real cross-branch access control as a Phase 2 that rides on top of whenever RBAC enforcement lands — flagging it here so it isn't silently assumed solved.


Frontend impact (sketch — detail belongs in 20-FRONTEND.md once agreed)

  • A "current branch" selector (similar in spirit to a warehouse picker) somewhere in the shell (AppSidebar/Header), persisted per session.
  • Every list/detail screen for a branch-scoped entity sends the selected branch as a filter; create/edit forms stamp it automatically rather than exposing a raw branch picker on every form.
  • Warehouse pickers throughout procurement/stock/sales screens narrow to the current branch's warehouses only, once Warehouse.branch_id exists.
  • Category/Brand/UOM/Item pickers and their settings screens are unaffected — they stay branch-agnostic everywhere they're used today.

Migration phasing (suggested)

  1. Schema: add Branch FK to Warehouse (backfill: create one default branch, assign all existing warehouses to it — zero-downtime for current single-branch users).
  2. Schema: add branch_id to the header-only entities in §"Entity classification" that don't inherit via warehouse (Vendor, Customer, Requisition, RFQ, PurchaseOrder, PurchaseInvoice), backfilled to the same default branch.
  3. Backend: thread "current branch" through the request (claim, header, or query param — decide in Open Questions §4) and add it as a filter in the relevant repositories/services. No cross-branch validation yet (data is scoped, not yet enforced).
  4. Frontend: branch selector + filtering wired into the screens listed above.
  5. Validation: add the cross-branch guards flagged in Open Questions (§1 PO lines, §3 batch/serial, §2 transfers) once the team has decided how to handle each.
  6. (Optional, later) Per-branch document numbering (§5) and real endpoint-level access enforcement (§6), once RBAC ships.

Open questions / decisions needed

  1. Purchase Order spanning branches. PoLine.warehouse_id is independent of any future PurchaseOrder.branch_id. Decide: (a) constrain every PO to one branch — all lines' warehouses must belong to it (validated at write time), or (b) allow a PO to span branches and only branch-scope at the warehouse/stock level. (a) is simpler and matches "others must be branch-wise" more literally.
  2. Inter-branch stock transfers. StockTransfer has src_warehouse_id/dest_warehouse_id. If warehouses in different branches can transfer to each other, StockTransfer needs to be visible to both branches (or modeled as leaving-branch/entering-branch documents) rather than owned by one. Decide whether cross-branch transfers are allowed at all in v1.
  3. Batch/Serial ownership. Batch/Serial key off item_id only today, not warehouse_id. If the same batch number is received into two different branches' warehouses, is that the same Batch row (shared) or two independent ones? Affects FEFO/expiry queries.
  4. How "current branch" reaches the server. A JWT claim (parallel to how User/Role are shadowed from AuthHex — 10-BACKEND-PHASE1.md C.9), a custom request header, or a query parameter per call. A claim is the most tamper-resistant but needs AuthHex-side changes; a header/param is faster to ship but relies on the not-yet-built enforcement layer (§6) to not be spoofable.
  5. Document numbering. NumberSequence(sequence_id, doc_type, year, last_number) is global today (one GRN-2026-0001 sequence company-wide). Multi-branch ERPs commonly number per branch (GRN-COL-2026-0001). Decide if that's wanted now or deferred — it's an additive column (branch_id joins the sequence key) either way.
  6. Vendor/Customer sharing. The request says "others must be branch-wise," which this plan takes literally for Vendor/Customer. Confirm that's intended — many ERPs keep vendors/customers company-wide (one AP/AR relationship, purchases/sales tagged by branch) rather than duplicating the vendor record per branch. If shared vendors/customers are actually wanted, move both back to the "Global" row and this becomes a much smaller change (no new FK on either).
  7. Reporting across branches. Once data is isolated, does any role need a company-wide rollup (e.g. total stock value across all branches)? Not addressed here — flag if needed so the query layer isn't built branch-only from the start.

This plan follows 01-DOC-GUIDE.md's "one source of truth" rule: once decisions above are made, the actual column/entity changes are written into 10-BACKEND-PHASE1.md Part C and 14-BACKEND-SALES-API.md in place, and this file stops being duplicated content.