Erp Multi Entity Setup

Production engineering guide for erp multi entity setup covering patterns, implementation strategies, and operational best practices.

Erp Multi Entity Setup enables a single ERP instance to manage multiple legal or operational entities—such as subsidiaries, divisions, or regional offices—each with its own chart of accounts, financial calendars, master data, and business processes. It matters when an organisation grows beyond a single-company ERP, typically during mid-scale expansion or acquisition integration. This setup is essential for centralised reporting, shared services, and cross-entity workflows, but it requires careful configuration to avoid data silos, duplicated effort, and reconciliation overhead.

Core Configuration Steps

Define Entity Hierarchy and Structure

Entities are defined through a tenant or entity table with the following core fields:

Each entity must have a unique code and may inherit configuration from a parent. The parent_entity_id enables a hierarchical structure—e.g., US-CHI, US-DET, US-ATL all under US.

INSERT INTO entity (entity_id, code, name, parent_entity_id, is_active, financial_calendar_id, chart_of_accounts_id)
VALUES
  ('1a2b3c', 'US', 'United States', NULL, true, 1, 101),
  ('4d5e6f', 'US-CHI', 'Chicago Branch', '1a2b3c', true, 1, 102),
  ('7g8h9i', 'US-DET', 'Detroit Branch', '1a2b3c', true, 1, 103);

Configure Shared and Isolated Data

Data is partitioned by entity using a tenant_id column on all transactional and master data tables. This column is used in joins, filters, and views. The following tables must include tenant_id:

All queries must filter or join on tenant_id. A common failure is missing tenant_id in a critical table: for example, purchase_order is not partitioned, leading to orders being visible across entities but not scoped to one.

Set Up Entity-Specific Business Processes

Each entity requires its own business process configuration, stored in a business_process table with entity_id and process_name. Key fields include:

A process is activated per entity by setting active = true and populating definition_json with a standard workflow schema:

{
  "version": 1,
  "steps": [
    {
      "name": "Submit",
      "type": "form",
      "assignee": "approver",
      "condition": "total_amount > 10000"
    },
    {
      "name": "Approve",
      "type": "approval",
      "required_approvals": 2,
      "escalation_days": 3
    },
    {
      "name": "Post",
      "type": "system",
      "action": "journal_entry.create",
      "trigger": "after_approval"
    }
  ]
}

The tenant_id is passed through all process invocations. A common mistake is reusing the same business_process record across entities without copying it per entity_id, causing one entity’s payroll rules to apply to all.

Data Flow and Cross-Entity Transactions

Inter-Entity Transfers

Transfers between entities use a transfer_order table with source_entity_id and destination_entity_id. Each transfer is processed independently per entity, but the total value is tracked at the parent level.

INSERT INTO transfer_order (transfer_id, source_entity_id, destination_entity_id, status, created_at)
VALUES ('t123', 'US-CHI', 'US-DET', 'confirmed', '2024-03-15 10:00:00');

A transfer triggers:

  1. A stock_movement in the source entity
  2. A purchase_order in the destination entity
  3. A journal_entry in both entities to record inventory transfer cost

The transfer is considered complete only when the destination confirms receipt. Failure to update status in both systems causes reconciliation gaps.

Shared Master Data and Synchronization

Master data—such as item, customer, vendor—can be shared across entities. Shared data is marked with is_shared = true in the master table. A master_data_sync job runs every 5 minutes, pulling changes from the shared entity and pushing to all tenant entities.

The job uses a change data capture (CDC) table:

CREATE TABLE master_data_change (
  change_id SERIAL PRIMARY KEY,
  entity_id UUID NOT NULL,
  table_name TEXT NOT NULL,
  operation TEXT CHECK (operation IN ('create', 'update', 'delete')),
  record_id UUID NOT NULL,
  payload JSONB NOT NULL,
  processed_at TIMESTAMP WITH TIME ZONE,
  processed_by TEXT
);

A sync_job script processes all unprocessed changes:

#!/bin/bash


set -euo pipefail

DB_HOST="db-prod.example.com"
DB_NAME="erp"
SYNC_INTERVAL=300  # seconds

while true; do
  psql -h "$DB_HOST" -d "$DB_NAME" -c "
    SELECT entity_id, table_name, operation, record_id, payload
    FROM master_data_change
    WHERE processed_at IS NULL
    ORDER BY change_id
    LIMIT 100
  " | while IFS=$'\t' read -r entity_id table_name operation record_id payload; do
    # Fetch entity-specific configuration
    config=$(curl -s -H "Authorization: Bearer $API_KEY" \
      "https://api.erp.example.com/tenants/$entity_id/config")

    # Apply changes based on table name
    case "$table_name" in
      "item")
        # Update item in target entity
        curl -X PUT \
          -H "Content-Type: application/json" \
          -d "$payload" \
          "https://api.erp.example.com/tenants/$entity_id/items/$record_id"
        ;;
      "customer")
        curl -X POST \
          -H "Content-Type: application/json" \
          -d "$payload" \
          "https://api.erp.example.com/tenants/$entity_id/customers"
        ;;
      *)
        echo "Unhandled table: $table_name"
        exit 1
        ;;
    esac

    # Mark change as processed
    psql -h "$DB_HOST" -d "$DB_NAME" -c "
      UPDATE master_data_change
      SET processed_at = NOW(), processed_by = 'sync-job'
      WHERE change_id = (SELECT change_id FROM master_data_change WHERE entity_id = '$entity_id' AND record_id = '$record_id')
    "
  done

  sleep "$SYNC_INTERVAL"
done

A failure mode: if the sync_job crashes before updating processed_at, the same change is processed twice, leading to duplicate records or inconsistent states.

Entity-Specific Reporting and Analytics

Reporting Scope and Data Isolation

All reporting queries must include tenant_id in the WHERE clause and GROUP BY clauses. A common mistake is querying sales_order without filtering by tenant_id, leading to aggregated totals across all entities, which misrepresents performance.

To generate a report for a single entity, use:

SELECT
  e.code AS entity_code,
  e.name AS entity_name,
  COUNT(so.order_id) AS order_count,
  SUM(so.total_amount) AS total_revenue
FROM sales_order so
JOIN entity e ON so.tenant_id = e.entity_id
WHERE so.order_date >= '2024-01-01'
  AND so.tenant_id = 'US-CHI'
GROUP BY e.entity_id, e.code, e.name;

A missing tenant_id in the JOIN causes data from other entities to leak into the report.

Cross-Entity Consolidation

Consolidation combines financial data from all entities under a parent. The process uses a consolidation_run table and a consolidation_job script:

CREATE TABLE consolidation_run (
  run_id SERIAL PRIMARY KEY,
  parent_entity_id UUID NOT NULL,
  start_date DATE NOT NULL,
  end_date DATE NOT NULL,
  status TEXT CHECK (status IN ('queued', 'running', 'completed', 'failed')),
  started_at TIMESTAMP WITH TIME ZONE,
  completed_at TIMESTAMP WITH TIME ZONE
);

A consolidation_job aggregates data from each child entity:

#!/bin/bash
# consolidation-job.sh

set -euo pipefail

PARENT_ENTITY="US"
START_DATE="2024-01-01"
END_DATE="2024-12-31"

# Create run record
run_id=$(psql -h "$DB_HOST" -d "$DB_NAME" -c "
  INSERT INTO consolidation_run (parent_entity_id, start_date, end_date, status)
  VALUES ('$PARENT_ENTITY', '$START_DATE', '$END_DATE', 'queued')
  RETURNING run_id
" | tail -n 1)

# Get all child entities
children=$(psql -h "$DB_HOST" -d "$DB_NAME" -c "
  SELECT entity_id FROM entity WHERE parent_entity_id = '$PARENT_ENTITY'
" | tail -n +3)

# For each child, fetch financials and insert into consolidated table
for child in $children; do
  # Fetch financials from child entity
  financials=$(curl -s "https://api.erp.example.com/tenants/$child/financials?from=$START_DATE&to=$END_DATE")

  # Insert into consolidation table
  psql -h "$DB_HOST" -d "$DB_NAME" -c "
    INSERT INTO consolidated_financials (run_id, entity_id, period, revenue, expenses, net_income)
    VALUES ($run_id, '$child', '2024', 125000, 80000, 45000)
  "
done

# Mark run as completed
psql -h "$DB_HOST" -d "$DB_NAME" -c "
  UPDATE consolidation_run
  SET status = 'completed', completed_at = NOW()
  WHERE run_id = $run_id
"

A silent failure: if the consolidation_job runs but the consolidation_run status is never updated to completed, subsequent jobs may process the same period twice.

Common Failure Modes and Sharp Edges

Tenant-Scoped Configuration Overwrites

Configuration values are stored in a config table with entity_id, key, value, and scope (e.g., system, module, user). When a config is read, the system looks for:

  1. Global defaults (entity_id IS NULL)
  2. Entity-specific (entity_id = X)
  3. Module-specific (entity_id = X AND scope = 'inventory')

A common error: a config key inventory.default_warehouse is set at the global level but overridden at the US-CHI level, yet the US-DET entity uses the global value. The system must explicitly merge configurations, and the order of lookup matters.

Missing Tenant Context in API Requests

API endpoints expect a X-Tenant-ID header. A service call to GET /api/v1/invoices without X-Tenant-ID: US-CHI returns invoices for the default tenant, not the intended one. The header must be propagated through all microservices.

A failure: a POST /api/v1/sales-order without X-Tenant-ID creates an order in the wrong entity, with no error logged or alert. The system assumes the tenant is derived from the request body, but the tenant_id field is optional and defaults to NULL.

Duplicate Entities with Same Code

Each entity.code must be unique within the system. A duplicate code causes data drift: two entities with code = 'US' but different entity_ids, leading to confusion in reports and transfers. A validation job checks for duplicates:

SELECT code, COUNT(*) AS count
FROM entity
GROUP BY code
HAVING COUNT(*) > 1;

The job must be scheduled daily. A missing UNIQUE(code) constraint on entity leads to silent duplication, especially during data migration.

Entity Activation and Deactivation

When an entity is deactivated (is_active = false), the system:

A failure mode: a deactivated entity still receives incoming transfers, but the transfer order is not confirmed, leading to unprocessed inventory. The system must enforce is_active checks in all business logic, including validation in APIs and background jobs.

Key Takeaways

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.