diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py index 30c5997..ac26269 100644 --- a/backend/app/routers/sites.py +++ b/backend/app/routers/sites.py @@ -75,6 +75,47 @@ async def get_site_prices( return [r for r in rows if isinstance(r, dict)] +@router.get("/{site_id}/prices/slots") +async def get_site_prices_slots_range( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + from_ts: datetime = Query( + ..., + alias="from", + description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min", + ), + to_ts: datetime = Query( + ..., + alias="to", + description="Konec polouzavřeného intervalu (max. 14 dní za from)", + ), +) -> dict[str, list[dict[str, Any]]]: + if to_ts <= from_ts: + raise HTTPException(status_code=422, detail="'to' must be after 'from'") + if to_ts - from_ts > timedelta(days=14): + raise HTTPException( + status_code=422, + detail="Span between 'from' and 'to' must be at most 14 days", + ) + async with db.acquire() as conn: + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + raw = await fetch_json( + conn, + "select ems.fn_site_effective_prices_slots_range($1::int, $2::timestamptz, $3::timestamptz)", + site_id, + from_ts, + to_ts, + ) + rows = raw if isinstance(raw, list) else [] + if not isinstance(rows, list): + rows = [] + return {"slots": [r for r in rows if isinstance(r, dict)]} + + class PricesImportResponse(BaseModel): slots_imported: int date: str diff --git a/db/routines/R__067_fn_site_effective_prices_slots_range.sql b/db/routines/R__067_fn_site_effective_prices_slots_range.sql new file mode 100644 index 0000000..dff0eba --- /dev/null +++ b/db/routines/R__067_fn_site_effective_prices_slots_range.sql @@ -0,0 +1,28 @@ +create or replace function ems.fn_site_effective_prices_slots_range( + p_site_id int, + p_from timestamptz, + p_to timestamptz +) +returns jsonb +language sql +stable +as $fn$ + select coalesce( + jsonb_agg(u.j order by u.interval_start), + '[]'::jsonb + ) + from ( + select + v.interval_start, + to_jsonb(v) as j + from ems.vw_site_effective_price v + where v.site_id = p_site_id + and v.interval_start >= p_from + and v.interval_end < p_to + order by v.interval_start + ) u; +$fn$; + +comment on function ems.fn_site_effective_prices_slots_range(int, timestamptz, timestamptz) is + 'Efektivní ceny pro [p_from, p_to) jako pole JSON řádků view (range pro UI bez PostgREST auth).'; + diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 78053de..78d135e 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -270,6 +270,18 @@ export async function getSitePrices(siteId: number, date: string): Promise { + const { data } = await client.get<{ slots?: SiteEffectivePriceRowDto[] }>( + `/sites/${siteId}/prices/slots`, + { params: { from: fromIso, to: toIso }, timeout: 60_000 }, + ) + return Array.isArray(data?.slots) ? data.slots : [] +} + export type ForecastPvIntervalRow = { interval_start: string power_w?: number | string | null diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index b7b0226..855071a 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getCurrentPlan, getForecastPvSlotsRangeCorrected, + getSitePricesSlotsRange, } from '../api/backend' import { getJson } from '../api/postgrest' import { @@ -20,7 +21,6 @@ import type { HeatPumpLatestRow, ModeLogRecentRow, SiteStatusRow, - SiteEffectivePriceRow, Telemetry15m7dRow, } from '../types/ems' import type { PlanningIntervalDto } from '../types/plan' @@ -220,15 +220,12 @@ export function useDashboardData(siteId: number | null) { limit: '200', }), getJson('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }), - // Ceny bereme v jednom range dotazu přes PostgREST view (místo N× /api/v1/sites/{id}/prices?date=...). - // Okno: [windowStart, windowStart + TOTAL_SLOTS) pro mapování na sloty. - getJson('/vw_site_effective_price', { - site_id: `eq.${siteId}`, - interval_start: `gte.${new Date(windowStart).toISOString()}`, - interval_end: `lt.${new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString()}`, - order: 'interval_start.asc', - limit: String(TOTAL_SLOTS + 32), - }), + // Ceny bereme přes FastAPI range endpoint (PostgREST /rest je u vás chráněné → 401). + getSitePricesSlotsRange( + siteId, + new Date(windowStart).toISOString(), + new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString(), + ), ]) const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null @@ -242,7 +239,7 @@ export function useDashboardData(siteId: number | null) { } const priceBySlot = new Map() - const flatPrices: SiteEffectivePriceRow[] = Array.isArray(priceRows) ? priceRows : [] + const flatPrices = Array.isArray(priceRows) ? priceRows : [] for (const r of flatPrices) { const k = slotTimeKey(new Date(r.interval_start).getTime()) priceBySlot.set(k, {