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+,
pydantic2.x, andpandas2.x. - A Toast API bearer token with
menus.readscope, plus alocation_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_datecolumns 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.
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(). ANaNhere 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,
T001withMOD_AVOCADOshould show anAVOCADO_SLICErow at1.0oz and beef/bun rows unchanged;T003withMOD_NO_ONIONshould carryONION_RINGat-1.0. Spot-check withingredient_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 oftransaction_id + location_id + timestampbefore this step. - Mapping integrity. If
validate="m:1"raisesMergeError, the mapping matrix has duplicatecategory_idkeys. 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 metriccup) must be canonicalized upstream, never pattern-matched here. - Yield applied at the wrong stage. Fold yield into
yield_weight_ozat 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_overridesleft-joins toNaNand is treated as a no-op. That is intentional for cosmetic modifiers (well done) but dangerous for ingredient-bearing ones. Periodically diff the distinctmodifier_idset 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 anexpiration_dateinstead. - IEEE-754 drift on the money path. Quantities here are floats, which is fine for weights. The moment
final_qty_ozis multiplied by aunit_cost, switch todecimal.Decimalor PostgreSQLNUMERICso sub-cent error does not accumulate across a month of transactions — that conversion happens in the variance layer, not here.
Related
- Mapping POS Taxonomies to Ingredients — the parent workflow and four-phase architecture this Toast pipeline implements.
- POS API Polling Strategies — how to pull the Toast payload on schedule without hammering rate limits.
- How to Structure Recipe BOMs in PostgreSQL — the schema the
recipe_bomtable in Step 3 mirrors. - Yield Factor Calculation Frameworks — where the
yield_weight_ozvalues come from. - Variance Mapping Methodologies — the theoretical-vs-actual layer that consumes this ledger and applies cost.
- Core Architecture & Cost Mapping Systems — the wider system this translation layer anchors.