-- Kontrola plán vs. audit (síť) po uzavření slotu: pravidla v DB, dedup insert, výstup pro Discord z Pythonu. create or replace function ems.fn_plan_actual_slot_guard_site( p_site_id int, p_now timestamptz default now() ) returns jsonb language sql volatile as $fn$ with v_code as ( select s.code as site_code from ems.site s where s.id = p_site_id ), slots as ( select distinct unnest(array[ ems.fn_planning_slot_boundary_prague(-1, p_now), ems.fn_planning_slot_boundary_prague(-2, p_now) ]) as interval_start ), base as ( select s.interval_start, ai.actual_grid_power_w, ai.deviation_grid_w, pi.grid_setpoint_w as plan_grid_w, pi.effective_sell_price as plan_sell_czk from slots s inner join ems.audit_interval ai on ai.site_id = p_site_id and ai.interval_start = s.interval_start left join ems.planning_interval pi on pi.run_id = ai.planning_run_id and pi.interval_start = ai.interval_start ), cls as ( select b.interval_start, b.plan_grid_w, coalesce(b.actual_grid_power_w, 0) as actual_grid_w, b.deviation_grid_w, case when b.plan_grid_w is null or b.deviation_grid_w is null then null::text when coalesce( b.plan_sell_czk, ems.fn_effective_sell_price(p_site_id, b.interval_start) ) < 0 and coalesce(b.actual_grid_power_w, 0) < -4000 then 'NEG_SELL_EXPORT' when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500 then 'GRID_IMPORT_VS_EXPORT_PLAN' when b.plan_grid_w <> 0 and coalesce(b.actual_grid_power_w, 0) <> 0 and (b.plan_grid_w > 0) <> (coalesce(b.actual_grid_power_w, 0) > 0) and least( abs(b.plan_grid_w), abs(coalesce(b.actual_grid_power_w, 0)) ) >= 400 then 'GRID_SIGN_MISMATCH' when b.plan_grid_w > -1000 and coalesce(b.actual_grid_power_w, 0) < -4000 then 'GRID_EXPORT_SPIKE' when abs(b.deviation_grid_w) >= 10000 and abs(b.plan_grid_w) <= 2500 then 'GRID_LARGE_DEVIATION' else null::text end as reason_code, case when b.plan_grid_w is null or b.deviation_grid_w is null then null::text when coalesce( b.plan_sell_czk, ems.fn_effective_sell_price(p_site_id, b.interval_start) ) < 0 and coalesce(b.actual_grid_power_w, 0) < -4000 then format( 'záporná vykupní %s Kč/kWh, skutečnost síť %s W (vývoz nad práh 4 kW)', round( coalesce( b.plan_sell_czk, ems.fn_effective_sell_price(p_site_id, b.interval_start) )::numeric, 4 ), coalesce(b.actual_grid_power_w, 0) ) when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500 then format( 'plán síť %s W vs skutečnost %s W (plán vývoz, skutečnost silný odběr)', b.plan_grid_w, coalesce(b.actual_grid_power_w, 0) ) when b.plan_grid_w <> 0 and coalesce(b.actual_grid_power_w, 0) <> 0 and (b.plan_grid_w > 0) <> (coalesce(b.actual_grid_power_w, 0) > 0) and least( abs(b.plan_grid_w), abs(coalesce(b.actual_grid_power_w, 0)) ) >= 400 then format( 'plán síť %s W vs skutečnost %s W (opačný směr import/export)', b.plan_grid_w, coalesce(b.actual_grid_power_w, 0) ) when b.plan_grid_w > -1000 and coalesce(b.actual_grid_power_w, 0) < -4000 then format( 'plán síť %s W vs skutečnost %s W (neočekávaný silný vývoz)', b.plan_grid_w, coalesce(b.actual_grid_power_w, 0) ) when abs(b.deviation_grid_w) >= 10000 and abs(b.plan_grid_w) <= 2500 then format( 'odchylka výkonu sítě %s W (plán %s W, skutečnost %s W)', b.deviation_grid_w, b.plan_grid_w, coalesce(b.actual_grid_power_w, 0) ) else null::text end as detail_cs from base b ), ins as ( insert into ems.plan_fatal_deviation_sent (site_id, interval_start, reason_code) select p_site_id, c.interval_start, c.reason_code from cls c where c.reason_code is not null on conflict (site_id, interval_start) do nothing returning interval_start, reason_code ), notified as ( select c.interval_start, c.reason_code, c.detail_cs, c.plan_grid_w, c.actual_grid_w, c.deviation_grid_w from cls c inner join ins i on i.interval_start = c.interval_start and i.reason_code = c.reason_code ) select case when not exists (select 1 from v_code) then jsonb_build_object('error', 'unknown_site', 'site_id', p_site_id) else jsonb_build_object( 'site_id', p_site_id, 'site_code', (select vc.site_code from v_code vc), 'alerts', coalesce( ( select coalesce( jsonb_agg( jsonb_build_object( 'interval_start', n.interval_start, 'reason_code', n.reason_code, 'detail', n.detail_cs, 'plan_grid_w', n.plan_grid_w, 'actual_grid_w', n.actual_grid_w, 'deviation_grid_w', n.deviation_grid_w, 'notify', true ) order by n.interval_start ), '[]'::jsonb ) from notified n ), '[]'::jsonb ) ) end; $fn$; comment on function ems.fn_plan_actual_slot_guard_site(int, timestamptz) is 'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit (včetně NEG_SELL_EXPORT při sell<0 a vývozu >4 kW) → insert plan_fatal_deviation_sent (dedup); JSON alerts pro Discord.'; create or replace function ems.fn_plan_actual_slot_guard_all_active( p_now timestamptz default now() ) returns jsonb language sql volatile as $fn$ select coalesce( jsonb_agg( ems.fn_plan_actual_slot_guard_site((elem->>'id')::int, p_now) order by (elem->>'id')::int ), '[]'::jsonb ) from jsonb_array_elements(ems.fn_vw_site_directory_active()) as t(elem); $fn$; comment on function ems.fn_plan_actual_slot_guard_all_active(timestamptz) is 'Projde aktivní lokality (fn_vw_site_directory_active) a zavolá fn_plan_actual_slot_guard_site; pole výsledků pro scheduler.';