Reporting Cicd Delivery Pipelines

Reporting, CI/CD & Delivery Pipelines

A food-cost model is only worth what reaches a decision-maker before the decision is made. Most multi-unit operations lose margin not because the calculation is wrong but because the number arrives late, arrives in a spreadsheet nobody opens, or changes silently between two runs and no one notices until the monthly close. This section of the restaurant-menu.org food cost automation library covers the last mile: how validated cost and variance data becomes a scheduled report, a pull-request gate, and a delivered artifact that operators, culinary managers, and food-tech developers can trust. It assumes the upstream machinery — the core architecture and cost mapping systems that produce a defensible cost, the data ingestion and recipe parsing workflows that feed it, and the theoretical vs actual food cost calculation that measures the gap — already exists, and it concerns itself with turning those outputs into something dependable and repeatable.

The domain serves three consumers who each need a different delivery contract. Food-tech developers need cost logic wired into version control so that a recipe or mapping change cannot merge if it moves margin outside an agreed band. Multi-unit operators need a weekly artifact — a margin report per location — that lands in the same inbox at the same hour every week without a human running a notebook. Culinary managers need an alert when a specific dish crosses a threshold, not a firehose of raw rows. The three disciplines below — batch reporting, CI/CD gating, and scheduled distribution — are designed so all three consume the same materialized numbers rather than three divergent exports.

Reporting and delivery pipeline overview A single materialized cost and variance store on the left feeds three parallel delivery paths. The top path is a batch report builder that renders weekly per-location margin artifacts. The middle path is a CI/CD food-cost diff gate that compares a proposed change against a baseline and either passes or fails the pull request. The bottom path is a scheduled distributor that pushes formatted digests to email and chat channels. All three read the same source; none recomputes cost independently. Materialized Cost & Variance one source of truth Batch Report Builder weekly per-location margin artifact CI/CD Diff Gate baseline vs proposed → pass / fail Scheduled Distributor digest → the right channel Operator inbox PDF / HTML report Pull request merge blocked on regression Email & chat culinary + finance

Subsystem 1 — Report Ingestion and Snapshot Validation

The delivery layer must never recompute cost. Its single input vector is a materialized snapshot produced by the variance engine — a table (or a partitioned set of them) keyed by location_id, menu_item_id, and a run_version that stamps exactly which nightly roll-up produced the figures. Before any report renders, the snapshot is validated against a schema contract: every location present that was expected, no run_version older than the reporting window, and no NaN in a margin column that a downstream template would render as a blank cell an operator misreads as zero.

The reason to gate on run_version rather than a timestamp is idempotency. A weekly report generated twice for the same fiscal week must be byte-identical, and it can only be if both runs read the same immutable snapshot rather than “whatever the table holds right now.” The disciplined pattern is to freeze the snapshot the report will consume — copy it to a versioned reporting table, or select against a specific run_version — so a late-arriving invoice correction that rewrites the live numbers cannot retroactively change a report already sent. That freeze is the reporting analog of the temporal versioning the core architecture and cost mapping systems apply at ingestion, and it is what lets an operator reconcile the printed report against the ledger months later.

Rows that fail validation are not rendered with a placeholder; they are held, logged with a machine-readable reason, and the report either ships with an explicit “N locations pending” banner or blocks entirely, depending on the report’s severity. A margin dashboard that silently omits a location reads as “that location had no sales,” which is a materially different — and dangerous — claim than “that location’s feed has not landed yet.”

Subsystem 2 — The Batch Report Builder

The core of the batch path is a deterministic transform from a validated snapshot to a rendered artifact. It is a pure function: the same snapshot always yields the same report. The reference shape below aggregates a per-item variance snapshot into a per-location margin summary using vectorized pandas, carries money as Decimal, and returns a frame ready to hand to a template rather than printing anything itself.

from __future__ import annotations

from decimal import Decimal

import pandas as pd


def build_margin_summary(snapshot: pd.DataFrame) -> pd.DataFrame:
    """Aggregate a per-item variance snapshot into a per-location margin report.

    Expects columns: location_id, menu_item_id, units_sold,
    theoretical_cost, actual_cost, net_revenue  (costs as Decimal).
    """
    df = snapshot.copy()
    df["cost_variance"] = df["actual_cost"] - df["theoretical_cost"]

    grouped = df.groupby("location_id", as_index=False).agg(
        units_sold=("units_sold", "sum"),
        theoretical_cost=("theoretical_cost", "sum"),
        actual_cost=("actual_cost", "sum"),
        net_revenue=("net_revenue", "sum"),
        cost_variance=("cost_variance", "sum"),
    )

    # Ratios stay Decimal so the printed figure matches the ledger to the cent.
    grouped["food_cost_pct"] = grouped.apply(
        lambda r: (r["actual_cost"] / r["net_revenue"]).quantize(Decimal("0.0001"))
        if r["net_revenue"] > 0 else Decimal("0"),
        axis=1,
    )
    return grouped.sort_values("cost_variance", ascending=False)

Two choices make this report defensible. The aggregation is a single groupby rather than a loop over locations, so a portfolio of hundreds of units renders in one vectorized pass. And the food-cost percentage is computed in Decimal, quantized once, so the ratio printed on the page reconciles exactly with the summed ledger — a report that rounds mid-calculation will disagree with the finance system by a cent per location and erode trust in every figure on the page. The full walkthrough, including template rendering and multi-week trend columns, lives in batch cost reporting automation, and the specific weekly-cadence implementation in generating weekly margin reports with pandas.

Subsystem 3 — Cost Diffs as a CI/CD Gate

Recipe definitions, BOM edges, and POS mapping tables are data, but they are versioned data — they live in a repository and change through pull requests. That makes food cost a testable property. A food-cost diff check in CI/CD recomputes theoretical cost on the proposed branch, diffs it against a stored baseline, and fails the build if any item’s margin moved outside an allowed band. A change that drops a garnish, swaps a supplier SKU, or edits a yield factor stops being an invisible edit and becomes a reviewable, blockable event.

The gate is structurally the same comparison the nightly variance job performs, run at a different time against a different pair of inputs. Instead of theoretical-versus-actual on last night’s sales, it is proposed-versus-baseline on a fixed sample basket. The comparison logic below returns the set of regressions a CI job would surface:

from __future__ import annotations

from decimal import Decimal

import pandas as pd


def diff_margins(baseline: pd.DataFrame, proposed: pd.DataFrame,
                 tolerance: Decimal = Decimal("0.01")) -> pd.DataFrame:
    """Return items whose food-cost ratio moved beyond tolerance versus baseline."""
    merged = baseline.merge(
        proposed, on="menu_item_id", suffixes=("_base", "_new"), how="outer",
        indicator=True,
    )
    merged["delta"] = merged["food_cost_pct_new"] - merged["food_cost_pct_base"]
    regressions = merged[
        (merged["_merge"] != "both") | (merged["delta"].abs() > tolerance)
    ]
    return regressions[["menu_item_id", "food_cost_pct_base",
                        "food_cost_pct_new", "delta", "_merge"]]

Wiring that comparison into a pipeline — computing an exit code, annotating the pull request, and choosing which deltas warrant a hard failure versus a warning — is worked end to end in running food-cost diffs as GitHub Actions gates and failing a build on margin-regression thresholds. Catching the same class of error before it reaches production, at the developer’s desk, is the purpose of pre-commit hooks for recipe cost validation.

Subsystem 4 — Scheduled Distribution and Multi-Unit Fan-Out

Rendering a report is not delivering it. The distribution layer takes a validated artifact and routes it to the right consumer on a fixed cadence: a weekly margin PDF to each operator, a daily variance digest to a regional chat channel, an immediate alert to a culinary manager when one dish breaches a band. Across a multi-location estate this fans out — one report per cost center — and the fan-out must be isolated so that a failure delivering to one location never blocks the other two hundred.

The scaling concern mirrors the rest of the platform: there is one rendering job and N delivery targets, keyed by location_id, not N bespoke jobs. Regional routing (which channel, which recipients, which timezone the “weekly” boundary respects) is configuration keyed on the same multi-location cost center architecture the cost engine uses, so adding a location adds a config row, not a code path. The delivery mechanics — retry on a transient send failure, dead-letter a permanently failing target, dedupe so a retried job never double-sends — are covered in scheduled report distribution and its channel-specific walkthrough, distributing cost reports via email and Slack.

Security, RBAC and Audit Boundaries

Reports carry the most sensitive figures the platform produces — location-level margins, supplier pricing, and often the numbers behind franchisee or manager compensation. Delivery therefore has to enforce access at the routing layer, not just the data layer. A regional manager’s digest must contain only their locations; a franchise report must never leak another franchisee’s cost structure. The distributor resolves recipients through the same role model the rest of the system uses, and it treats the recipient list as data that is itself audited.

Every generated report writes an immutable record: which run_version it was built from, who or what triggered it, which recipients it went to, and a content hash of the artifact. That record is what lets an operator six months later prove which numbers were sent and confirm they match the ledger of record. Because the CI/CD gate also writes its verdicts — which pull request, which regressions, who overrode a failure — the audit trail spans from a developer’s proposed recipe edit all the way to the delivered margin report, with no unlogged step in between.

Operational Reliability Checklist

A delivery pipeline that works on a clean week but drops a report during month-end is worse than no automation, because people stop checking manually once they trust it. The following disciplines keep it dependable:

  • Idempotent generation. Key each report on (report_type, period, location_id) and upsert, so a retried or double-triggered job produces one artifact, not two conflicting ones in an inbox.
  • Snapshot pinning. Build every report against a specific run_version, never the live table, so a late correction cannot silently alter an already-sent report and the same period always regenerates identically.
  • Bounded retries with dead-lettering. A transient send failure (SMTP timeout, chat API 503) retries with backoff; a permanent one (bad address, revoked webhook) dead-letters to a review queue rather than retrying forever or failing the whole batch.
  • Delta gating before send. Diff a report against last period and hold anything that moved implausibly — a location whose food cost “improved” 30% overnight is more likely a broken feed than a genuine win, and it should page an engineer, not an operator. This is the reporting-side complement to threshold tuning for alerts.
  • Structured logging with correlation IDs. Emit JSON logs carrying report_id, run_version, and location_id on every stage, so a “the Denver report never arrived” ticket is traced from snapshot to send in one query.
  • Deterministic monetary types. Carry Decimal/NUMERIC through rendering and round only at the display boundary, so the printed figure and the ledger figure never disagree.

Frequently Asked Questions

Why should the reporting layer never recompute cost itself?

Because two systems computing the same number will eventually disagree, and when a report disagrees with the ledger, operators stop trusting both. The reporting layer’s job is to read a materialized, version-stamped snapshot produced by the variance engine and render it faithfully. All cost logic lives in one place; reporting formats and delivers, it does not calculate. This also guarantees a report can be reconciled against the exact roll-up that produced it.

How can food cost be a CI/CD test if it depends on live sales?

The CI gate does not test actual food cost — it tests theoretical cost, which is a pure function of recipe definitions, BOM edges, yield factors, and a fixed sample basket, all of which live in version control. A pull request that edits any of those inputs can be recomputed deterministically and diffed against a stored baseline. Actual cost still needs live sales and is measured nightly; the CI gate catches the subset of margin regressions that are introduced by a code or data change before they ship.

What stops a retried delivery job from sending the same report twice?

An idempotency key on (report_type, period, location_id) plus a generation record. Before sending, the distributor checks whether that key was already delivered with the same content hash; if so, the retry is a no-op. Generation is an upsert, so re-running produces one canonical artifact rather than a duplicate, and the audit record proves exactly what was sent and when.

How does distribution stay correct across a large multi-location estate?

There is one rendering job and one delivery job, both keyed by location_id, with routing (channel, recipients, timezone) stored as configuration against the multi-location cost center model. Adding a location adds a config row, not code. Each location’s delivery is isolated so one failing target dead-letters without blocking the rest, and RBAC on the recipient list ensures each consumer receives only the locations they are entitled to see.

Up one level: restaurant-menu.org — food cost automation library.

For library specifics, see the official pandas documentation on group-by aggregation and the GitHub Actions documentation on workflow triggers and job outputs.