205 lines
6.3 KiB
Python
205 lines
6.3 KiB
Python
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}
|