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