Core Architecture Cost Mapping Systems

Map Toast POS Categories to Ingredient SKUs

This page walks a food-tech developer or automation builder through the exact tables, Pydantic models, and vectorized pandas joins that turn a raw Toast menu payload into a deterministic ingredient consumption ledger. It is the Toast-specific implementation companion to the broader workflow described in Mapping POS Taxonomies to Ingredients; read that first for the four-phase architecture, then follow the numbered steps here to stand up a working Toast-to-SKU resolver you can run against today’s sales export.

The problem this solves is a taxonomy mismatch: Toast organizes sales by Category, MenuItem, and ModifierGroup, while inventory and recipe costing require granular ingredient SKUs, yield-adjusted weights, and strict units of measure. Bridging the two by hand in spreadsheets is where margin visibility quietly dies. The pipeline below closes the gap with explicit, auditable resolution rules rather than fuzzy string matching.

Prerequisites and Data Contract

Every step is written against the following environment and structural assumptions. If they drift, the joins will silently produce wrong quantities rather than erroring.

  • Python 3.11+, pydantic 2.x, and pandas 2.x.
  • A Toast API bearer token with menus.read scope, plus a location_id (Toast calls this the restaurant GUID). Pulling that payload on a schedule is the job of the POS API polling strategies upstream of this page — assume you already have a raw JSON array of menu items in hand.
  • Four flat, version-controlled tables. Mapping rules are data, not code: store them as Git-backed CSVs or a relational table with effective_date / expiration_date columns so retroactive cost reconciliation stays possible when a menu changes.

The contract separates four concerns. category_mapping links a Toast category_id to a base SKU; recipe_bom expands a base SKU into raw ingredient SKUs and per-portion weights; modifier_overrides adds or subtracts SKUs for add-ons and substitutions; and the incoming pos_sales frame is the transactional feed. Costs are never stored on these tables — quantities resolve here, and the money multiply happens downstream in the variance mapping methodologies layer using Decimal arithmetic.

Toast category and modifier resolution into an ingredient ledger The sale line CAT_BURGER + MOD_AVOCADO at quantity 1 joins category_mapping to base SKU_BURGER_BASE (base_qty x1.0). The recipe_bom expansion fans that base SKU into GROUND-BEEF-8020 at x8.0 oz and BUN-BRIOCHE at x4.5 oz per portion. A modifier_overrides row adds AVOCADO_SLICE at +1.0 oz times quantity. All three rows converge into the flat ingredient ledger: beef 8.0 oz, bun 4.5 oz, avocado 1.0 oz. 1 · POS SALE 2 · CATEGORY → BASE SKU 3 · BOM EXPAND / MODIFIER Δ 4 · LEDGER ×qty 1 ×8.0 oz ×4.5 oz +1.0 oz Toast POS sale CAT_BURGER MOD_AVOCADO quantity_sold 1 category_mapping SKU_BURGER_BASE base_qty ×1.0 GROUND-BEEF-8020 recipe_bom leaf BUN-BRIOCHE recipe_bom leaf modifier_overrides AVOCADO_SLICE signed qty_adjustment ingredient ledger GROUND-BEEF-8020 8.0 oz BUN-BRIOCHE 4.5 oz AVOCADO_SLICE 1.0 oz

Step-by-Step Implementation

Step 1 — Validate and flatten the Toast menu payload

Toast responses are nested JSON with id, name, category_id, is_active, and a list of modifier_group_ids. Enforce a schema at the ingestion boundary with a Pydantic v2 model so malformed items are logged and skipped, never silently joined. Deprecated items are flagged archived rather than dropped, preserving historical reconciliation.

from __future__ import annotations

import json

import pandas as pd
from pydantic import BaseModel, Field, ValidationError


class ToastMenuItem(BaseModel):
    id: str
    name: str
    category_id: str
    is_active: bool
    location_id: str
    modifier_group_ids: list[str] = Field(default_factory=list)


def normalize_toast_payload(raw_json: str) -> pd.DataFrame:
    """Parse, validate, and flatten a Toast menu payload into a canonical frame."""
    validated: list[dict] = []
    for item in json.loads(raw_json):
        try:
            validated.append(ToastMenuItem(**item).model_dump())
        except ValidationError as exc:
            # Quarantine malformed items for review; do not halt the pipeline.
            print(f"schema rejection for {item.get('id', '?')}: {exc}")

    df = pd.DataFrame(validated)
    # One row per (item, modifier) so downstream joins stay deterministic.
    df = df.explode("modifier_group_ids").rename(
        columns={"modifier_group_ids": "modifier_id"}
    )
    df["modifier_id"] = df["modifier_id"].fillna("MOD_NONE")
    df["status"] = df["is_active"].map({True: "active", False: "archived"})
    return df

Step 2 — Define the deterministic category-to-SKU matrix

Each Toast category_id resolves to exactly one base SKU with a base quantity. Keep this as a flat lookup — no nested rules, no recursion — so a single merge with validate="m:1" can guarantee there are no duplicate keys silently overwriting one another. This table is where culinary managers own the meaning; automation only enforces its shape.

import pandas as pd

category_mapping = pd.DataFrame(
    {
        "category_id": ["CAT_BURGER", "CAT_DRAFT_BEER"],
        "base_sku": ["SKU_BURGER_BASE", "SKU_BEER_DRAFT_16OZ"],
        "base_qty": [1.0, 1.0],
    }
)

Step 3 — Load the recipe BOM expansion table

A base SKU like SKU_BURGER_BASE is not a purchasable item — it is a recipe. The expansion table links each base SKU to its constituent ingredient SKUs and the yield-adjusted weight consumed per portion. This table must share the same referential structure as your recipe BOM database schema so a SKU means the same thing on both sides of the join.

import pandas as pd

recipe_bom = pd.DataFrame(
    {
        "parent_sku": ["SKU_BURGER_BASE", "SKU_BURGER_BASE"],
        "ingredient_sku": ["GROUND-BEEF-8020", "BUN-BRIOCHE"],
        "yield_weight_oz": [8.0, 4.5],
    }
)

The yield_weight_oz values already fold in trim and cook loss; deriving them is the job of the yield factor calculation frameworks, and the BOM table simply carries the resulting per-portion weight.

Step 4 — Encode modifier overrides as additive vectors

Toast modifiers (add avocado, no onion) adjust the base recipe. Model each as a signed quantity adjustment against a specific ingredient SKU: +1 slice of avocado, -1 onion ring. Because overrides are additive vectors rather than opaque strings, substitutions never double-count and never inflate theoretical usage.

import pandas as pd

modifier_overrides = pd.DataFrame(
    {
        "modifier_id": ["MOD_AVOCADO", "MOD_NO_ONION"],
        "ingredient_sku": ["AVOCADO_SLICE", "ONION_RING"],
        "qty_adjustment": [1.0, -1.0],
    }
)

Step 5 — Resolve sales into a flat ingredient ledger

With the four tables in place, resolution is three vectorized merges — no row-by-row iteration. validate="m:1" on the category join fails fast if the mapping matrix has duplicate keys; the modifier join is a left merge so unmodified lines pass through untouched. The result is one auditable row per (transaction, ingredient).

import pandas as pd

pos_sales = pd.DataFrame(
    {
        "transaction_id": ["T001", "T001", "T002", "T003"],
        "category_id": ["CAT_BURGER", "CAT_BURGER", "CAT_DRAFT_BEER", "CAT_BURGER"],
        "modifier_id": ["MOD_NONE", "MOD_AVOCADO", "MOD_NONE", "MOD_NO_ONION"],
        "quantity_sold": [2, 1, 3, 1],
    }
)


def resolve_pos_to_ingredients(
    pos_df: pd.DataFrame,
    cat_map: pd.DataFrame,
    bom_df: pd.DataFrame,
    mod_df: pd.DataFrame,
) -> pd.DataFrame:
    """Deterministically resolve Toast categories to ingredient-level consumption."""
    # A: category -> base SKU (m:1 guards against duplicate mapping keys)
    mapped = pos_df.merge(cat_map, on="category_id", how="left", validate="m:1")
    mapped["base_qty_total"] = mapped["quantity_sold"] * mapped["base_qty"]

    # B: base SKU -> BOM ingredient weights
    bom = mapped.merge(bom_df, left_on="base_sku", right_on="parent_sku", how="left")
    bom["ingredient_qty"] = bom["base_qty_total"] * bom["yield_weight_oz"]

    # C: apply signed modifier adjustments before aggregation
    out = bom.merge(mod_df, on=["modifier_id", "ingredient_sku"], how="left")
    out["qty_adjustment"] = out["qty_adjustment"].fillna(0.0)
    out["final_qty_oz"] = out["ingredient_qty"].fillna(0.0) + (
        out["qty_adjustment"] * out["quantity_sold"]
    )

    ledger = out.dropna(subset=["ingredient_sku"])
    return ledger.groupby(
        ["transaction_id", "category_id", "modifier_id", "ingredient_sku"],
        as_index=False,
    )["final_qty_oz"].sum()


ingredient_ledger = resolve_pos_to_ingredients(
    pos_sales, category_mapping, recipe_bom, modifier_overrides
)
print(ingredient_ledger.to_markdown(index=False))

Verification and Validation

Confirm each layer before trusting the ledger downstream.

  • Every sold line resolved. After the category join, assert nothing fell through: assert mapped["base_sku"].notna().all(). A NaN here means a live Toast category has no mapping row — route it to a quarantine table for culinary review rather than shipping a zero.
  • Modifier adjustments landed. For the sample data, T001 with MOD_AVOCADO should show an AVOCADO_SLICE row at 1.0 oz and beef/bun rows unchanged; T003 with MOD_NO_ONION should carry ONION_RING at -1.0. Spot-check with ingredient_ledger.query("transaction_id == 'T001'").
  • Idempotency. Run the resolver twice against the same snapshot and compare with pd.testing.assert_frame_equal. Identical input must yield identical output — deduplicate raw API pulls on a hash of transaction_id + location_id + timestamp before this step.
  • Mapping integrity. If validate="m:1" raises MergeError, the mapping matrix has duplicate category_id keys. Fix the source table; do not drop the constraint to make the error go away.

Gotchas and Edge Cases

  • Unit-of-measure mismatch. Toast categories rarely match inventory receiving units. Normalize every output to a single base unit (ounces or grams) inside this layer before handing off; a draft beer counted in pours and received in kegs will otherwise misreport by orders of magnitude. Regional aliases (floz, fl oz, US vs metric cup) must be canonicalized upstream, never pattern-matched here.
  • Yield applied at the wrong stage. Fold yield into yield_weight_oz at BOM expansion (Step 3), not during variance calculation. Applying it later conflates theoretical usage with actual waste and corrupts every downstream comparison.
  • Silently dropped modifiers. A modifier present in Toast but absent from modifier_overrides left-joins to NaN and is treated as a no-op. That is intentional for cosmetic modifiers (well done) but dangerous for ingredient-bearing ones. Periodically diff the distinct modifier_id set in sales against the override table.
  • Archived items vanishing. Do not filter status == "archived" before joining historical sales — a burger sold last week under a now-retired category still needs to resolve. Keep archived rows in the mapping matrix with an expiration_date instead.
  • IEEE-754 drift on the money path. Quantities here are floats, which is fine for weights. The moment final_qty_oz is multiplied by a unit_cost, switch to decimal.Decimal or PostgreSQL NUMERIC so sub-cent error does not accumulate across a month of transactions — that conversion happens in the variance layer, not here.