@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()}
|
||||
Reference in New Issue
Block a user