#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Модуль для расчетов рецептов кормления """ 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 кг Args: value: Значение для округления Returns: Округленное значение, кратное 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]]: """ Рассчитывает веса и сухое вещество для ингредиентов Args: ingredients: Список ингредиентов с полями weightPerHead, dryMatter, component_id heads_count: Количество голов trip_percent: Процент на рейс (0-100) component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально) Returns: Список словарей с рассчитанными значениями: totalWeight, tripWeight, dryMatterPerHead, weightPerHead """ 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 = weightPerHead * headsCount weight = weight_per_head * heads_count # tripWeight = totalWeight * (tripPercent / 100) trip_weight = weight * (trip_percent / 100) # dryMatterPerHead = weightPerHead * (dryMatterPercent / 100) dry_matter_per_head = weight_per_head * (dry_matter_percent / 100) # Округляем до 2 знаков после запятой, но защищаем от обнуления очень маленьких значений # Если значение очень маленькое (< 0.01), округляем до 3 знаков для сохранения точности if weight_per_head > 0 and weight_per_head < 0.01: rounded_wph = round(weight_per_head, 3) else: rounded_wph = round(weight_per_head, 2) # СВ/гол до 4 знаков, чтобы при обратном расчёте (режим замок СВ) Вес/гол сохранялся до сотых if dry_matter_per_head > 0 and 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]: """ Рассчитывает общие итоги по ингредиентам Args: calculated_ingredients: Результаты расчета ингредиентов ingredients: Исходные данные ингредиентов (используется как fallback) Returns: Словарь с итогами: totalWeight, totalTripWeight, totalDryMatterPerHead, totalWeightPerHead """ total_weight = 0 total_trip_weight = 0 total_dry_matter_per_head = 0 total_weight_per_head = 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'] # weightPerHead теперь всегда есть в calculated_ingredients if 'weightPerHead' in calc: total_weight_per_head += calc['weightPerHead'] elif i < len(ingredients): # Fallback на случай, если weightPerHead отсутствует 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]]: """ Рассчитывает веса для групп выгрузки Args: unloading_groups: Список групп выгрузки с полями distributionType, value total_trip_weight: Общий вес на рейс heads_count: Количество голов Returns: Кортеж: (список рассчитанных групп, итоги групп) """ calculated_groups = [] total_percent = 0 total_heads = 0 total_weight_kg = 0 # Суммируем неокругленные значения for group in unloading_groups: group_type = group.get('distributionType', 'percent') value = float(group.get('value', 0)) calculated_weight = 0 if group_type == 'percent' and value > 0: # Рассчитываем через процент от итогового веса calculated_weight = (total_trip_weight * value) / 100 elif group_type == 'heads' and value > 0 and heads_count > 0: # Рассчитываем через количество голов calculated_weight = (total_trip_weight / heads_count) * value # Округляем до кратного 5 для отображения в группе 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 # Округляем итог согласно формуле: W_groups_total_rounded = round(W_groups_total / 5) × 5 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]: """ Главная функция для расчета всего рецепта Args: ingredients: Список ингредиентов heads_count: Количество голов trip_percent: Процент на рейс unloading_groups: Список групп выгрузки (опционально) component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально) calculate_from_dry_matter: Если True, рассчитывает от сухого вещества (обратный расчет) Returns: Словарь с результатами расчетов """ _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 = { '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]]: """ Рассчитывает веса от сухого вещества (обратный расчет) Args: ingredients: Список ингредиентов с полями dryMatterPerHead, dryMatter, component_id heads_count: Количество голов trip_percent: Процент на рейс (0-100) component_dry_matter_map: Словарь для получения dry_matter по component_id (опционально) Returns: Список словарей с рассчитанными значениями: totalWeight, tripWeight, weightPerHead, dryMatterPerHead """ calculated_ingredients = [] _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, ) # Получаем 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" # Обратные расчеты от сухого вещества # Используем более точную проверку с учетом погрешности округления EPSILON = 1e-6 # Порог для сравнения с нулем if dry_matter_percent > EPSILON: # weightPerHead = dryMatterPerHead / (dryMatterPercent / 100) # Или: weightPerHead = (dryMatterPerHead * 100) / dryMatterPercent weight_per_head = (dry_matter_per_head * 100) / dry_matter_percent # ЗАЩИТА: если результат получился очень маленьким (< 0.01), # но dry_matter_per_head был значимым, сохраняем минимум 0.01 # Это предотвращает обнуление из-за округления if weight_per_head < 0.01 and dry_matter_per_head >= 0.001: weight_per_head = 0.01 else: # ЗАЩИТА: если dry_matter_percent равен 0, но есть dry_matter_per_head, # не обнуляем веса - это может быть ошибка данных # В этом случае возвращаем 0, но логируем предупреждение 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 # Расчеты # totalWeight = weightPerHead * headsCount weight = weight_per_head * heads_count # tripWeight = totalWeight * (tripPercent / 100) trip_weight = weight * (trip_percent / 100) _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, }, formula={ "weightPerHead": "(dryMatterPerHead * 100) / dryMatterPercent", "totalWeight": "weightPerHead * headsCount", "tripWeight": "totalWeight * (tripPercent/100)", }, result={ "weightPerHead": round(weight_per_head, 2), "totalWeight": round(weight, 2), "tripWeight": round(trip_weight, 2), "dryMatterPerHead": round(dry_matter_per_head, 2), }, ) # Округляем до 2 знаков после запятой, но для очень маленьких значений используем 3 знака # чтобы избежать обнуления из-за округления if weight_per_head > 0 and weight_per_head < 0.01: rounded_wph = round(weight_per_head, 3) else: rounded_wph = round(weight_per_head, 2) _calc_log( "calculate_ingredients_from_dry_matter:weightPerHead:output", idx=idx, component_id=component_id, formula="(dryMatterPerHead*100)/dryMatterPercent", 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), 'weightPerHead': rounded_wph, 'dryMatterPerHead': round(dry_matter_per_head, 4) # до 4 знаков, чтобы при обратном расчёте Вес/гол сохранялся до сотых }) _calc_log("calculate_ingredients_from_dry_matter:end", ingredient_count=len(calculated_ingredients)) return calculated_ingredients