Restaurant inventory is a deceptively complex domain. Ingredients spoil, recipes change, portions vary by preparation method, and staff makes errors at every step. A well-designed inventory system for a restaurant group in Lebanon needs to handle all of this without requiring a full-time operator.
Restaurant inventory is a deceptively complex domain. Ingredients spoil, recipes change, portions vary by preparation method, and staff makes errors at every step. A well-designed inventory system for a restaurant group in Lebanon or across MENA needs to handle all of this without requiring a full-time operator to babysit it.
The domain is harder than it looks
A supermarket inventory system tracks units. One bottle of olive oil comes in, one bottle goes out. A restaurant inventory system tracks consumption at the recipe level. One portion of grilled chicken might consume 180g of chicken breast, 12g of olive oil, 3g of spice blend, and 0.5g of lemon zest. The inventory deduction happens not when a server places a dish on a table, but when the kitchen prepares it.
This creates immediate complexity:
- Ingredients are purchased in supplier units (kilograms, liters, cartons) but consumed in recipe units (grams, milliliters).
- A kilogram of chicken breast purchased yields approximately 850g after trimming. The yield percentage is not fixed; it varies by supplier and cut quality.
- Recipes have sub-recipes. The spice blend is itself a recipe: five ingredients mixed in specific ratios. Deducting a portion of grilled chicken requires cascading through the recipe tree.
- Waste happens at multiple points: receiving (damaged goods), prep (trim loss), cooking (evaporation, burning), and plating (presentation waste).
A system that does not model all four waste points gives inventory numbers that drift from physical reality within days.
Data model for recipe-level inventory
The core entities in a restaurant inventory system:
-- Ingredients are the atomic unit
CREATE TABLE ingredients (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
name text NOT NULL,
purchase_unit text NOT NULL, -- 'kg', 'L', 'unit'
cost_per_purchase_unit numeric(10,4) NOT NULL DEFAULT 0,
yield_pct numeric(5,2) NOT NULL DEFAULT 100.00 -- 85.00 = 15% trim loss
);
-- Recipes can contain ingredients or other recipes
CREATE TABLE recipe_items (
id bigserial PRIMARY KEY,
recipe_id bigint NOT NULL REFERENCES recipes(id),
ingredient_id bigint REFERENCES ingredients(id), -- NULL if sub-recipe
sub_recipe_id bigint REFERENCES recipes(id), -- NULL if ingredient
quantity numeric(10,4) NOT NULL,
unit text NOT NULL, -- 'g', 'ml', 'unit'
CONSTRAINT exactly_one_ref CHECK (
(ingredient_id IS NULL) != (sub_recipe_id IS NULL)
)
);
-- Each stock event (purchase, deduction, waste, adjustment) is a ledger entry
CREATE TABLE stock_ledger (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
ingredient_id bigint NOT NULL REFERENCES ingredients(id),
event_type text NOT NULL CHECK (event_type IN ('purchase','deduction','waste','adjustment','count')),
quantity numeric(12,4) NOT NULL, -- negative for deductions and waste
unit text NOT NULL,
reference_id bigint, -- order_id for deductions, purchase_order_id for purchases
notes text,
created_at timestamptz NOT NULL DEFAULT now()
);
The stock ledger is append-only. Current stock for any ingredient is the sum of all ledger entries. This preserves a full audit trail and makes it possible to reconstruct stock at any historical point, which is essential for period-end variance analysis.
Recipe cost calculation with cascading sub-recipes
Calculating the cost of a recipe requires recursively walking the recipe tree and converting all units to purchase units:
type RecipeCost struct {
RecipeID int64
Name string
TotalCost decimal.Decimal
CostPerUnit decimal.Decimal
}
func (s *Service) CalculateRecipeCost(ctx context.Context, recipeID int64) (RecipeCost, error) {
items, err := s.repo.GetRecipeItems(ctx, recipeID)
if err != nil {
return RecipeCost{}, err
}
totalCost := decimal.Zero
for _, item := range items {
if item.IngredientID != nil {
// Direct ingredient cost
converted, err := convertUnit(item.Quantity, item.Unit, item.Ingredient.PurchaseUnit)
if err != nil {
return RecipeCost{}, err
}
// Apply yield percentage: if 100g is needed and yield is 85%,
// you need to purchase 117.6g
effectiveQty := converted.Div(decimal.NewFromFloat(item.Ingredient.YieldPct).Div(decimal.NewFromInt(100)))
cost := effectiveQty.Mul(item.Ingredient.CostPerPurchaseUnit)
totalCost = totalCost.Add(cost)
} else {
// Sub-recipe: recurse
subCost, err := s.CalculateRecipeCost(ctx, *item.SubRecipeID)
if err != nil {
return RecipeCost{}, err
}
// Scale by quantity ratio
scaled := subCost.CostPerUnit.Mul(item.Quantity)
totalCost = totalCost.Add(scaled)
}
}
recipe, _ := s.repo.GetRecipe(ctx, recipeID)
return RecipeCost{
RecipeID: recipeID,
Name: recipe.Name,
TotalCost: totalCost,
CostPerUnit: totalCost.Div(recipe.Yield),
}, nil
}
Yield percentage handling is where most inventory systems fail. Buying 100g of chicken at $15/kg costs $1.50, but if the yield is 85%, only 85g is usable. The effective cost per usable gram is $1.50 / 85 = $0.0176, not $1.50 / 100. Over a full day of service, this difference between using yield-adjusted cost and face-value cost can be $50 to $200 in a busy restaurant.
Automatic deduction from POS sales
The integration between the POS system and the inventory system is the most operationally critical link. When an order is closed on the POS, the inventory system must deduct the recipe quantities for every item sold.
This must happen asynchronously. The POS closing flow should not wait for inventory deductions to complete. If the inventory system is slow or unavailable, the POS must still process payments.
The pattern: POS writes a sales event to a queue. The inventory service consumes the queue and writes stock ledger entries:
func (w *InventoryWorker) ProcessSaleEvent(ctx context.Context, event SaleEvent) error {
for _, lineItem := range event.LineItems {
bom, err := w.repo.GetBillOfMaterials(ctx, lineItem.MenuItemID)
if err != nil {
return fmt.Errorf("BOM for item %d: %w", lineItem.MenuItemID, err)
}
for _, component := range bom {
entry := StockLedgerEntry{
TenantID: event.TenantID,
IngredientID: component.IngredientID,
EventType: "deduction",
Quantity: component.Quantity.Neg().Mul(decimal.NewFromInt(lineItem.Quantity)),
Unit: component.Unit,
ReferenceID: &event.OrderID,
}
if err := w.repo.InsertLedgerEntry(ctx, entry); err != nil {
return err
}
}
}
return nil
}
The BOM (bill of materials) for a menu item is the flattened list of ingredients with quantities, pre-computed from the recipe tree. Re-walking the recipe tree on every sale is expensive; materializing the flat BOM at recipe-save time and using that for deductions is faster.
Physical count and variance detection
Automatic deductions drift from physical reality over time. A well-running restaurant should count physical stock weekly for high-value ingredients (proteins, premium items) and monthly for staples.
The count process in the system:
- Generate a count sheet with expected quantities (sum of ledger entries since last count).
- Staff physically counts and enters actual quantities.
- System writes a
countledger entry:actual - expected = variance. - Variance report shows which ingredients have significant discrepancies.
Variance thresholds for alerts vary by ingredient value. A 5% variance on salt is meaningless. A 5% variance on prime beef at $40/kg for a restaurant doing 200 covers per day is a $40 daily loss that compounds.
For restaurants in Lebanon and MENA where ingredient costs are significant relative to margins, catching 3% consistent variance on expensive proteins can save $800 to $1,500 per month in a mid-sized operation.
Handling supplier price changes
When a supplier updates the price of an ingredient, the system must:
- Update the current cost for future purchases.
- Preserve the historical cost for past deductions and variance reports.
The stock ledger model handles this naturally: each ledger entry records the cost at the time of the transaction. Recipe cost calculations use the current ingredient cost by default but can use historical costs for period analysis.
For purchase orders, record both the purchase price and the unit quantity received. This builds a price history that helps procurement identify price trends and negotiate better rates.
Key lessons from production
Building inventory systems for restaurant groups in MENA comes down to a few architectural truths:
- Model waste at every stage: receiving, prep, cooking, plating. Systems that only track sales deductions drift within a week.
- Yield percentages are not optional. Every protein and most vegetables have meaningfully different actual-yield versus purchased-weight.
- The stock ledger must be append-only. Deleting or updating ledger entries destroys the audit trail and makes variance analysis impossible.
- POS integration must be asynchronous. Inventory processing should never block payment flows.
- Materialize flat BOM at recipe save time. Walking the recipe tree on every sale does not scale for high-volume operations.
- Alert on variance, not just on low stock. Low stock alerts tell you when to order. Variance alerts tell you when something is wrong operationally.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire builds POS and inventory systems for restaurant groups and retail operations across Lebanon and the MENA region. If you need a custom inventory solution or want to evaluate RTYLR for your operation, reach out.
https://voxire.com/get-a-quote/



