#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Модуль для расчетов рецептов кормления. Перенесен из корневого recipe_calculator.py в пакет app.services без изменения алгоритмов. """ from typing import List, Dict, Any, Optional, Tuple import os import json import logging logger = logging.getLogger(__name__) def _calc_debug_enabled() -> bool: """ Включает подробный трейс расчетов только по флагу окружения. Удобно для диагностики: set CALC_DEBUG=1 """ return str(os.getenv("CALC_DEBUG", "")).strip().lower() in { "1", "true", "yes", "on", } def _calc_log(msg: str, **data) -> None: if not _calc_debug_enabled(): return try: if data: logger.info( "[CALC] %s | %s", msg, json.dumps(data, ensure_ascii=False, default=str), ) else: logger.info("[CALC] %s", msg) except Exception: # никогда не ломаем расчет из-за логов pass def round_to_step5(value: float) -> float: """ Округляет значение до кратного 5 кг """ return round(value / 5) * 5 def calculate_ingredients( ingredients: List[Dict[str, Any]], heads_count: int, trip_percent: float, component_dry_matter_map: Optional[Dict[str, float]] = None, ) -> List[Dict[str, float]]: """ Рассчитывает веса и сухое вещество для ингредиентов """ calculated_ingredients = [] _calc_log( "calculate_ingredients:start", heads_count=heads_count, trip_percent=trip_percent, ingredient_count=len(ingredients), ) for idx, ing in enumerate(ingredients, 1): weight_per_head_raw = ing.get("weightPerHead", 0) weight_per_head = float(weight_per_head_raw) dry_matter_percent = float(ing.get("dryMatter", 0)) component_id = ing.get("component_id") _calc_log( "calculate_ingredients:weightPerHead:input", idx=idx, component_id=component_id, weightPerHead_raw=weight_per_head_raw, weightPerHead_raw_type=type(weight_per_head_raw).__name__, weightPerHead_float=weight_per_head, ) # Получаем dry_matter из компонента, если не указан и есть component_id dm_source = "payload" if dry_matter_percent == 0 and component_id and component_dry_matter_map: if component_id in component_dry_matter_map: dry_matter_percent = component_dry_matter_map[component_id] dm_source = "component_map" # Расчеты weight = weight_per_head * heads_count trip_weight = weight * (trip_percent / 100) dry_matter_per_head = weight_per_head * (dry_matter_percent / 100) if 0 < weight_per_head < 0.01: rounded_wph = round(weight_per_head, 3) else: rounded_wph = round(weight_per_head, 2) if 0 < dry_matter_per_head < 0.01: rounded_dm_per_head = round(dry_matter_per_head, 4) else: rounded_dm_per_head = round(dry_matter_per_head, 4) _calc_log( "ingredient:calc", idx=idx, component_id=component_id, dm_source=dm_source, input={ "weightPerHead": weight_per_head, "dryMatterPercent": dry_matter_percent, "headsCount": heads_count, "tripPercent": trip_percent, }, formula={ "totalWeight": "weightPerHead * headsCount", "tripWeight": "totalWeight * (tripPercent/100)", "dryMatterPerHead": "weightPerHead * (dryMatterPercent/100)", }, result={ "totalWeight": round(weight, 2), "tripWeight": round(trip_weight, 2), "dryMatterPerHead": rounded_dm_per_head, "weightPerHead": rounded_wph, }, ) _calc_log( "calculate_ingredients:weightPerHead:output", idx=idx, component_id=component_id, weightPerHead_before_round=weight_per_head, rounded_wph=rounded_wph, rounded_wph_type=type(rounded_wph).__name__, ) calculated_ingredients.append( { "totalWeight": round(weight, 2), "tripWeight": round(trip_weight, 2), "dryMatterPerHead": rounded_dm_per_head, "weightPerHead": rounded_wph, } ) _calc_log("calculate_ingredients:end", ingredient_count=len(calculated_ingredients)) return calculated_ingredients def calculate_totals( calculated_ingredients: List[Dict[str, float]], ingredients: List[Dict[str, Any]], ) -> Dict[str, float]: """ Рассчитывает общие итоги по ингредиентам """ total_weight = 0.0 total_trip_weight = 0.0 total_dry_matter_per_head = 0.0 total_weight_per_head = 0.0 _calc_log( "calculate_totals:start", ingredient_count=len(calculated_ingredients) ) for i, calc in enumerate(calculated_ingredients): total_weight += calc["totalWeight"] total_trip_weight += calc["tripWeight"] total_dry_matter_per_head += calc["dryMatterPerHead"] if "weightPerHead" in calc: total_weight_per_head += calc["weightPerHead"] elif i < len(ingredients): total_weight_per_head += float(ingredients[i].get("weightPerHead", 0)) totals = { "totalWeight": round(total_weight, 2), "totalTripWeight": round(total_trip_weight, 2), "totalDryMatterPerHead": round(total_dry_matter_per_head, 2), "totalWeightPerHead": round(total_weight_per_head, 2), } _calc_log("calculate_totals:end", totals=totals) return totals def calculate_unloading_groups( unloading_groups: List[Dict[str, Any]], total_trip_weight: float, heads_count: int, ) -> Tuple[List[Dict[str, float]], Dict[str, Any]]: """ Рассчитывает веса для групп выгрузки. """ calculated_groups: List[Dict[str, float]] = [] total_percent = 0.0 total_heads = 0.0 total_weight_kg = 0.0 for group in unloading_groups: group_type = group.get("distributionType", "percent") value = float(group.get("value", 0)) calculated_weight = 0.0 if group_type == "percent" and value > 0: calculated_weight = (total_trip_weight * value) / 100.0 elif group_type == "heads" and value > 0 and heads_count > 0: calculated_weight = (total_trip_weight / heads_count) * value calculated_weight_rounded = round_to_step5(calculated_weight) calculated_groups.append({"calculatedWeight": calculated_weight_rounded}) if group_type == "percent": total_percent += value else: total_heads += value total_weight_kg += calculated_weight unloading_totals = { "totalPercent": round(total_percent, 1), "totalHeads": int(total_heads), "totalWeightKg": round_to_step5(total_weight_kg), } return calculated_groups, unloading_totals def calculate_recipe( ingredients: List[Dict[str, Any]], heads_count: int, trip_percent: float, unloading_groups: Optional[List[Dict[str, Any]]] = None, component_dry_matter_map: Optional[Dict[str, float]] = None, calculate_from_dry_matter: bool = False, ) -> Dict[str, Any]: """ Главная функция для расчета всего рецепта. """ _calc_log( "calculate_recipe:start", heads_count=heads_count, trip_percent=trip_percent, calculate_from_dry_matter=calculate_from_dry_matter, ingredient_count=len(ingredients or []), unloading_group_count=len(unloading_groups or []), ) if unloading_groups is None: unloading_groups = [] if calculate_from_dry_matter: calculated_ingredients = calculate_ingredients_from_dry_matter( ingredients, heads_count, trip_percent, component_dry_matter_map ) else: calculated_ingredients = calculate_ingredients( ingredients, heads_count, trip_percent, component_dry_matter_map ) totals = calculate_totals(calculated_ingredients, ingredients) calculated_groups, unloading_totals = calculate_unloading_groups( unloading_groups, totals["totalTripWeight"], heads_count ) result: Dict[str, Any] = { "ingredients": calculated_ingredients, "totals": totals, "unloadingGroups": calculated_groups, "unloadingTotals": unloading_totals, } _calc_log("calculate_recipe:end", totals=totals, unloadingTotals=unloading_totals) return result def calculate_ingredients_from_dry_matter( ingredients: List[Dict[str, Any]], heads_count: int, trip_percent: float, component_dry_matter_map: Optional[Dict[str, float]] = None, ) -> List[Dict[str, float]]: """ Рассчитывает веса от сухого вещества (обратный расчет). """ calculated_ingredients: List[Dict[str, float]] = [] _calc_log( "calculate_ingredients_from_dry_matter:start", heads_count=heads_count, trip_percent=trip_percent, ingredient_count=len(ingredients), ) for idx, ing in enumerate(ingredients, 1): dry_matter_per_head_raw = ing.get("dryMatterPerHead", 0) dry_matter_per_head = float(dry_matter_per_head_raw) dry_matter_percent = float(ing.get("dryMatter", 0)) component_id = ing.get("component_id") _calc_log( "calculate_ingredients_from_dry_matter:input", idx=idx, component_id=component_id, dryMatterPerHead_raw=dry_matter_per_head_raw, dryMatterPercent=dry_matter_percent, ) dm_source = "payload" if dry_matter_percent == 0 and component_id and component_dry_matter_map: if component_id in component_dry_matter_map: dry_matter_percent = component_dry_matter_map[component_id] dm_source = "component_map" EPSILON = 1e-6 if dry_matter_percent > EPSILON: weight_per_head = (dry_matter_per_head * 100.0) / dry_matter_percent if weight_per_head < 0.01 and dry_matter_per_head >= 0.001: weight_per_head = 0.01 else: if dry_matter_per_head > EPSILON: _calc_log( "ingredient:warning_zero_dm_percent", idx=idx, component_id=component_id, dry_matter_per_head=dry_matter_per_head, message=( "dry_matter_percent равен 0, но dry_matter_per_head > 0 - " "веса обнулены" ), ) weight_per_head = 0.0 weight = weight_per_head * heads_count trip_weight = weight * (trip_percent / 100.0) _calc_log( "ingredient:inverse_calc", idx=idx, component_id=component_id, dm_source=dm_source, input={ "dryMatterPerHead": dry_matter_per_head, "dryMatterPercent": dry_matter_percent, "headsCount": heads_count, "tripPercent": trip_percent, }, ) calculated_ingredients.append( { "totalWeight": round(weight, 2), "tripWeight": round(trip_weight, 2), "weightPerHead": round(weight_per_head, 2), "dryMatterPerHead": round(dry_matter_per_head, 4), } ) _calc_log( "calculate_ingredients_from_dry_matter:end", ingredient_count=len(calculated_ingredients), ) return calculated_ingredients