Интегрирован 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
@@ -0,0 +1 @@
"""Контроль отклонений при загрузке и выгрузке корма."""
@@ -0,0 +1,402 @@
"""Оценка отчётов и запись feed_alert."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import delete, exists, select
from app import db
from app.models import Component, LoadingReport, UnloadingReport, UnloadingReportGroup
from app.models.feed_alert import FeedAlert
from app.services.feed_quality.notify import notify_feed_quality_alerts
from app.services.feed_quality.rules import (
AlertCandidate,
classify_component_loading_time,
classify_left_in_mixer,
classify_loading_component,
classify_mix_time,
classify_unloading_group,
merge_component_loading_alerts,
)
from app.timeutil import utc_now_naive
logger = logging.getLogger(__name__)
def _component_prices(component_ids: List[Optional[str]], names: List[str]) -> Dict[str, float]:
prices: Dict[str, float] = {}
ids = [x for x in component_ids if x]
if ids:
rows = db.session.execute(select(Component).where(Component.id.in_(ids))).scalars().all()
for row in rows:
if row and not getattr(row, "is_deleted", False):
prices[row.id] = float(row.price or 0)
for name in names:
if not name or name in prices:
continue
row = db.session.execute(
select(Component).where(Component.name == name, Component.is_deleted.is_(False)).limit(1)
).scalar_one_or_none()
if row:
prices[name] = float(row.price or 0)
return prices
def _price_for_component(
prices: Dict[str, float], *, component_id: Optional[str], name: str
) -> float:
if component_id and component_id in prices:
return prices[component_id]
return prices.get(name, 0.0)
def _collect_candidates_for_loading(report: LoadingReport) -> List[AlertCandidate]:
candidates: List[AlertCandidate] = []
components = [
c
for c in (report.components or [])
if c and not getattr(c, "is_deleted", False)
]
prices = _component_prices(
[getattr(c, "component_id", None) for c in components],
[str(c.component_name or "") for c in components],
)
durations: Dict[str, float] = {}
for lt in report.component_loading_times or []:
if lt and not getattr(lt, "is_deleted", False):
durations[str(lt.component_name or "")] = float(lt.loading_duration or 0)
for comp in components:
target = float(comp.target_weight or 0)
actual = float(comp.actual_weight or 0)
name = str(comp.component_name or "")
price = _price_for_component(
prices,
component_id=getattr(comp, "component_id", None),
name=name,
)
weight_alerts = classify_loading_component(
component_name=name,
target_kg=target,
actual_kg=actual,
price_rub_per_kg=price,
)
weight_alert = weight_alerts[0] if weight_alerts else None
time_issue = classify_component_loading_time(
component_name=name,
duration_sec=durations.get(name),
)
candidates.extend(
merge_component_loading_alerts(
component_name=name,
weight_alert=weight_alert,
time_issue=time_issue,
)
)
candidates.extend(
classify_mix_time(
target_sec=report.target_mixing_time,
actual_sec=report.actual_mixing_time,
)
)
return candidates
def _collect_candidates_for_unloading(
unloading: UnloadingReport,
) -> List[AlertCandidate]:
candidates: List[AlertCandidate] = []
groups = [
g
for g in (unloading.groups or [])
if g and not getattr(g, "is_deleted", False)
]
for group in groups:
for item in classify_unloading_group(
group_name=str(group.name or ""),
target_kg=float(group.target_weight or 0),
unloaded_kg=float(group.unloaded_weight or 0),
):
candidates.append(
AlertCandidate(
event_type=item.event_type,
severity=item.severity,
detail=item.detail,
group_name=item.group_name,
deviation_kg=item.deviation_kg,
deviation_pct=item.deviation_pct,
unloading_report_id=unloading.id,
)
)
candidates.extend(
classify_left_in_mixer(
remaining_kg=float(unloading.remaining_weight or 0),
total_kg=float(unloading.total_weight or 0),
unloading_report_id=unloading.id,
)
)
return candidates
def _persist_alerts(
*,
loading_report_id: str,
recipe_id: str,
recipe_name: str,
client_id: Optional[str],
report_time,
candidates: List[AlertCandidate],
) -> List[FeedAlert]:
db.session.execute(
delete(FeedAlert).where(FeedAlert.loading_report_id == loading_report_id)
)
now = utc_now_naive()
created_at = report_time or now
rows: List[FeedAlert] = []
for cand in candidates:
row = FeedAlert(
event_type=cand.event_type,
severity=cand.severity,
loading_report_id=loading_report_id,
unloading_report_id=cand.unloading_report_id,
recipe_id=recipe_id,
recipe_name=recipe_name or "",
component_name=cand.component_name,
group_name=cand.group_name,
detail=cand.detail,
deviation_kg=cand.deviation_kg,
deviation_pct=cand.deviation_pct,
cost_deviation_rub=cand.cost_deviation_rub,
client_id=client_id,
created_at=created_at,
)
db.session.add(row)
rows.append(row)
db.session.commit()
return rows
def _load_loading_report(report_id: str) -> Optional[LoadingReport]:
report = db.session.get(LoadingReport, report_id)
if report is None or getattr(report, "is_deleted", False):
return None
_ = report.components
_ = report.component_loading_times
return report
def _load_unloading_for_loading(loading_report_id: str) -> Optional[UnloadingReport]:
unloading = db.session.execute(
select(UnloadingReport)
.where(
UnloadingReport.loading_report_id == loading_report_id,
UnloadingReport.is_deleted.is_(False),
)
.order_by(UnloadingReport.start_time.desc())
.limit(1)
).scalar_one_or_none()
if unloading is None:
return None
_ = unloading.groups
return unloading
def evaluate_loading_report(
report_id: str, *, send_notifications: bool = True
) -> List[FeedAlert]:
report = _load_loading_report(report_id)
if report is None:
return []
candidates = _collect_candidates_for_loading(report)
unloading = _load_unloading_for_loading(report_id)
if unloading is not None:
candidates.extend(_collect_candidates_for_unloading(unloading))
try:
rows = _persist_alerts(
loading_report_id=report.id,
recipe_id=report.recipe_id,
recipe_name=report.recipe_name,
client_id=report.client_id,
report_time=report.start_time,
candidates=candidates,
)
if send_notifications:
notify_feed_quality_alerts(rows)
return rows
except Exception:
db.session.rollback()
logger.exception("[FEED-QUALITY] evaluate loading report %s failed", report_id)
return []
def evaluate_unloading_report(
unloading_report_id: str, *, send_notifications: bool = True
) -> List[FeedAlert]:
unloading = db.session.get(UnloadingReport, unloading_report_id)
if unloading is None or getattr(unloading, "is_deleted", False):
return []
_ = unloading.groups
loading_id = unloading.loading_report_id
if not loading_id:
return []
report = _load_loading_report(loading_id)
if report is None:
return []
candidates = _collect_candidates_for_loading(report)
candidates.extend(_collect_candidates_for_unloading(unloading))
try:
rows = _persist_alerts(
loading_report_id=loading_id,
recipe_id=report.recipe_id,
recipe_name=report.recipe_name,
client_id=report.client_id or unloading.client_id,
report_time=report.start_time,
candidates=candidates,
)
if send_notifications:
notify_feed_quality_alerts(rows)
return rows
except Exception:
db.session.rollback()
logger.exception(
"[FEED-QUALITY] evaluate unloading report %s failed", unloading_report_id
)
return []
def _parse_period_date(value: Optional[str], *, end_of_day: bool = False):
if not value:
return None
from datetime import datetime
try:
dt = datetime.strptime(str(value).strip()[:10], "%Y-%m-%d")
except ValueError:
return None
if end_of_day:
return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def backfill_feed_alerts_for_period(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 100,
) -> int:
"""Переоценка отчётов без алертов (отчёты до внедрения feed_quality)."""
dt_from = _parse_period_date(date_from)
dt_to = _parse_period_date(date_to, end_of_day=True)
if dt_from is None and dt_to is None:
return 0
lim = max(1, min(int(limit), 500))
stmt = (
select(LoadingReport.id)
.where(LoadingReport.is_deleted.is_(False))
.where(
~exists(
select(FeedAlert.id).where(
FeedAlert.loading_report_id == LoadingReport.id
)
)
)
.order_by(LoadingReport.start_time.desc())
.limit(lim)
)
if dt_from is not None:
stmt = stmt.where(LoadingReport.start_time >= dt_from)
if dt_to is not None:
stmt = stmt.where(LoadingReport.start_time <= dt_to)
report_ids = list(db.session.execute(stmt).scalars().all())
evaluated = 0
for report_id in report_ids:
try:
evaluate_loading_report(str(report_id), send_notifications=False)
evaluated += 1
except Exception:
logger.exception("[FEED-QUALITY] backfill evaluate %s failed", report_id)
if evaluated:
logger.info(
"[FEED-QUALITY] backfill period %s..%s: evaluated %s reports",
date_from,
date_to,
evaluated,
)
return evaluated
def reevaluate_feed_alerts_for_period(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 500,
) -> int:
"""Переоценка всех отчётов за период (после смены настроек)."""
dt_from = _parse_period_date(date_from)
dt_to = _parse_period_date(date_to, end_of_day=True)
if dt_from is None and dt_to is None:
return 0
lim = max(1, min(int(limit), 500))
stmt = (
select(LoadingReport.id)
.where(LoadingReport.is_deleted.is_(False))
.order_by(LoadingReport.start_time.desc())
.limit(lim)
)
if dt_from is not None:
stmt = stmt.where(LoadingReport.start_time >= dt_from)
if dt_to is not None:
stmt = stmt.where(LoadingReport.start_time <= dt_to)
report_ids = list(db.session.execute(stmt).scalars().all())
evaluated = 0
for report_id in report_ids:
try:
evaluate_loading_report(str(report_id), send_notifications=False)
evaluated += 1
except Exception:
logger.exception("[FEED-QUALITY] reevaluate %s failed", report_id)
if evaluated:
logger.info(
"[FEED-QUALITY] reevaluate period %s..%s: %s reports",
date_from,
date_to,
evaluated,
)
return evaluated
def evaluate_reports_pushed_from_client(
applied_changes: List[Dict[str, Any]], *, send_notifications: bool = False
) -> None:
"""После sync push: переоценка отчётов без повторных UI-уведомлений."""
loading_ids: List[str] = []
unloading_ids: List[str] = []
for change in applied_changes:
table_name = change.get("table_name")
action = change.get("action")
record_id = change.get("record_id")
if action not in ("create", "update") or not record_id:
continue
if table_name == "loading_report":
loading_ids.append(str(record_id))
elif table_name == "unloading_report":
unloading_ids.append(str(record_id))
seen_loading: set[str] = set()
for lid in loading_ids:
if lid in seen_loading:
continue
seen_loading.add(lid)
try:
evaluate_loading_report(lid, send_notifications=send_notifications)
except Exception:
logger.exception("[FEED-QUALITY] sync evaluate loading %s", lid)
for uid in unloading_ids:
try:
evaluate_unloading_report(uid, send_notifications=send_notifications)
except Exception:
logger.exception("[FEED-QUALITY] sync evaluate unloading %s", uid)
@@ -0,0 +1,85 @@
"""Уведомления в центр зоотехника по алертам feed_quality."""
from __future__ import annotations
import logging
from typing import Iterable
from app.models.feed_alert import FeedAlert
from app.services.feed_quality.rules import (
EVENT_LEFT_IN_MIXER,
EVENT_LOADING_FAST,
EVENT_LOADING_TIME,
EVENT_MIX_TIME,
EVENT_OVERLOAD,
EVENT_UNDERLOAD,
SEVERITY_ERROR,
SEVERITY_WARNING,
settings_section_for_alert,
)
from app.services.feed_quality.settings_store import get_feed_quality_settings
from app.services.notification_center_service import create_notification, format_detail_timestamp
logger = logging.getLogger(__name__)
_EVENT_TITLES = {
EVENT_OVERLOAD: "Перегруз при загрузке",
EVENT_UNDERLOAD: "Недогруз при загрузке",
EVENT_LOADING_TIME: "Долгая загрузка компонента",
EVENT_LOADING_FAST: "Быстрая загрузка компонента",
EVENT_MIX_TIME: "Отклонение смешивания",
EVENT_LEFT_IN_MIXER: "Остаток в миксере",
}
def _alert_title(alert: FeedAlert) -> str:
if alert.group_name and alert.event_type in (EVENT_OVERLOAD, EVENT_UNDERLOAD):
return "Отклонение при выгрузке"
return _EVENT_TITLES.get(alert.event_type, "Отклонение кормления")
def _alert_detail(alert: FeedAlert) -> str:
when = format_detail_timestamp(alert.created_at)
recipe = alert.recipe_name or "рейс"
return f"Рейс «{recipe}»: {alert.detail}{when}"
def _should_notify(alert: FeedAlert) -> bool:
if alert.severity not in (SEVERITY_WARNING, SEVERITY_ERROR):
return False
section_key = settings_section_for_alert(
alert.event_type,
component_name=alert.component_name,
group_name=alert.group_name,
)
cfg = get_feed_quality_settings().get(section_key) or {}
if alert.severity == SEVERITY_ERROR:
return bool(cfg.get("notify_critical", True))
return bool(cfg.get("notify_warning", True))
def notify_feed_quality_alerts(alerts: Iterable[FeedAlert]) -> int:
"""Создаёт записи в «К» по флагам notify_* в настройках."""
count = 0
for alert in alerts:
if not _should_notify(alert):
continue
kind = "error" if alert.severity == SEVERITY_ERROR else "warning"
try:
create_notification(
title=_alert_title(alert),
detail=_alert_detail(alert),
kind=kind,
category="feed_quality",
page="reports",
link_kind="report_loading",
link_id=alert.loading_report_id,
)
count += 1
except Exception:
logger.exception(
"[FEED-QUALITY-NOTIFY] alert=%s report=%s",
alert.id,
alert.loading_report_id,
)
return count
+330
View File
@@ -0,0 +1,330 @@
"""Чтение алертов feed_quality для API."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app import db
from app.models import (
ComponentLoadingTime,
FeedingPeriod,
LoadingReport,
LoadingReportComponent,
PeriodRecipe,
UnloadingReport,
UnloadingReportGroup,
)
from app.models.feed_alert import FeedAlert
from app.services.feed_quality.evaluator import backfill_feed_alerts_for_period
from app.services.feed_quality.rules import EVENT_LEFT_IN_MIXER, EVENT_MIX_TIME
from app.timeutil import naive_utc_to_iso
def _parse_date(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
if not value:
return None
try:
dt = datetime.strptime(str(value).strip()[:10], "%Y-%m-%d")
except ValueError:
return None
if end_of_day:
return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def _recipe_ids_for_dispenser(dispenser_id: str) -> List[str]:
period_ids = (
db.session.execute(
select(FeedingPeriod.id).where(
FeedingPeriod.dispenser_id == dispenser_id,
FeedingPeriod.is_deleted.is_(False),
)
)
.scalars()
.all()
)
if not period_ids:
return []
return list(
db.session.execute(
select(PeriodRecipe.recipe_id).where(
PeriodRecipe.period_id.in_(period_ids),
PeriodRecipe.is_deleted.is_(False),
)
)
.scalars()
.all()
)
def _parse_dispenser_ids(dispenser_id: Optional[str]) -> List[str]:
if not dispenser_id:
return []
return [part.strip() for part in str(dispenser_id).split(",") if part.strip()]
def _recipe_ids_for_dispensers(dispenser_ids: List[str]) -> List[str]:
recipe_ids: set[str] = set()
for dispenser_id in dispenser_ids:
recipe_ids.update(_recipe_ids_for_dispenser(dispenser_id))
return list(recipe_ids)
def _unload_duration_sec(report: Optional[UnloadingReport]) -> Optional[float]:
if report is None or not report.start_time or not report.end_time:
return None
delta = (report.end_time - report.start_time).total_seconds()
return float(delta) if delta > 0 else None
def _build_report_context(loading_report_ids: List[str]) -> Dict[str, Any]:
if not loading_report_ids:
return {
"component_weights": {},
"component_durations": {},
"group_weights": {},
"group_durations": {},
"mix_times": {},
"remaining_by_loading": {},
"unload_duration_by_loading": {},
}
component_weights: Dict[tuple, Dict[str, float]] = {}
component_durations: Dict[tuple, float] = {}
for row in (
db.session.execute(
select(LoadingReportComponent).where(
LoadingReportComponent.report_id.in_(loading_report_ids),
LoadingReportComponent.is_deleted.is_(False),
)
)
.scalars()
.all()
):
key = (row.report_id, str(row.component_name or ""))
component_weights[key] = {
"targetKg": float(row.target_weight or 0),
"actualKg": float(row.actual_weight or 0),
}
for row in (
db.session.execute(
select(ComponentLoadingTime).where(
ComponentLoadingTime.report_id.in_(loading_report_ids),
ComponentLoadingTime.is_deleted.is_(False),
)
)
.scalars()
.all()
):
key = (row.report_id, str(row.component_name or ""))
component_durations[key] = float(row.loading_duration or 0)
mix_times: Dict[str, Dict[str, Optional[int]]] = {}
for row in (
db.session.execute(
select(LoadingReport).where(
LoadingReport.id.in_(loading_report_ids),
LoadingReport.is_deleted.is_(False),
)
)
.scalars()
.all()
):
mix_times[row.id] = {
"targetMixingSec": row.target_mixing_time,
"actualMixingSec": row.actual_mixing_time,
}
group_weights: Dict[tuple, Dict[str, float]] = {}
group_durations: Dict[tuple, Optional[float]] = {}
remaining_by_loading: Dict[str, float] = {}
unload_duration_by_loading: Dict[str, Optional[float]] = {}
unloading_rows = (
db.session.execute(
select(UnloadingReport).where(
UnloadingReport.loading_report_id.in_(loading_report_ids),
UnloadingReport.is_deleted.is_(False),
)
)
.scalars()
.all()
)
unload_by_id = {row.id: row for row in unloading_rows}
unload_duration_by_id = {
row.id: _unload_duration_sec(row) for row in unloading_rows
}
for row in unloading_rows:
remaining_by_loading[row.loading_report_id] = float(row.remaining_weight or 0)
unload_duration_by_loading[row.loading_report_id] = _unload_duration_sec(row)
if unload_by_id:
for group in (
db.session.execute(
select(UnloadingReportGroup).where(
UnloadingReportGroup.report_id.in_(list(unload_by_id.keys())),
UnloadingReportGroup.is_deleted.is_(False),
)
)
.scalars()
.all()
):
unloading = unload_by_id.get(group.report_id)
if unloading is None:
continue
key = (unloading.loading_report_id, str(group.name or ""))
group_weights[key] = {
"targetKg": float(group.target_weight or 0),
"actualKg": float(group.unloaded_weight or 0),
}
group_durations[key] = unload_duration_by_id.get(group.report_id)
return {
"component_weights": component_weights,
"component_durations": component_durations,
"group_weights": group_weights,
"group_durations": group_durations,
"mix_times": mix_times,
"remaining_by_loading": remaining_by_loading,
"unload_duration_by_loading": unload_duration_by_loading,
}
def _attach_fact_and_duration(item: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
loading_id = item.get("loadingReportId") or ""
event_type = item.get("eventType") or ""
component_name = str(item.get("componentName") or "")
group_name = str(item.get("groupName") or "")
target_kg = None
actual_kg = None
duration_sec = None
if component_name:
weights = ctx["component_weights"].get((loading_id, component_name))
if weights:
target_kg = weights.get("targetKg")
actual_kg = weights.get("actualKg")
duration_sec = ctx["component_durations"].get((loading_id, component_name))
elif group_name:
weights = ctx["group_weights"].get((loading_id, group_name))
if weights:
target_kg = weights.get("targetKg")
actual_kg = weights.get("actualKg")
duration_sec = ctx["unload_duration_by_loading"].get(loading_id)
if event_type == EVENT_MIX_TIME:
mix = ctx["mix_times"].get(loading_id) or {}
target_kg = mix.get("targetMixingSec")
actual_kg = mix.get("actualMixingSec")
duration_sec = float(actual_kg) if actual_kg is not None else None
elif event_type == EVENT_LEFT_IN_MIXER:
actual_kg = ctx["remaining_by_loading"].get(loading_id)
duration_sec = ctx["unload_duration_by_loading"].get(loading_id)
item["targetKg"] = target_kg
item["actualKg"] = actual_kg
item["durationSec"] = duration_sec
return item
def _serialize_alert(row: FeedAlert) -> Dict[str, Any]:
return {
"id": row.id,
"eventType": row.event_type,
"severity": row.severity,
"loadingReportId": row.loading_report_id,
"unloadingReportId": row.unloading_report_id,
"recipeId": row.recipe_id,
"recipeName": row.recipe_name,
"componentName": row.component_name,
"groupName": row.group_name,
"detail": row.detail,
"deviationKg": row.deviation_kg,
"deviationPct": row.deviation_pct,
"costDeviationRub": row.cost_deviation_rub,
"clientId": row.client_id,
"createdAt": naive_utc_to_iso(row.created_at),
"linkKind": "report_loading",
"linkId": row.loading_report_id,
"targetKg": None,
"actualKg": None,
"durationSec": None,
}
def list_feed_alerts(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
severity: Optional[str] = None,
event_type: Optional[str] = None,
dispenser_id: Optional[str] = None,
limit: int = 200,
) -> Dict[str, Any]:
dt_from = _parse_date(date_from)
dt_to = _parse_date(date_to, end_of_day=True)
lim = max(1, min(int(limit), 500))
if date_from or date_to:
backfill_feed_alerts_for_period(date_from=date_from, date_to=date_to, limit=lim)
stmt = (
select(FeedAlert, LoadingReport.start_time)
.join(LoadingReport, LoadingReport.id == FeedAlert.loading_report_id)
.where(LoadingReport.is_deleted.is_(False))
)
if dt_from is not None:
stmt = stmt.where(LoadingReport.start_time >= dt_from)
if dt_to is not None:
stmt = stmt.where(LoadingReport.start_time <= dt_to)
if severity:
stmt = stmt.where(FeedAlert.severity == severity.strip().lower())
if event_type:
stmt = stmt.where(FeedAlert.event_type == event_type.strip().upper())
if dispenser_id:
dispenser_ids = _parse_dispenser_ids(dispenser_id)
recipe_ids = _recipe_ids_for_dispensers(dispenser_ids)
if recipe_ids:
stmt = stmt.where(FeedAlert.recipe_id.in_(recipe_ids))
else:
return {"items": [], "total": 0, "bySeverity": {"warning": 0, "error": 0, "info": 0}}
stmt = stmt.order_by(LoadingReport.start_time.desc(), FeedAlert.created_at.desc()).limit(lim)
rows = db.session.execute(stmt).all()
items = [_serialize_alert(alert) for alert, _ in rows]
loading_ids = list({item["loadingReportId"] for item in items if item.get("loadingReportId")})
ctx = _build_report_context(loading_ids)
items = [_attach_fact_and_duration(item, ctx) for item in items]
by_severity = {"warning": 0, "error": 0, "info": 0}
for item in items:
sev = item.get("severity") or "warning"
if sev in by_severity:
by_severity[sev] += 1
return {"items": items, "total": len(items), "bySeverity": by_severity}
def feed_alerts_summary(
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
dispenser_id: Optional[str] = None,
) -> Dict[str, Any]:
data = list_feed_alerts(
date_from=date_from,
date_to=date_to,
dispenser_id=dispenser_id,
limit=500,
)
total = data["total"]
by = data["bySeverity"]
return {
"total": total,
"warning": by.get("warning", 0),
"error": by.get("error", 0),
"info": by.get("info", 0),
}
+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"
@@ -0,0 +1,327 @@
"""Глобальные настройки контроля отклонений (recipes.db, singleton)."""
from __future__ import annotations
import json
import logging
import os
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, Tuple
from config import Config
logger = logging.getLogger(__name__)
SETTINGS_VERSION = 1
SETTINGS_ROW_ID = 1
DEFAULT_SETTINGS: Dict[str, Any] = {
"version": SETTINGS_VERSION,
"loading": {
"enabled": True,
"warning_pct": 10.0,
"critical_pct": 15.0,
"warning_min_sec": 3,
"critical_min_sec": 1,
"warning_max_sec": 180,
"critical_max_sec": 600,
"notify_warning": True,
"notify_critical": True,
},
"unloading": {
"enabled": True,
"warning_pct": 5.0,
"critical_pct": 10.0,
"notify_warning": True,
"notify_critical": True,
},
"mix_time": {
"enabled": True,
"warning_delta_sec": 30,
"critical_delta_sec": 60,
"notify_warning": True,
"notify_critical": True,
},
"left_in_mixer": {
"enabled": True,
"warning_min_kg": 5.0,
"warning_min_pct": 2.0,
"critical_min_kg": 15.0,
"notify_warning": True,
"notify_critical": True,
},
}
def get_legacy_json_path() -> Path:
custom = os.getenv("WESP_FEED_QUALITY_SETTINGS_PATH", "").strip()
if custom:
p = Path(custom)
if not p.is_absolute():
p = Path(Config.BASE_DIR) / p
return p
return Path(Config.DATA_DIR) / "wesp_feed_quality_settings.json"
def default_settings() -> Dict[str, Any]:
return deepcopy(DEFAULT_SETTINGS)
def _coerce_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
if isinstance(value, (int, float)):
return bool(value)
return default
def _coerce_float(value: Any, default: float, *, min_v: float, max_v: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
num = default
return max(min_v, min(max_v, num))
def _coerce_int(value: Any, default: int, *, min_v: int, max_v: int) -> int:
try:
num = int(round(float(value)))
except (TypeError, ValueError):
num = default
return max(min_v, min(max_v, num))
def _merge_section(
raw: Dict[str, Any],
defaults: Dict[str, Any],
*,
float_fields: Tuple[Tuple[str, float, float, float], ...] = (),
int_fields: Tuple[Tuple[str, int, int, int], ...] = (),
) -> Dict[str, Any]:
out = deepcopy(defaults)
if not isinstance(raw, dict):
return out
if "enabled" in raw:
out["enabled"] = _coerce_bool(raw.get("enabled"), bool(defaults.get("enabled", True)))
for key, default, min_v, max_v in float_fields:
if key in raw:
out[key] = _coerce_float(raw.get(key), float(defaults[key]), min_v=min_v, max_v=max_v)
for key, default, min_v, max_v in int_fields:
if key in raw:
out[key] = _coerce_int(raw.get(key), int(defaults[key]), min_v=min_v, max_v=max_v)
for notify_key in ("notify_warning", "notify_critical"):
if notify_key in raw:
out[notify_key] = _coerce_bool(raw.get(notify_key), bool(defaults.get(notify_key, True)))
return out
def normalize_settings(partial: Dict[str, Any]) -> Dict[str, Any]:
"""Валидирует и нормализует настройки; critical >= warning где применимо."""
base = default_settings()
raw = partial if isinstance(partial, dict) else {}
loading = _merge_section(
raw.get("loading") or {},
base["loading"],
float_fields=(
("warning_pct", 10.0, 0.1, 100.0),
("critical_pct", 15.0, 0.1, 100.0),
),
int_fields=(
("warning_min_sec", 3, 0, 3600),
("critical_min_sec", 1, 0, 3600),
("warning_max_sec", 180, 0, 86400),
("critical_max_sec", 600, 0, 86400),
),
)
if loading["critical_pct"] < loading["warning_pct"]:
loading["critical_pct"] = loading["warning_pct"]
if loading["critical_min_sec"] > loading["warning_min_sec"]:
loading["critical_min_sec"] = loading["warning_min_sec"]
if loading["critical_max_sec"] < loading["warning_max_sec"]:
loading["critical_max_sec"] = loading["warning_max_sec"]
unloading = _merge_section(
raw.get("unloading") or {},
base["unloading"],
float_fields=(
("warning_pct", 5.0, 0.1, 100.0),
("critical_pct", 10.0, 0.1, 100.0),
),
)
if unloading["critical_pct"] < unloading["warning_pct"]:
unloading["critical_pct"] = unloading["warning_pct"]
mix_time = _merge_section(
raw.get("mix_time") or {},
base["mix_time"],
int_fields=(
("warning_delta_sec", 30, 1, 3600),
("critical_delta_sec", 60, 1, 3600),
),
)
if mix_time["critical_delta_sec"] < mix_time["warning_delta_sec"]:
mix_time["critical_delta_sec"] = mix_time["warning_delta_sec"]
left_in_mixer = _merge_section(
raw.get("left_in_mixer") or {},
base["left_in_mixer"],
float_fields=(
("warning_min_kg", 5.0, 0.0, 10000.0),
("warning_min_pct", 2.0, 0.0, 100.0),
("critical_min_kg", 15.0, 0.0, 10000.0),
),
)
if left_in_mixer["critical_min_kg"] < left_in_mixer["warning_min_kg"]:
left_in_mixer["critical_min_kg"] = left_in_mixer["warning_min_kg"]
return {
"version": SETTINGS_VERSION,
"loading": loading,
"unloading": unloading,
"mix_time": mix_time,
"left_in_mixer": left_in_mixer,
}
def _read_legacy_json_raw() -> Dict[str, Any]:
path = get_legacy_json_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
logger.warning("Не удалось прочитать %s", path, exc_info=True)
return {}
def _payload_to_settings(payload: str | None) -> Dict[str, Any]:
if not payload:
return default_settings()
try:
raw = json.loads(payload)
if not isinstance(raw, dict):
return default_settings()
return normalize_settings(raw)
except Exception:
return default_settings()
def _persist_settings(settings: Dict[str, Any]) -> Dict[str, Any]:
from app import db
from app.models.feed_quality_settings import FeedQualitySettings
from app.timeutil import utc_now_naive
merged = normalize_settings(settings)
row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID)
if row is None:
row = FeedQualitySettings(
id=SETTINGS_ROW_ID,
payload=json.dumps(merged, ensure_ascii=False),
updated_at=utc_now_naive(),
)
db.session.add(row)
else:
row.payload = json.dumps(merged, ensure_ascii=False)
row.updated_at = utc_now_naive()
db.session.commit()
return merged
def import_legacy_json_to_db(*, force: bool = False) -> bool:
"""Импортирует JSON в БД, если строки нет или force=True."""
from app import db
from app.models.feed_quality_settings import FeedQualitySettings
row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID)
if row is not None and not force:
return False
raw = _read_legacy_json_raw()
if not raw and row is not None:
return False
settings = normalize_settings(raw) if raw else default_settings()
_persist_settings(settings)
return True
def create_feed_quality_settings_table(bind) -> None:
from app.models.feed_quality_settings import FeedQualitySettings
FeedQualitySettings.__table__.create(bind, checkfirst=True)
def import_feed_quality_settings_json(bind) -> bool:
"""Импорт legacy JSON в пустую таблицу (миграция, без Flask app context)."""
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection, Engine
from app.timeutil import utc_now_naive
insp = inspect(bind)
if not insp.has_table("feed_quality_settings"):
return False
def _import(conn: Connection) -> bool:
existing = conn.execute(
text("SELECT id FROM feed_quality_settings WHERE id = 1")
).fetchone()
if existing is not None:
return False
raw = _read_legacy_json_raw()
settings = normalize_settings(raw) if raw else default_settings()
conn.execute(
text(
"INSERT INTO feed_quality_settings (id, payload, updated_at) "
"VALUES (1, :payload, :updated_at)"
),
{
"payload": json.dumps(settings, ensure_ascii=False),
"updated_at": utc_now_naive(),
},
)
if conn.in_transaction():
conn.commit()
return True
if isinstance(bind, Engine):
with bind.connect() as conn:
return _import(conn)
return _import(bind)
def ensure_feed_quality_settings_table(bind, *, import_json: bool = False) -> None:
create_feed_quality_settings_table(bind)
if import_json:
import_feed_quality_settings_json(bind)
def get_feed_quality_settings() -> Dict[str, Any]:
from app import db
from app.models.feed_quality_settings import FeedQualitySettings
row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID)
if row is None:
import_legacy_json_to_db()
row = db.session.get(FeedQualitySettings, SETTINGS_ROW_ID)
if row is None:
return default_settings()
return _payload_to_settings(row.payload)
def save_feed_quality_settings(partial: Dict[str, Any]) -> Dict[str, Any]:
current = get_feed_quality_settings()
merged_input = deepcopy(current)
for key, val in (partial or {}).items():
if key in ("loading", "unloading", "mix_time", "left_in_mixer") and isinstance(val, dict):
merged_input[key] = {**current.get(key, {}), **val}
else:
merged_input[key] = val
return _persist_settings(normalize_settings(merged_input))
def settings_for_api() -> Dict[str, Any]:
return {"settings": get_feed_quality_settings(), "defaults": default_settings()}