"""Пороги и классификация отклонений feed_quality.""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Dict, List, Optional from app.services.feed_quality.settings_store import get_feed_quality_settings EVENT_OVERLOAD = "OVERLOAD" EVENT_UNDERLOAD = "UNDERLOAD" EVENT_LOADING_TIME = "LOADING_TIME" EVENT_LOADING_FAST = "LOADING_FAST" EVENT_MIX_TIME = "MIX_TIME" EVENT_LEFT_IN_MIXER = "LEFT_IN_MIXER" TIME_ISSUE_FAST = "fast" TIME_ISSUE_SLOW = "slow" SEVERITY_INFO = "info" SEVERITY_WARNING = "warning" SEVERITY_ERROR = "error" @dataclass(frozen=True) class AlertCandidate: event_type: str severity: str detail: str component_name: Optional[str] = None group_name: Optional[str] = None deviation_kg: Optional[float] = None deviation_pct: Optional[float] = None cost_deviation_rub: Optional[float] = None unloading_report_id: Optional[str] = None def deviation_pct(dev_kg: float, target_kg: float) -> Optional[float]: if target_kg <= 0: return None return (dev_kg / target_kg) * 100.0 def _settings() -> Dict[str, Any]: return get_feed_quality_settings() def _severity_from_pct(abs_pct: float, *, warning_pct: float, critical_pct: float) -> Optional[str]: if abs_pct < warning_pct: return None if abs_pct >= critical_pct: return SEVERITY_ERROR return SEVERITY_WARNING def _max_severity(a: str, b: str) -> str: rank = {SEVERITY_WARNING: 1, SEVERITY_ERROR: 2} return a if rank.get(a, 0) >= rank.get(b, 0) else b @dataclass(frozen=True) class ComponentTimeIssue: kind: str severity: str duration_sec: float detail: str deviation_sec: float def classify_loading_component( *, component_name: str, target_kg: float, actual_kg: float, price_rub_per_kg: float = 0.0, ) -> List[AlertCandidate]: cfg = _settings().get("loading") or {} if not cfg.get("enabled", True): return [] if target_kg <= 0: return [] dev_kg = actual_kg - target_kg pct = deviation_pct(dev_kg, target_kg) if pct is None: return [] severity = _severity_from_pct( abs(pct), warning_pct=float(cfg.get("warning_pct", 10.0)), critical_pct=float(cfg.get("critical_pct", 15.0)), ) if severity is None: return [] if dev_kg > 0: event_type = EVENT_OVERLOAD sign = "+" else: event_type = EVENT_UNDERLOAD sign = "" cost = max(0.0, dev_kg) * max(0.0, price_rub_per_kg) detail = ( f"Компонент «{component_name}»: план {target_kg:.1f} кг, факт {actual_kg:.1f} кг, " f"отклонение {sign}{dev_kg:.1f} кг ({sign}{pct:.1f}%)" ) return [ AlertCandidate( event_type=event_type, severity=severity, detail=detail, component_name=component_name, deviation_kg=dev_kg, deviation_pct=pct, cost_deviation_rub=cost if cost > 0 else None, ) ] def classify_component_loading_time( *, component_name: str, duration_sec: Optional[float], ) -> Optional[ComponentTimeIssue]: """Слишком быстро (< min) или слишком долго (> max).""" cfg = _settings().get("loading") or {} if not cfg.get("enabled", True): return None if duration_sec is None: return None duration = float(duration_sec) if duration < 0: return None warn_min = int(cfg.get("warning_min_sec", 3)) crit_min = int(cfg.get("critical_min_sec", 1)) warn_max = int(cfg.get("warning_max_sec", 180)) crit_max = int(cfg.get("critical_max_sec", 600)) if crit_min > 0 and duration < crit_min: return ComponentTimeIssue( kind=TIME_ISSUE_FAST, severity=SEVERITY_ERROR, duration_sec=duration, detail=( f"загрузка {duration:.1f} с — быстрее критичного порога {crit_min} с" ), deviation_sec=crit_min - duration, ) if warn_min > 0 and duration < warn_min: return ComponentTimeIssue( kind=TIME_ISSUE_FAST, severity=SEVERITY_WARNING, duration_sec=duration, detail=( f"загрузка {duration:.1f} с — быстрее порога предупреждения {warn_min} с" ), deviation_sec=warn_min - duration, ) if crit_max > 0 and duration > crit_max: return ComponentTimeIssue( kind=TIME_ISSUE_SLOW, severity=SEVERITY_ERROR, duration_sec=duration, detail=( f"загрузка {duration:.1f} с — дольше критичного порога {crit_max} с" ), deviation_sec=duration - crit_max, ) if warn_max > 0 and duration > warn_max: return ComponentTimeIssue( kind=TIME_ISSUE_SLOW, severity=SEVERITY_WARNING, duration_sec=duration, detail=( f"загрузка {duration:.1f} с — дольше порога предупреждения {warn_max} с" ), deviation_sec=duration - warn_max, ) return None def _standalone_time_alert( *, component_name: str, time_issue: ComponentTimeIssue, ) -> AlertCandidate: event_type = ( EVENT_LOADING_FAST if time_issue.kind == TIME_ISSUE_FAST else EVENT_LOADING_TIME ) return AlertCandidate( event_type=event_type, severity=time_issue.severity, detail=f"Компонент «{component_name}»: {time_issue.detail}", component_name=component_name, deviation_kg=time_issue.deviation_sec, deviation_pct=None, ) def merge_component_loading_alerts( *, component_name: str, weight_alert: Optional[AlertCandidate], time_issue: Optional[ComponentTimeIssue], ) -> List[AlertCandidate]: """Вес + время — одна карточка; только время — отдельная (быстро или долго).""" if weight_alert and time_issue: merged_detail = f"{weight_alert.detail}; {time_issue.detail}" return [ AlertCandidate( event_type=weight_alert.event_type, severity=_max_severity(weight_alert.severity, time_issue.severity), detail=merged_detail, component_name=component_name, deviation_kg=weight_alert.deviation_kg, deviation_pct=weight_alert.deviation_pct, cost_deviation_rub=weight_alert.cost_deviation_rub, ) ] if weight_alert: return [weight_alert] if time_issue: return [_standalone_time_alert(component_name=component_name, time_issue=time_issue)] return [] def classify_mix_time( *, target_sec: Optional[int], actual_sec: Optional[int], ) -> List[AlertCandidate]: cfg = _settings().get("mix_time") or {} if not cfg.get("enabled", True): return [] if target_sec is None or actual_sec is None: return [] diff = int(actual_sec) - int(target_sec) abs_diff = abs(diff) warn = int(cfg.get("warning_delta_sec", 30)) crit = int(cfg.get("critical_delta_sec", 60)) if abs_diff < warn: return [] severity = SEVERITY_ERROR if abs_diff >= crit else SEVERITY_WARNING detail = ( f"Время смешивания: план {target_sec} с, факт {actual_sec} с (Δ {diff:+d} с)" ) return [ AlertCandidate( event_type=EVENT_MIX_TIME, severity=severity, detail=detail, deviation_kg=float(diff), deviation_pct=None, ) ] def classify_unloading_group( *, group_name: str, target_kg: float, unloaded_kg: float, ) -> List[AlertCandidate]: cfg = _settings().get("unloading") or {} if not cfg.get("enabled", True): return [] if target_kg <= 0: return [] dev_kg = unloaded_kg - target_kg pct = deviation_pct(dev_kg, target_kg) if pct is None: return [] severity = _severity_from_pct( abs(pct), warning_pct=float(cfg.get("warning_pct", 5.0)), critical_pct=float(cfg.get("critical_pct", 10.0)), ) if severity is None: return [] if dev_kg > 0: event_type = EVENT_OVERLOAD sign = "+" else: event_type = EVENT_UNDERLOAD sign = "" detail = ( f"Группа «{group_name}»: план {target_kg:.1f} кг, выгружено {unloaded_kg:.1f} кг, " f"отклонение {sign}{dev_kg:.1f} кг ({sign}{pct:.1f}%)" ) return [ AlertCandidate( event_type=event_type, severity=severity, detail=detail, group_name=group_name, deviation_kg=dev_kg, deviation_pct=pct, unloading_report_id=None, ) ] def classify_left_in_mixer( *, remaining_kg: float, total_kg: float, unloading_report_id: Optional[str] = None, ) -> List[AlertCandidate]: cfg = _settings().get("left_in_mixer") or {} if not cfg.get("enabled", True): return [] warn_kg = float(cfg.get("warning_min_kg", 5.0)) warn_pct = float(cfg.get("warning_min_pct", 2.0)) crit_kg = float(cfg.get("critical_min_kg", 15.0)) pct_of_total = deviation_pct(remaining_kg, total_kg) if total_kg > 0 else None if remaining_kg >= crit_kg: severity = SEVERITY_ERROR elif remaining_kg >= warn_kg or ( pct_of_total is not None and pct_of_total >= warn_pct ): severity = SEVERITY_WARNING else: return [] pct_text = f" ({pct_of_total:.1f}% от партии)" if pct_of_total is not None else "" detail = f"В миксере осталось {remaining_kg:.1f} кг после выгрузки{pct_text}" return [ AlertCandidate( event_type=EVENT_LEFT_IN_MIXER, severity=severity, detail=detail, deviation_kg=remaining_kg, deviation_pct=pct_of_total, unloading_report_id=unloading_report_id, ) ] def settings_section_for_alert( event_type: str, *, component_name: Optional[str], group_name: Optional[str] ) -> str: if event_type == EVENT_MIX_TIME: return "mix_time" if event_type == EVENT_LEFT_IN_MIXER: return "left_in_mixer" if group_name and event_type in (EVENT_OVERLOAD, EVENT_UNDERLOAD): return "unloading" if event_type in ( EVENT_OVERLOAD, EVENT_UNDERLOAD, EVENT_LOADING_TIME, EVENT_LOADING_FAST, ): return "loading" return "loading"