403 lines
13 KiB
Python
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)
|