from __future__ import annotations import logging from datetime import datetime from typing import Any from app import db from app.lab.calc.nutrients import parse_num from app.lab.indicators import label_to_indicator_key from app.lab.models import ( LabRationCalcIndicator, LabRationCalcTotal, LabRationCompoundLine, LabRecipeRation, ) from app.models.base import default_uuid _log = logging.getLogger("app.lab.calc") def _indicator_key(row: dict[str, Any]) -> str | None: key = row.get("key") if key: return str(key) label = row.get("label") if label: return label_to_indicator_key(str(label)) return None def _log_calc_errors(recipe_id: str, errors: list[str] | None) -> None: for message in errors or []: text = str(message or "").strip() if text: _log.warning("ration calc recipe=%s: %s", recipe_id, text) def clear_calc(recipe_id: str) -> None: for model in ( LabRationCalcTotal, LabRationCalcIndicator, LabRationCompoundLine, ): model.query.filter_by(recipe_id=recipe_id).delete(synchronize_session=False) def _save_totals(recipe_id: str, scope: str, totals: list[dict[str, Any]] | None) -> None: for idx, row in enumerate(totals or []): db.session.add( LabRationCalcTotal( id=default_uuid(), recipe_id=recipe_id, scope=scope, metric_key=str(row.get("key") or f"metric_{idx}"), label=str(row.get("label") or ""), value=parse_num(row.get("value")), sort_order=idx, ) ) def _save_indicators( recipe_id: str, scope: str, indicators: list[dict[str, Any]] | None, ) -> None: for idx, row in enumerate(indicators or []): db.session.add( LabRationCalcIndicator( id=default_uuid(), recipe_id=recipe_id, scope=scope, indicator_key=_indicator_key(row), label=str(row.get("label") or ""), unit=str(row.get("unit") or ""), min_value=parse_num(row.get("min")), max_value=parse_num(row.get("max")), content=parse_num(row.get("content")), diff=parse_num(row.get("diff")), sort_order=idx, ) ) def _save_compound_lines(recipe_id: str, lines: list[dict[str, Any]] | None) -> None: for idx, row in enumerate(lines or []): db.session.add( LabRationCompoundLine( id=default_uuid(), recipe_id=recipe_id, row_index=idx, ingredient_name=row.get("ingredient_name"), daily_kg=parse_num(row.get("daily_kg")), share_pct=parse_num(row.get("share_pct")), ) ) def save_calc_result(recipe_id: str, result: dict[str, Any], header: LabRecipeRation) -> None: clear_calc(recipe_id) _log_calc_errors(recipe_id, result.get("errors")) header.calc_engine = str(result.get("engine") or "native") calculated_at = result.get("calculated_at") if calculated_at: try: header.calculated_at = datetime.fromisoformat(str(calculated_at).replace("Z", "+00:00")) except ValueError: header.calculated_at = datetime.utcnow() else: header.calculated_at = datetime.utcnow() _save_totals(recipe_id, "ration", result.get("totals")) _save_indicators(recipe_id, "ration", result.get("indicators")) compound = result.get("compound") if compound: _save_totals(recipe_id, "compound", compound.get("totals")) _save_indicators(recipe_id, "compound", compound.get("indicators")) _save_compound_lines(recipe_id, compound.get("lines")) def _load_totals(recipe_id: str, scope: str) -> list[dict[str, Any]]: rows = ( LabRationCalcTotal.query.filter_by(recipe_id=recipe_id, scope=scope) .order_by(LabRationCalcTotal.sort_order) .all() ) return [ {"key": row.metric_key, "label": row.label, "value": row.value} for row in rows ] def _load_indicators(recipe_id: str, scope: str) -> list[dict[str, Any]]: rows = ( LabRationCalcIndicator.query.filter_by(recipe_id=recipe_id, scope=scope) .order_by(LabRationCalcIndicator.sort_order) .all() ) return [ { "key": row.indicator_key, "label": row.label, "unit": row.unit, "min": row.min_value, "max": row.max_value, "content": row.content, "diff": row.diff, } for row in rows ] def load_compound_results(recipe_id: str) -> dict[str, Any]: totals = _load_totals(recipe_id, "compound") indicators = _load_indicators(recipe_id, "compound") lines = ( LabRationCompoundLine.query.filter_by(recipe_id=recipe_id) .order_by(LabRationCompoundLine.row_index) .all() ) if not totals and not indicators and not lines: return {} return { "totals": totals, "indicators": indicators, "lines": [ { "ingredient_name": row.ingredient_name, "daily_kg": row.daily_kg, "share_pct": row.share_pct, } for row in lines ], } def load_ration_results(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]: if header is None or header.calculated_at is None: return {} totals = _load_totals(recipe_id, "ration") indicators = _load_indicators(recipe_id, "ration") if not totals and not indicators: return {} compound = load_compound_results(recipe_id) payload: dict[str, Any] = { "calculated_at": header.calculated_at.isoformat(), "engine": header.calc_engine or "native", "totals": totals, "indicators": indicators, } if compound: payload["compound"] = compound return payload def load_params(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]: if header is None or not header.seed_source: return {} if header.seed_source == "execution": return {"seeded_from": "execution"} if header.seed_source == "synced_from": return {"synced_from": "execution"} return {"source": header.seed_source}