Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+354
View File
@@ -0,0 +1,354 @@
"""Пороги и классификация отклонений 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"