"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho". Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena, SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě neposlaný) řádek pošle jeden Discord nudge. Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent (on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní). DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně. """ from __future__ import annotations import logging from typing import Any import asyncpg from app.db_json import fetch_json from services.notification_service import send_discord logger = logging.getLogger(__name__) def _fmt_price(value: Any) -> str: try: return f"{float(value):.2f}" except (TypeError, ValueError): return "?" def _build_message(row: asyncpg.Record) -> str: name = row["vehicle_name"] or "EV" reason = str(row["trigger_reason"] or "") sell = row["effective_sell_price_czk_kwh"] buy = row["effective_buy_price_czk_kwh"] soc = row["battery_level_pct"] tgt = row["target_soc_pct"] if reason == "NEG_OR_ZERO_SELL": why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta" else: why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh" soc_line = "" if soc is not None: soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + ( f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else "" ) return ( f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n" f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)." ) async def run_ev_presence_nudge_for_site( site_id: int, conn: asyncpg.Connection ) -> int: """Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo. Vrátí počet odeslaných notifikací. """ try: rows = await conn.fetch( "select * from ems.fn_ev_presence_nudge_due($1::int)", site_id, ) except Exception: logger.exception( "ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id ) return 0 sent = 0 for row in rows: try: await send_discord(conn, site_id, _build_message(row), level="info") sent += 1 logger.info( "ev_presence_nudge sent site=%s vehicle=%s reason=%s", site_id, row["vehicle_id"], row["trigger_reason"], ) except Exception: logger.exception( "ev_presence_nudge: Discord send failed site=%s vehicle=%s", site_id, row["vehicle_id"], ) return sent async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None: """Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge.""" async with pool.acquire() as conn: raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()") sites = raw if isinstance(raw, list) else [] for site in sites: if not isinstance(site, dict) or site.get("id") is None: continue site_id = int(site["id"]) try: await run_ev_presence_nudge_for_site(site_id, conn) except Exception: logger.exception("ev_presence_nudge site=%s failed", site_id)