Files
site/WESP_REL/app/services/feed_quality/evaluator.py
T
2026-07-17 12:57:18 +03:00

403 lines
13 KiB
Python

"""Оценка отчётов и запись 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)