solorpower/supabase/migrations/20260807000001_stats_write_consistency.sql
haneulai a716dbef96
Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run
feat: harden solar monitoring through stage 7
2026-08-07 14:07:22 +09:00

304 lines
9.8 KiB
PL/PgSQL

-- Centralize daily-stat write policy and keep monthly aggregates in sync.
ALTER TABLE public.daily_stats
ADD COLUMN IF NOT EXISTS updated_at timestamp with time zone NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'legacy';
UPDATE public.daily_stats
SET updated_at = COALESCE(created_at, now())
WHERE updated_at IS NULL;
ALTER TABLE public.daily_stats
DROP CONSTRAINT IF EXISTS daily_stats_source_check;
ALTER TABLE public.daily_stats
ADD CONSTRAINT daily_stats_source_check CHECK (
source IN ('legacy', 'realtime', 'daily_summary', 'history', 'excel_daily')
);
ALTER TABLE public.monthly_stats
RENAME COLUMN currnet_last_date TO last_date;
ALTER TABLE public.monthly_stats
ALTER COLUMN last_date TYPE date
USING CASE
WHEN last_date IS NULL OR btrim(last_date) = '' THEN NULL
WHEN last_date ~ '^\d{4}-\d{2}-\d{2}$' THEN last_date::date
ELSE NULL
END;
ALTER TABLE public.monthly_stats
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'legacy';
ALTER TABLE public.monthly_stats
DROP CONSTRAINT IF EXISTS monthly_stats_source_check;
ALTER TABLE public.monthly_stats
ADD CONSTRAINT monthly_stats_source_check CHECK (
source IN ('legacy', 'derived_daily', 'excel_monthly', 'history_monthly')
);
COMMENT ON COLUMN public.daily_stats.updated_at IS '통계 값이 실제로 변경된 시각';
COMMENT ON COLUMN public.daily_stats.source IS '현재 일 발전량 값을 결정한 저장 경로';
COMMENT ON COLUMN public.monthly_stats.last_date IS '월간 합계에 포함된 마지막 일자';
COMMENT ON COLUMN public.monthly_stats.source IS '월간 값의 생성 경로';
CREATE OR REPLACE FUNCTION public.refresh_monthly_stat(
p_plant_id text,
p_month text
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_month_start date;
v_next_month date;
v_total double precision;
v_last_date date;
v_count integer;
BEGIN
IF p_month !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN
RAISE EXCEPTION 'invalid month: %', p_month;
END IF;
v_month_start := (p_month || '-01')::date;
v_next_month := (v_month_start + interval '1 month')::date;
SELECT
COALESCE(sum(ds.total_generation), 0)::double precision,
max(ds.date),
count(*)::integer
INTO v_total, v_last_date, v_count
FROM public.daily_stats AS ds
WHERE ds.plant_id = p_plant_id
AND ds.date >= v_month_start
AND ds.date < v_next_month;
IF v_count = 0 THEN
DELETE FROM public.monthly_stats AS ms
WHERE ms.plant_id = p_plant_id
AND ms.month = p_month
AND ms.source NOT IN ('excel_monthly', 'history_monthly');
RETURN;
END IF;
INSERT INTO public.monthly_stats AS ms (
plant_id,
month,
total_generation,
last_date,
updated_at,
source
) VALUES (
p_plant_id,
p_month,
round(v_total::numeric, 2)::double precision,
v_last_date,
now(),
'derived_daily'
)
ON CONFLICT (plant_id, month) DO UPDATE
SET total_generation = EXCLUDED.total_generation,
last_date = EXCLUDED.last_date,
updated_at = CASE
WHEN ms.total_generation IS DISTINCT FROM EXCLUDED.total_generation
OR ms.last_date IS DISTINCT FROM EXCLUDED.last_date
OR ms.source IS DISTINCT FROM EXCLUDED.source
THEN now()
ELSE ms.updated_at
END,
source = EXCLUDED.source
WHERE ms.source NOT IN ('excel_monthly', 'history_monthly');
END;
$$;
CREATE OR REPLACE FUNCTION public.sync_monthly_stat_from_daily()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM public.refresh_monthly_stat(OLD.plant_id, to_char(OLD.date, 'YYYY-MM'));
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE'
AND OLD.plant_id IS NOT DISTINCT FROM NEW.plant_id
AND OLD.date IS NOT DISTINCT FROM NEW.date
AND OLD.total_generation IS NOT DISTINCT FROM NEW.total_generation THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE'
AND (OLD.plant_id IS DISTINCT FROM NEW.plant_id OR OLD.date IS DISTINCT FROM NEW.date) THEN
PERFORM public.refresh_monthly_stat(OLD.plant_id, to_char(OLD.date, 'YYYY-MM'));
END IF;
PERFORM public.refresh_monthly_stat(NEW.plant_id, to_char(NEW.date, 'YYYY-MM'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS daily_stats_sync_monthly ON public.daily_stats;
CREATE TRIGGER daily_stats_sync_monthly
AFTER INSERT OR UPDATE OR DELETE ON public.daily_stats
FOR EACH ROW EXECUTE FUNCTION public.sync_monthly_stat_from_daily();
CREATE OR REPLACE FUNCTION public.upsert_daily_stats(
p_records jsonb,
p_source text,
p_allow_decrease boolean DEFAULT false
)
RETURNS SETOF public.daily_stats
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_record jsonb;
v_plant_id text;
v_date date;
v_total double precision;
v_peak double precision;
v_capacity double precision;
v_result public.daily_stats%ROWTYPE;
BEGIN
IF jsonb_typeof(p_records) <> 'array' THEN
RAISE EXCEPTION 'p_records must be a JSON array';
END IF;
IF p_source NOT IN ('realtime', 'daily_summary', 'history', 'excel_daily') THEN
RAISE EXCEPTION 'invalid daily stats source: %', p_source;
END IF;
FOR v_record IN SELECT value FROM jsonb_array_elements(p_records)
LOOP
v_plant_id := NULLIF(btrim(v_record ->> 'plant_id'), '');
v_date := (v_record ->> 'date')::date;
v_total := (v_record ->> 'total_generation')::double precision;
v_peak := COALESCE((v_record ->> 'peak_kw')::double precision, 0);
IF v_plant_id IS NULL THEN
RAISE EXCEPTION 'plant_id is required';
END IF;
IF v_total < 0 OR v_peak < 0 THEN
RAISE EXCEPTION 'generation values must be non-negative: % %', v_plant_id, v_date;
END IF;
SELECT COALESCE(p.capacity, 0)
INTO v_capacity
FROM public.plants AS p
WHERE p.id = v_plant_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'unknown plant_id: %', v_plant_id;
END IF;
INSERT INTO public.daily_stats AS ds (
plant_id,
date,
total_generation,
peak_kw,
generation_hours,
created_at,
updated_at,
source
) VALUES (
v_plant_id,
v_date,
round(v_total::numeric, 2)::double precision,
round(v_peak::numeric, 2)::double precision,
CASE
WHEN v_capacity > 0
THEN round((v_total / v_capacity)::numeric, 2)::double precision
ELSE 0
END,
now(),
now(),
p_source
)
ON CONFLICT (plant_id, date) DO UPDATE
SET total_generation = CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END,
peak_kw = GREATEST(COALESCE(ds.peak_kw, 0), EXCLUDED.peak_kw),
generation_hours = CASE
WHEN v_capacity > 0 THEN round((
(CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END) / v_capacity
)::numeric, 2)::double precision
ELSE 0
END,
updated_at = CASE
WHEN ds.total_generation IS DISTINCT FROM (
CASE
WHEN p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation
THEN EXCLUDED.total_generation
ELSE ds.total_generation
END
) OR ds.peak_kw IS DISTINCT FROM GREATEST(COALESCE(ds.peak_kw, 0), EXCLUDED.peak_kw)
THEN now()
ELSE ds.updated_at
END,
source = CASE
WHEN ds.total_generation IS DISTINCT FROM EXCLUDED.total_generation
AND (p_allow_decrease OR EXCLUDED.total_generation > ds.total_generation)
THEN p_source
ELSE ds.source
END
RETURNING ds.* INTO v_result;
RETURN NEXT v_result;
END LOOP;
RETURN;
END;
$$;
REVOKE ALL ON FUNCTION public.upsert_daily_stats(jsonb, text, boolean) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.upsert_daily_stats(jsonb, text, boolean)
TO anon, authenticated, service_role;
REVOKE ALL ON FUNCTION public.refresh_monthly_stat(text, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.refresh_monthly_stat(text, text)
TO authenticated, service_role;
-- Preserve legacy monthly totals, but populate their last included daily date.
UPDATE public.monthly_stats AS ms
SET last_date = daily.last_date
FROM (
SELECT plant_id, to_char(date, 'YYYY-MM') AS month, max(date) AS last_date
FROM public.daily_stats
GROUP BY plant_id, to_char(date, 'YYYY-MM')
) AS daily
WHERE ms.plant_id = daily.plant_id
AND ms.month = daily.month
AND ms.last_date IS DISTINCT FROM daily.last_date;
-- Create or refresh only the current KST month. Historical totals remain untouched.
DO $$
DECLARE
v_pair record;
v_current_month text := to_char(timezone('Asia/Seoul', now()), 'YYYY-MM');
BEGIN
FOR v_pair IN
SELECT DISTINCT plant_id
FROM public.daily_stats
WHERE date >= (v_current_month || '-01')::date
AND date < ((v_current_month || '-01')::date + interval '1 month')::date
LOOP
PERFORM public.refresh_monthly_stat(v_pair.plant_id, v_current_month);
END LOOP;
END;
$$;