Core Architecture Cost Mapping Systems

Multi-Location Cost Center Architecture

Multi-unit restaurant groups face a persistent synchronization problem: a decentralized point-of-sale (POS) estate emits transactions in dozens of local dialects, yet food cost analytics must resolve every one of them to a single, location-specific ledger. This page addresses one scoped sub-problem within the Core Architecture & Cost Mapping Systems framework — how to build the cost center hierarchy that routes ingredient consumption, prep overhead, and waste to the exact operational node that incurred it, then rolls those nodes up to regional and corporate views without double-counting. Without a rigid node model, food cost percentages are diluted by cross-location transfers, shared commissary production, and inconsistent POS categorization, and every executive dashboard reports a number no franchisee can defend in an audit.

The pattern documented here is a deterministic pipeline: validate every location against a master node registry, decompose sales into ingredients using the shared recipe graph, translate POS taxonomies into procurement SKUs, and aggregate up the parent-child tree with exact decimal arithmetic. Each stage has a strict data contract, an explicit error-routing path, and an idempotency guarantee so that re-running a nightly sync never duplicates consumption.

Multi-location cost center routing and three-tier roll-up The POS estate emits transactions from many stores into a node-validation gate checked against the master registry. Orphaned location codes split off to a quarantine branch; valid rows flow through BOM decomposition and taxonomy resolution into a per-node consumption ledger. The ledger fans out to leaf cost centers — individual stores and a shared commissary. Inter-unit commissary transfers are reconciled once, before roll-up. Leaf nodes roll up to franchise-region aggregates, and regional totals are FX-normalized to USD as they consolidate into the corporate ledger that feeds the variance engine. invalid valid per-node consumption → leaf cost centers roll-up (leaves → corporate) POS Estate Store 01 · terminal Store 02 · terminal … Store N Node Validation vs master registry Quarantine orphaned location_id + reason BOM Decomposition + taxonomy resolution Per-Node Consumption Ledger location · ingredient · day Store 01 unit · USD Store 02 unit · USD Store 03 unit · EUR Commissary shared prep Transfer reconciliation shipments ↔ receiving logs FIFO / weighted-avg cost costed once, before roll-up Franchise Group — East region roll-up Franchise Group — West region roll-up × FX → USD × FX → USD Corporate Consolidated Ledger single source of truth → variance engine

Concept Definition & Data Contract

A cost center is a discrete financial node representing a physical or logical unit of production: an individual kitchen, a prep station, a service terminal, or a shared commissary. Every node carries a unique location_id, a nullable parent_id that positions it in the tree, a cost_center_type, and a currency_code. The hierarchy is a directed acyclic graph rooted at the corporate entity; leaves are the stores and stations that actually consume ingredients, and interior nodes are the regional and franchise-group aggregates that only ever receive rolled-up values.

The architecture consumes three inputs and emits one:

Contract Shape Key constraints
Master node registry location_id, parent_id, cost_center_type, currency_code location_id unique and immutable; parent_id must reference an existing node or be null (root only)
Sales ledger location_id, menu_sku, qty_sold, transaction_date every location_id must resolve against the registry; qty_sold >= 0
Recipe BOM recipe_id, ingredient_id, ingredient_weight, yield_factor yield_factor > 0; weights already unit-canonicalized to a base metric
Output: consumption ledger location_id, ingredient_id, net_consumption, transaction_date, cost one row per node/ingredient/day; cost computed with Decimal, not float

The registry is the authority. No transactional row is admitted to the consumption ledger until its location_id has been matched against an active node, which is what keeps a single mis-tagged terminal from silently corrupting an entire region’s roll-up. Ingredient weights arrive already normalized to grams and milliliters — the unit canonicalization discipline described in the parent framework is a precondition here, not a responsibility of this layer.

Architecture Decision Rationale

The central decision is whether to physically partition data per location (a separate schema or database per store) or to keep one shared model with a location dimension on every row. Multi-unit groups are tempted by physical partitioning because it feels like isolation, but it forces the recipe graph to be duplicated per store, and a single menu-wide recipe change then has to be replayed across hundreds of copies that inevitably drift. The pattern here chooses a single shared recipe graph with a location-scoped cost overlay: there is exactly one BOM, and each location supplies its own price_map and yield_map. This mirrors the roll-up strategy used across the designing recipe BOM databases guidance and keeps referential integrity intact as the estate scales.

The second decision is where the parent-child roll-up executes. A recursive CTE in PostgreSQL is the right tool when the aggregate feeds a materialized view or a SQL report, because it avoids shipping the whole edge table to the application. Vectorized pandas groupby on a parent_id join is preferable when the roll-up is one stage of a larger batch that also does ingestion, decomposition, and logging in the same process. The reference code below uses the pandas path because most groups run this as a nightly batch, but the node registry and the reconciliation rules are storage-agnostic and translate directly to a recursive CTE.

The third decision is monetary precision. Binary floating point cannot represent most decimal cents exactly, and a franchise estate adds thousands of line items per location per night; the drift accumulates into discrepancies that fail reconciliation. Every cost figure in this pipeline is computed with Python’s decimal.Decimal (or PostgreSQL NUMERIC) and rounded once, at the reporting boundary.

Phase 1 — Node Validation & Master Mapping

At the foundation lies a normalized mapping layer that treats every kitchen, prep station, and terminal as a discrete financial entity. The ingestion layer validates location codes against an active master table before any transactional data reaches the analytics warehouse, so orphaned records are quarantined rather than allowed to skew aggregate food cost.

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Optional

import pandas as pd

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


@dataclass(frozen=True)
class CostCenterNode:
    location_id: str
    parent_id: Optional[str]
    cost_center_type: str  # 'kitchen' | 'prep' | 'service' | 'commissary'
    currency_code: str = "USD"


class MasterMappingRegistry:
    """Authoritative, immutable registry of valid cost center nodes."""

    _REQUIRED = {"location_id", "parent_id", "cost_center_type", "currency_code"}

    def __init__(self, master_df: pd.DataFrame) -> None:
        missing = self._REQUIRED - set(master_df.columns)
        if missing:
            raise ValueError(f"Master table missing required columns: {sorted(missing)}")

        self._nodes: dict[str, CostCenterNode] = {
            row["location_id"]: CostCenterNode(**row[list(self._REQUIRED)].to_dict())
            for _, row in master_df.iterrows()
        }
        self._active_ids: frozenset[str] = frozenset(self._nodes)

    def validate_batch(self, tx_df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
        """Vectorized split into (valid, quarantined) frames — no rows are dropped."""
        valid_mask = tx_df["location_id"].isin(self._active_ids)
        quarantined = tx_df.loc[~valid_mask].assign(reason="ORPHANED_LOCATION_ID")
        if not quarantined.empty:
            logging.warning(
                "Quarantined %d transactions with unknown location codes.",
                len(quarantined),
            )
        return tx_df.loc[valid_mask].copy(), quarantined

The registry is constructed once per run and treated as immutable. validate_batch returns two frames rather than discarding rows: valid transactions continue down the pipeline, and orphans are routed to a quarantine table with a machine-readable reason so an operator can reconcile a mis-configured terminal instead of hunting for silently vanished sales.

Phase 2 — Recursive BOM Decomposition & Taxonomy Resolution

With a validated batch in hand, each line item sold must be decomposed into its constituent ingredients. This is a recursive join against the shared recipe graph: pull the daily sales ledger, match each SKU to its bill of materials, multiply sold quantity by the standardized ingredient weight, and apply the yield factor calculation frameworks that translate raw weights into net edible consumption. The result is tagged with the originating node’s location_id.

import pandas as pd


def decompose_sales_to_ingredients(
    sales_df: pd.DataFrame,
    bom_df: pd.DataFrame,
    yield_df: pd.DataFrame,
) -> pd.DataFrame:
    """Vectorized POS -> BOM -> net ingredient consumption.

    Uses pandas merges (O(n log n)) rather than row-wise apply. Assumes weights
    are already canonicalized to a base metric upstream.
    """
    merged = sales_df.merge(
        bom_df,
        left_on="menu_sku",
        right_on="recipe_id",
        how="inner",
    )
    merged["raw_weight_g"] = merged["qty_sold"] * merged["ingredient_weight_g"]

    # Vectorized yield adjustment; guard against divide-by-zero from bad factors.
    yields = yield_df.set_index("ingredient_id")["yield_factor"]
    factor = merged["ingredient_id"].map(yields)
    if (factor <= 0).any() or factor.isna().any():
        bad = merged.loc[(factor <= 0) | factor.isna(), "ingredient_id"].unique()
        raise ValueError(f"Invalid or missing yield_factor for: {list(bad)}")
    merged["net_consumption_g"] = merged["raw_weight_g"] / factor

    return merged[["location_id", "ingredient_id", "net_consumption_g", "transaction_date"]]

A separate friction point in multi-location environments is nomenclature: cashiers and kitchen display systems rarely name items the way the inventory platform does. A deterministic translation layer bridges the gap, built on the same rules as the POS taxonomy mapping discipline so a menu item sold at one store deducts precise ingredient weights from the exact node that prepared it. Without this resolution, allocation falls back to flat-rate estimates that hide true margin leakage.

from __future__ import annotations

import json
from typing import Mapping

import pandas as pd


class TaxonomyResolver:
    """Deterministic POS SKU -> procurement ingredient ID translation."""

    def __init__(self, lookup: Mapping[str, str]) -> None:
        self._lookup = dict(lookup)

    @classmethod
    def from_json(cls, path: str) -> "TaxonomyResolver":
        with open(path, "r", encoding="utf-8") as fh:
            return cls(json.load(fh))

    def resolve_series(self, skus: pd.Series) -> pd.Series:
        """Vectorized translation; unmapped SKUs surface as NaN for routing."""
        return skus.map(self._lookup)

    def assert_fully_mapped(self, skus: pd.Series) -> None:
        unmapped = sorted(set(skus[~skus.isin(self._lookup)]))
        if unmapped:
            raise KeyError(f"Unmapped POS SKUs — update taxonomy registry: {unmapped}")

The resolver keeps translation rules in a version-controlled registry rather than scattered if branches, so a menu change is a data edit with an audit trail, not a code deploy. Unmapped SKUs are surfaced explicitly — either as NaN rows destined for quarantine, or as a hard failure during a validation pass — never silently zeroed.

Phase 3 — Franchise Aggregation & Rollup

Once per-node consumption is priced, the architecture must respect cross-entity financial boundaries. Franchise agreements dictate distinct allocation rules, commissary chargebacks, and regional thresholds. The franchise cost center setup guide covers how to isolate proprietary margin while still enabling consolidated visibility; the roll-up itself applies a deterministic three-step sequence.

  1. Node-level netting — deduct prep waste and spoilage from gross consumption, feeding the numbers that the waste tracking and routing systems attribute to variance rather than theoretical usage.
  2. Inter-unit transfer reconciliation — match outgoing commissary shipments to incoming receiving logs using FIFO or weighted-average cost, so a transferred case is costed once, at the node that consumes it.
  3. Parent-child rollup — sum net cost by parent_id, applying each location’s FX rate before corporate consolidation.
from __future__ import annotations

from decimal import Decimal, ROUND_HALF_UP
from typing import Mapping

import pandas as pd


def aggregate_to_corporate(
    node_df: pd.DataFrame,
    hierarchy_df: pd.DataFrame,
    fx_rates: Mapping[str, Decimal],
) -> pd.DataFrame:
    """Roll node-level cost to region/franchise views with exact decimal FX.

    `node_df.net_cost` must already hold Decimal objects; float money is rejected.
    """
    if not node_df["net_cost"].map(lambda v: isinstance(v, Decimal)).all():
        raise TypeError("net_cost must be Decimal — float loses cents at scale")

    rates = {k: Decimal(str(v)) for k, v in fx_rates.items()}
    node_df = node_df.copy()
    node_df["usd_equiv"] = [
        cost * rates[ccy] for cost, ccy in zip(node_df["net_cost"], node_df["currency_code"])
    ]

    rolled = node_df.merge(
        hierarchy_df[["location_id", "region_id", "franchise_group"]],
        on="location_id",
        how="left",
    )

    grouped = rolled.groupby(["region_id", "franchise_group"], as_index=False).agg(
        total_cost_usd=("usd_equiv", lambda s: sum(s, Decimal("0"))),
        active_locations=("location_id", "nunique"),
    )
    cent = Decimal("0.01")
    grouped["total_cost_usd"] = grouped["total_cost_usd"].map(
        lambda v: v.quantize(cent, rounding=ROUND_HALF_UP)
    )
    return grouped

Monetary values stay in Decimal through the entire roll-up and are quantized to cents exactly once, at the reporting boundary. The FX rates are coerced from strings to Decimal so a rate literal like 1.07 never enters as an inexact binary float. The consolidated frame is the single source of truth that the theoretical vs actual food cost calculation engine consumes to compute estate-wide variance.

Production Hardening

  • Idempotency. Every stage must be idempotent. Re-running a nightly sync on the same POS export produces identical output with no duplicated consumption rows. Derive a deterministic idempotency key from (location_id, menu_sku, transaction_date, source_batch_id) and upsert on it rather than blind-inserting.
  • Deduplication. POS exports frequently overlap at day boundaries. Deduplicate on the natural transaction key before decomposition so an item counted in two exports does not double the ingredient draw.
  • Memory management. For portfolios above ~500 units, partition ingestion by transaction_date and region_id, cast join keys to category dtype, and stage intermediate frames as Parquet. Push the heaviest joins to a columnar engine once nightly ledgers exceed ~10M rows.
  • Unit normalization hooks. This layer assumes canonicalized weights; assert that invariant on entry (unit == base_metric) so a raw-ounce value can never reach the decimal cost math.
  • Schema drift monitoring. Menu engineering teams update SKUs constantly, which breaks static BOM joins. Validate POS API response schemas with pydantic or pandera at the boundary and version the taxonomy registry so drift is a caught exception, not a silent miss.

Failure Modes & Troubleshooting

Orphaned location codes silently inflating “corporate” totals. A terminal configured with a typo’d location_id fails registry validation. If those rows are dropped instead of quarantined, sales disappear and food cost percentage looks artificially good. Detection: reconcile daily POS gross sales against the sum of valid-plus-quarantined rows; any gap is a routing bug.

Double-counted commissary transfers. When a shared commissary both records production and ships to stores, costing the ingredient at production and at store consumption double-books it. Detection: assert that the sum of node-level costed consumption equals total purchased cost minus closing inventory delta; a persistent positive gap points at unreconciled transfers.

Float drift in FX and roll-up. A net_cost accidentally left as float, or an FX rate entered as a binary float, produces sub-cent drift that compounds across thousands of lines and fails reconciliation by a few dollars per region. Detection: the TypeError guard above rejects float money at the boundary; add a nightly assertion that region totals reconcile to the sum of their leaves within Decimal("0.00").

yield_factor of zero or missing. A recipe imported without a yield factor maps to NaN or 0, and the division either explodes or produces inf consumption. The decomposition step raises on factor <= 0 before any bad value propagates; keep a monitor on newly added ingredients so the failure is caught at import, not at month-end.

Non-idempotent reruns duplicating consumption. Re-running a batch after a partial failure without an idempotency key doubles a day’s ingredient draw and doubles theoretical cost. Detection: a uniqueness constraint on the derived idempotency key turns a duplicate insert into a caught conflict instead of silent corruption.

Frequently Asked Questions

Should each location have its own copy of the recipe database?

No. There is one recipe graph and one cost overlay per location. The roll-up engine accepts a location-scoped price_map and yield_map and computes that node’s theoretical cost from the shared bill of materials. Duplicating recipes per store means a single menu change has to be replayed everywhere and the copies drift; a shared graph with a location dimension keeps every unit consistent on the next sync.

Where should the parent-child roll-up run — PostgreSQL or Python?

Run it where the result is consumed. A recursive CTE is ideal when the aggregate feeds a materialized view or SQL report, because it avoids shipping the whole edge table to the application. Vectorized pandas groupby on a parent_id join is better when the roll-up is one stage of a larger batch that also ingests, decomposes, and logs in the same process. Both give the same tree aggregation; the surrounding workflow decides.

How do commissary transfers avoid being counted twice?

Cost the ingredient once, at the node that ultimately consumes it. Outgoing commissary shipments are reconciled against incoming store receiving logs using FIFO or weighted-average cost before the parent-child rollup runs, so a transferred case is never booked at both production and consumption. A reconciliation assertion (purchased cost minus inventory delta equals costed consumption) catches any leak.

What happens to a transaction whose location code is not in the registry?

It is quarantined, not dropped. validate_batch returns valid and quarantined frames separately, tagging orphans with a reason code so an operator can fix the mis-configured terminal. Dropping the rows would make sales vanish and understate food cost, so the pipeline preserves them for reconciliation instead.

Why use Decimal for the roll-up instead of float?

Binary floating point cannot represent most decimal cents exactly, so repeated addition drifts. On one dish it is invisible; across a franchise estate’s thousands of nightly line items it accumulates into discrepancies that fail reconciliation. Keeping every cost in Decimal (or PostgreSQL NUMERIC) and quantizing to cents once, at the reporting boundary, keeps base-10 arithmetic exact.

Up one level: Core Architecture & Cost Mapping Systems.

For deeper implementation reference, consult the official pandas documentation on merge and groupby semantics, and the Python decimal documentation for exact monetary arithmetic.