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