Public Access
- 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
131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
"""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,
|
||
)
|