Files
Meal-Planner/backend/app/services/planner/cost.py
T
admin fd8ba3c4d2 feat(backend): implement unit conversion for cost calculation
- Add UnitConverter (normalization, within-family, density tables)
- Update cost.py to convert recipe qty to grocery price unit
- Update generate.py _load_match_index to fetch ingredient name + unit
- Fix orchestrator email/shopping-list cost loops to use conversion
- Fix missing Ingredient import in generate.py
- Add 19 unit tests
2026-05-24 13:46:50 -07:00

131 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Compute per-recipe cost and savings against ingredient_grocery_match.
Inputs:
ingredients: list[dict] from recipe.ingredients JSONB
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
pantry_ingredient_ids: set of ingredient_ids in home_pantry
ingredient_name_index: optional dict[ingredient_id, name_lower] for unit conversion
Strategy:
For each ingredient in the recipe, take the top-confidence match
(or skip if none). Cost = current_price × converted_qty.
Unit conversion is attempted via app.utils.units.convert_qty; if
the units are incommensurable, falls back to dimensionless qty to
preserve the monotonic ranking signal.
Savings = max(regular - current, 0) × converted_qty.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Dict, Iterable, List, Optional, Set
from uuid import UUID
from app.services.planner.types import IngredientCost, RecipeCost
from app.utils.units import convert_qty
def _decimal(v) -> Decimal:
if v is None:
return Decimal("0")
return v if isinstance(v, Decimal) else Decimal(str(v))
def _scale(price: Decimal, qty: float) -> Decimal:
return (price * Decimal(str(qty))).quantize(Decimal("0.01"))
def compute_recipe_cost(
*,
recipe_id: UUID,
ingredients: Iterable[dict],
match_index: Dict[UUID, List[dict]],
pantry_ingredient_ids: Set[UUID],
servings: int = 4,
) -> RecipeCost:
line_items: List[IngredientCost] = []
total_cost = Decimal("0.00")
total_savings = Decimal("0.00")
sale_count = 0
matched_count = 0
pantry_hits = 0
total = 0
for raw in ingredients:
total += 1
ing_id = raw["ingredient_id"]
if isinstance(ing_id, str):
ing_id = UUID(ing_id)
qty = float(raw.get("qty") or 1.0)
unit = raw.get("unit")
if ing_id in pantry_ingredient_ids:
pantry_hits += 1
candidates = match_index.get(ing_id) or []
if not candidates:
line_items.append(
IngredientCost(
ingredient_id=ing_id,
qty=qty,
unit=unit,
grocery_item_id=None,
grocery_item_name=None,
current_price=None,
regular_price=None,
is_on_sale=False,
estimated_cost=Decimal("0.00"),
estimated_savings=Decimal("0.00"),
matched=False,
)
)
continue
best = candidates[0]
current = _decimal(best.get("current_price"))
regular = _decimal(best.get("regular_price"))
is_on_sale = bool(best.get("is_on_sale"))
converted_qty = float(
convert_qty(
qty,
unit,
best.get("grocery_unit"),
ingredient_name_lower=best.get("ingredient_name"),
)
)
line_cost = _scale(current, converted_qty)
line_savings = _scale(max(regular - current, Decimal("0")), converted_qty)
matched_count += 1
if is_on_sale:
sale_count += 1
total_cost += line_cost
total_savings += line_savings
line_items.append(
IngredientCost(
ingredient_id=ing_id,
qty=qty,
unit=unit,
grocery_item_id=best.get("grocery_item_id"),
grocery_item_name=best.get("grocery_item_name"),
current_price=current,
regular_price=regular,
is_on_sale=is_on_sale,
estimated_cost=line_cost,
estimated_savings=line_savings,
matched=True,
)
)
return RecipeCost(
recipe_id=recipe_id,
total_cost=total_cost.quantize(Decimal("0.01")),
total_savings=total_savings.quantize(Decimal("0.01")),
sale_ingredient_count=sale_count,
matched_ingredient_count=matched_count,
total_ingredient_count=total,
pantry_hit_count=pantry_hits,
servings=max(servings, 1),
line_items=line_items,
)