Data Ingestion Recipe Parsing Workflows

Normalizing Modifier and Combo Pricing

This page walks a food-tech developer through the exact decomposition that turns a combo bundle and its modifiers into signed, canonical component rows a cost engine can join to recipes. It is the implementation companion to menu schema normalization; read that for the canonical schema and the structure-not-cost principle, then follow the steps here to handle the two shapes that break naive costing: bundled combos and additive modifiers.

The failure this prevents is double-counting and phantom pricing — a combo whose parts are costed both individually and as a bundle, or a modifier applied as an opaque string that never resolves to an ingredient quantity.

Prerequisites and Data Contract

  • Python 3.11+, pandas 2.x.
  • Canonical menu_items and menu_components frames from normalization, plus a combo_definitions frame mapping a combo to its parts and a modifier_definitions frame of signed ingredient adjustments.
  • Prices as Decimal; output is a menu_components frame where every row carries a signed quantity and each combo part is an independent, costable item.
Combo and modifier decomposition A combo bundle splits into its component menu items, each receiving an allocated portion of the bundle. Each modifier becomes a signed quantity adjustment against a specific ingredient: add is positive, remove is negative. Both converge into a flat signed component ledger that downstream costing sums. Combo bundle → parts + allocation Modifier → signed qty Signed components +1 / −1 Component ledger

Step-by-Step Implementation

Step 1 — Decompose a combo into component items

A combo is not a costable atom; expand it into its parts so each part costs against its own recipe.

import pandas as pd


def explode_combos(combos: pd.DataFrame) -> pd.DataFrame:
    """combos: combo_id, part_menu_item_id, part_qty -> one row per part."""
    parts = combos.rename(columns={"part_menu_item_id": "menu_item_id"})
    parts["kind"] = "combo_part"
    parts["signed_qty"] = parts["part_qty"]
    return parts[["combo_id", "menu_item_id", "kind", "signed_qty"]]

Step 2 — Model modifiers as signed quantities

Add and remove modifiers become positive and negative quantities against a specific ingredient SKU — never opaque strings.

import pandas as pd

modifier_definitions = pd.DataFrame(
    {
        "modifier_id": ["MOD_ADD_AVOCADO", "MOD_NO_ONION", "MOD_EXTRA_CHEESE"],
        "ingredient_sku": ["AVOCADO_SLICE", "ONION_DICED", "CHEDDAR_SHRED"],
        "signed_qty": [1.0, -1.0, 2.0],
    }
)

Step 3 — Merge into a signed component ledger

Combine combo parts and modifiers into one flat frame, keeping quantities Decimal so the later cost multiply is exact.

from decimal import Decimal

import pandas as pd


def component_ledger(sold: pd.DataFrame, combos: pd.DataFrame,
                     modifiers: pd.DataFrame) -> pd.DataFrame:
    exploded = sold.merge(explode_combos(combos), on="combo_id", how="left")
    with_mods = exploded.merge(
        modifiers, on="modifier_id", how="left", suffixes=("", "_mod")
    )
    with_mods["signed_qty"] = with_mods["signed_qty"].fillna(Decimal("0"))
    with_mods["mod_qty"] = with_mods["signed_qty_mod"].fillna(Decimal("0"))
    return with_mods

Step 4 — Allocate combo price to parts for reporting

When a combo has a bundle price different from the sum of its parts, allocate the discount proportionally so per-item margin stays honest.

from decimal import Decimal

import pandas as pd


def allocate_combo_price(parts: pd.DataFrame, bundle_price: Decimal) -> pd.DataFrame:
    total_standalone = parts["standalone_price"].sum()
    parts = parts.copy()
    parts["allocated_price"] = parts["standalone_price"].map(
        lambda p: (Decimal(str(p)) / total_standalone * bundle_price).quantize(Decimal("0.01"))
    )
    return parts

Verification and Validation

  • No double-count. A combo must appear in the ledger as its parts, not also as a standalone item. Assert the combo id itself has no direct recipe join.
  • Signed modifiers land. An MOD_NO_ONION line must produce -1 onion; MOD_ADD_AVOCADO, +1 avocado. Spot-check both signs.
  • Allocation sums to bundle. After allocate_combo_price, the allocated prices must sum to the bundle price exactly (to the cent) — a Decimal quantize guards this.
  • Idempotency. Re-run decomposition on the same sale and diff; identical output confirms determinism.

Gotchas and Edge Cases

  • Bundle vs sum-of-parts drift. Costing combo parts at standalone prices overstates revenue when the bundle is discounted. Allocate the bundle price across parts before computing per-item margin.
  • Modifier as string. Appending “no onion” to an item name instead of modeling -1 onion means the removed ingredient is never subtracted from theoretical usage. Always resolve modifiers to signed ingredient quantities.
  • Negative quantity below zero. A -1 modifier on an ingredient the base recipe uses only once can drive net usage negative if applied twice. Floor net component usage at zero after summing, and flag the anomaly.
  • Fractional-cent allocation. Proportional allocation must quantize once and reconcile to the bundle total; rounding each part independently can leave a stray cent. Allocate the remainder to the largest part.

For library specifics, see the official pandas documentation and Python decimal documentation.