implementace Ekonomiky
This commit is contained in:
@@ -15,6 +15,7 @@ import httpx
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.db_json import record_to_dict
|
||||
from app.deps import set_pg_pool
|
||||
from app.routers.economics import router as economics_router
|
||||
from app.routers.ev import router as ev_router
|
||||
from app.routers.full_status import router as full_status_router
|
||||
from app.routers.plan import router as plan_router
|
||||
@@ -423,6 +424,63 @@ async def lifespan(app: FastAPI):
|
||||
id="forecast_refresh_2h",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
async def scheduled_daily_economics_notification() -> None:
|
||||
from services.notification_service import notify_daily_economics
|
||||
|
||||
async with app.state.pg_pool.acquire() as conn:
|
||||
sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
site_id = int(site["id"])
|
||||
site_code = site["code"]
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT import_kwh, export_kwh,
|
||||
import_cost_czk, export_revenue_czk,
|
||||
green_bonus_czk, total_balance_czk,
|
||||
planned_balance_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local = (
|
||||
CURRENT_DATE AT TIME ZONE 'Europe/Prague' - INTERVAL '1 day'
|
||||
)::date
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
continue
|
||||
yesterday = (
|
||||
datetime.now(ZoneInfo("Europe/Prague"))
|
||||
- timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
await notify_daily_economics(
|
||||
site_code=site_code,
|
||||
day=yesterday,
|
||||
import_kwh=float(row["import_kwh"] or 0),
|
||||
import_cost=float(row["import_cost_czk"] or 0),
|
||||
export_kwh=float(row["export_kwh"] or 0),
|
||||
export_revenue=float(row["export_revenue_czk"] or 0),
|
||||
green_bonus=float(row["green_bonus_czk"] or 0),
|
||||
total_balance=float(row["total_balance_czk"] or 0),
|
||||
planned_balance=float(row["planned_balance_czk"])
|
||||
if row["planned_balance_czk"] is not None
|
||||
else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"scheduled_daily_economics_notification site=%s failed",
|
||||
site_id,
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
scheduled_daily_economics_notification,
|
||||
"cron",
|
||||
hour=7,
|
||||
minute=0,
|
||||
id="daily_economics_notification",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
|
||||
@@ -454,6 +512,7 @@ app = FastAPI(title="EMS Platform", lifespan=lifespan)
|
||||
app.include_router(plan_router, prefix="/api/v1")
|
||||
app.include_router(ev_router, prefix="/api/v1")
|
||||
app.include_router(full_status_router, prefix="/api/v1")
|
||||
app.include_router(economics_router, prefix="/api/v1")
|
||||
|
||||
sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"])
|
||||
|
||||
|
||||
386
backend/app/routers/economics.py
Normal file
386
backend/app/routers/economics.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""REST API – denní ekonomické vyhodnocení provozu."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.deps import get_pg_pool
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/sites/{site_id}/economics",
|
||||
tags=["economics"],
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DailyEconomics(BaseModel):
|
||||
day: date
|
||||
interval_count: int
|
||||
import_kwh: float
|
||||
export_kwh: float
|
||||
pv_kwh: float
|
||||
load_kwh: float
|
||||
self_consumption_kwh: float
|
||||
ev_kwh: float
|
||||
hp_kwh: float
|
||||
import_cost_czk: float
|
||||
export_revenue_czk: float
|
||||
net_cost_czk: float
|
||||
green_bonus_czk: float
|
||||
total_balance_czk: float
|
||||
planned_balance_czk: float | None
|
||||
deviation_cost_czk: float | None
|
||||
is_locked: bool
|
||||
|
||||
|
||||
class DailyEconomicsResponse(BaseModel):
|
||||
days: list[DailyEconomics]
|
||||
has_green_bonus: bool
|
||||
|
||||
|
||||
class IntervalEconomics(BaseModel):
|
||||
interval_start: str
|
||||
import_kwh: float
|
||||
export_kwh: float
|
||||
dynamic_cost_czk: float | None
|
||||
stored_cost_czk: float | None
|
||||
green_bonus_czk: float | None
|
||||
planned_cost_czk: float | None
|
||||
planned_grid_w: int | None
|
||||
actual_grid_power_w: int | None
|
||||
effective_buy_price: float | None
|
||||
effective_sell_price: float | None
|
||||
planned_buy_price: float | None
|
||||
planned_sell_price: float | None
|
||||
actual_pv_power_w: int | None
|
||||
actual_load_power_w: int | None
|
||||
actual_battery_power_w: int | None
|
||||
actual_battery_soc_pct: float | None
|
||||
|
||||
|
||||
class ChartDayPoint(BaseModel):
|
||||
day: date
|
||||
daily_balance_czk: float
|
||||
cumulative_balance_czk: float
|
||||
|
||||
|
||||
class LockResponse(BaseModel):
|
||||
locked: bool
|
||||
day: date
|
||||
|
||||
|
||||
def _num(val: Any) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
return float(val)
|
||||
|
||||
|
||||
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||
ok = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
|
||||
return bool(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ems.asset_pv_array
|
||||
WHERE site_id = $1
|
||||
AND green_bonus_czk_kwh IS NOT NULL
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyEconomicsResponse)
|
||||
async def get_economics_daily(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
month: str = Query(
|
||||
...,
|
||||
description="YYYY-MM (měsíc pro zobrazení)",
|
||||
pattern=r"^\d{4}-\d{2}$",
|
||||
),
|
||||
) -> DailyEconomicsResponse:
|
||||
try:
|
||||
year, mon = month.split("-")
|
||||
month_start = date(int(year), int(mon), 1)
|
||||
if int(mon) == 12:
|
||||
month_end = date(int(year) + 1, 1, 1)
|
||||
else:
|
||||
month_end = date(int(year), int(mon) + 1, 1)
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
has_bonus = await _has_green_bonus(conn, site_id)
|
||||
|
||||
dyn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: r for r in lock_rows}
|
||||
|
||||
days: list[DailyEconomics] = []
|
||||
for r in dyn_rows:
|
||||
d = r["day_local"]
|
||||
lock = locks.get(d)
|
||||
if lock:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
interval_count=r["interval_count"],
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
pv_kwh=_num(r["pv_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(lock["import_cost_czk"]),
|
||||
export_revenue_czk=_num(lock["export_revenue_czk"]),
|
||||
net_cost_czk=_num(lock["net_cost_czk"]),
|
||||
green_bonus_czk=_num(lock["green_bonus_czk"]),
|
||||
total_balance_czk=_num(lock["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
days.append(
|
||||
DailyEconomics(
|
||||
day=d,
|
||||
interval_count=r["interval_count"],
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
pv_kwh=_num(r["pv_kwh"]),
|
||||
load_kwh=_num(r["load_kwh"]),
|
||||
self_consumption_kwh=_num(r["self_consumption_kwh"]),
|
||||
ev_kwh=_num(r["ev_kwh"]),
|
||||
hp_kwh=_num(r["hp_kwh"]),
|
||||
import_cost_czk=_num(r["import_cost_czk"]),
|
||||
export_revenue_czk=_num(r["export_revenue_czk"]),
|
||||
net_cost_czk=_num(r["net_cost_czk"]),
|
||||
green_bonus_czk=_num(r["green_bonus_czk"]),
|
||||
total_balance_czk=_num(r["total_balance_czk"]),
|
||||
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
|
||||
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
|
||||
is_locked=False,
|
||||
)
|
||||
)
|
||||
|
||||
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
|
||||
|
||||
|
||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
|
||||
async def get_economics_intervals(
|
||||
site_id: int,
|
||||
day: date,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> list[IntervalEconomics]:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.vw_economics_interval
|
||||
WHERE site_id = $1
|
||||
AND date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date = $2
|
||||
ORDER BY interval_start
|
||||
""",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
|
||||
return [
|
||||
IntervalEconomics(
|
||||
interval_start=r["interval_start"].isoformat(),
|
||||
import_kwh=_num(r["import_kwh"]),
|
||||
export_kwh=_num(r["export_kwh"]),
|
||||
dynamic_cost_czk=float(r["dynamic_cost_czk"]) if r["dynamic_cost_czk"] is not None else None,
|
||||
stored_cost_czk=float(r["stored_cost_czk"]) if r["stored_cost_czk"] is not None else None,
|
||||
green_bonus_czk=float(r["green_bonus_czk"]) if r["green_bonus_czk"] is not None else None,
|
||||
planned_cost_czk=float(r["planned_cost_czk"]) if r["planned_cost_czk"] is not None else None,
|
||||
planned_grid_w=int(r["planned_grid_w"]) if r["planned_grid_w"] is not None else None,
|
||||
actual_grid_power_w=int(r["actual_grid_power_w"]) if r["actual_grid_power_w"] is not None else None,
|
||||
effective_buy_price=float(r["effective_buy_price_czk_kwh"]) if r["effective_buy_price_czk_kwh"] is not None else None,
|
||||
effective_sell_price=float(r["effective_sell_price_czk_kwh"]) if r["effective_sell_price_czk_kwh"] is not None else None,
|
||||
planned_buy_price=float(r["planned_buy_price"]) if r["planned_buy_price"] is not None else None,
|
||||
planned_sell_price=float(r["planned_sell_price"]) if r["planned_sell_price"] is not None else None,
|
||||
actual_pv_power_w=int(r["actual_pv_power_w"]) if r["actual_pv_power_w"] is not None else None,
|
||||
actual_load_power_w=int(r["actual_load_power_w"]) if r["actual_load_power_w"] is not None else None,
|
||||
actual_battery_power_w=int(r["actual_battery_power_w"]) if r["actual_battery_power_w"] is not None else None,
|
||||
actual_battery_soc_pct=float(r["actual_battery_soc_pct"]) if r["actual_battery_soc_pct"] is not None else None,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/daily/{day}/lock", response_model=LockResponse)
|
||||
async def lock_day(
|
||||
site_id: int,
|
||||
day: date,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
|
||||
green_bonus_czk, total_balance_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1 AND day_local = $2
|
||||
""",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No economics data for {day.isoformat()}",
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ems.audit_day_lock
|
||||
(site_id, day_local, import_cost_czk, export_revenue_czk,
|
||||
net_cost_czk, green_bonus_czk, total_balance_czk)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (site_id, day_local) DO UPDATE SET
|
||||
import_cost_czk = EXCLUDED.import_cost_czk,
|
||||
export_revenue_czk = EXCLUDED.export_revenue_czk,
|
||||
net_cost_czk = EXCLUDED.net_cost_czk,
|
||||
green_bonus_czk = EXCLUDED.green_bonus_czk,
|
||||
total_balance_czk = EXCLUDED.total_balance_czk,
|
||||
locked_at = now()
|
||||
""",
|
||||
site_id,
|
||||
day,
|
||||
row["import_cost_czk"],
|
||||
row["export_revenue_czk"],
|
||||
row["net_cost_czk"],
|
||||
row["green_bonus_czk"],
|
||||
row["total_balance_czk"],
|
||||
)
|
||||
|
||||
return LockResponse(locked=True, day=day)
|
||||
|
||||
|
||||
@router.delete("/daily/{day}/lock", response_model=LockResponse)
|
||||
async def unlock_day(
|
||||
site_id: int,
|
||||
day: date,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
) -> LockResponse:
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
|
||||
site_id,
|
||||
day,
|
||||
)
|
||||
return LockResponse(locked=False, day=day)
|
||||
|
||||
|
||||
@router.get("/monthly-chart", response_model=list[ChartDayPoint])
|
||||
async def get_monthly_chart(
|
||||
site_id: int,
|
||||
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||
month: str = Query(
|
||||
...,
|
||||
description="YYYY-MM",
|
||||
pattern=r"^\d{4}-\d{2}$",
|
||||
),
|
||||
) -> list[ChartDayPoint]:
|
||||
try:
|
||||
year, mon = month.split("-")
|
||||
month_start = date(int(year), int(mon), 1)
|
||||
if int(mon) == 12:
|
||||
month_end = date(int(year) + 1, 1, 1)
|
||||
else:
|
||||
month_end = date(int(year), int(mon) + 1, 1)
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
|
||||
|
||||
async with db.acquire() as conn:
|
||||
await _check_site(conn, site_id)
|
||||
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk
|
||||
FROM ems.vw_economics_daily
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
ORDER BY day_local
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
|
||||
lock_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT day_local, total_balance_czk
|
||||
FROM ems.audit_day_lock
|
||||
WHERE site_id = $1
|
||||
AND day_local >= $2
|
||||
AND day_local < $3
|
||||
""",
|
||||
site_id,
|
||||
month_start,
|
||||
month_end,
|
||||
)
|
||||
locks = {r["day_local"]: _num(r["total_balance_czk"]) for r in lock_rows}
|
||||
|
||||
points: list[ChartDayPoint] = []
|
||||
cumulative = 0.0
|
||||
for r in rows:
|
||||
d = r["day_local"]
|
||||
balance = locks.get(d, _num(r["total_balance_czk"]))
|
||||
cumulative += balance
|
||||
points.append(
|
||||
ChartDayPoint(
|
||||
day=d,
|
||||
daily_balance_czk=round(balance, 2),
|
||||
cumulative_balance_czk=round(cumulative, 2),
|
||||
)
|
||||
)
|
||||
|
||||
return points
|
||||
@@ -63,3 +63,33 @@ async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
|
||||
f"Důvod: {reason}"
|
||||
)
|
||||
await send_discord(msg, level="critical")
|
||||
|
||||
|
||||
async def notify_daily_economics(
|
||||
site_code: str,
|
||||
day: str,
|
||||
import_kwh: float,
|
||||
import_cost: float,
|
||||
export_kwh: float,
|
||||
export_revenue: float,
|
||||
green_bonus: float,
|
||||
total_balance: float,
|
||||
planned_balance: float | None,
|
||||
) -> None:
|
||||
lines = [
|
||||
f"Ekonomika **{site_code}** {day}:",
|
||||
f" Import: {import_kwh:.1f} kWh = {import_cost:.2f} Kč",
|
||||
f" Export: {export_kwh:.1f} kWh = {export_revenue:.2f} Kč",
|
||||
]
|
||||
if green_bonus > 0:
|
||||
lines.append(f" Zelený bonus: {green_bonus:.2f} Kč")
|
||||
sign = "+" if total_balance >= 0 else ""
|
||||
lines.append(f" **BILANCE: {sign}{total_balance:.2f} Kč**")
|
||||
if planned_balance is not None:
|
||||
dev = total_balance - planned_balance
|
||||
dev_sign = "+" if dev >= 0 else ""
|
||||
lines.append(
|
||||
f" Plán předpokládal: {planned_balance:+.2f} Kč "
|
||||
f"(odchylka {dev_sign}{dev:.2f} Kč)"
|
||||
)
|
||||
await send_discord("\n".join(lines), level="info")
|
||||
|
||||
@@ -16,9 +16,11 @@ SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
hid integer;
|
||||
chunk_ids integer[];
|
||||
n integer;
|
||||
hid integer;
|
||||
chunk_ids integer[];
|
||||
n integer;
|
||||
has_dropped boolean;
|
||||
q text;
|
||||
BEGIN
|
||||
SELECT h.id
|
||||
INTO hid
|
||||
@@ -40,18 +42,39 @@ BEGIN
|
||||
PERFORM _timescaledb_functions.remove_dropped_chunk_metadata(hid);
|
||||
END IF;
|
||||
|
||||
SELECT coalesce(array_agg(c.id ORDER BY c.id), ARRAY[]::integer[])
|
||||
INTO chunk_ids
|
||||
FROM _timescaledb_catalog.chunk c
|
||||
WHERE c.hypertable_id = hid
|
||||
AND NOT c.dropped
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class cl
|
||||
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
|
||||
WHERE ns.nspname = c.schema_name
|
||||
AND cl.relname = c.table_name
|
||||
);
|
||||
-- Sloupec chunk.dropped byl v novějším TimescaleDB odstraněn (metadata dropnutých chunků se maže).
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_attribute a
|
||||
JOIN pg_catalog.pg_class r ON r.oid = a.attrelid
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = r.relnamespace
|
||||
WHERE n.nspname = '_timescaledb_catalog'
|
||||
AND r.relname = 'chunk'
|
||||
AND a.attname = 'dropped'
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
)
|
||||
INTO has_dropped;
|
||||
|
||||
q := format(
|
||||
$fmt$
|
||||
SELECT coalesce(array_agg(c.id ORDER BY c.id), ARRAY[]::integer[])
|
||||
FROM _timescaledb_catalog.chunk c
|
||||
WHERE c.hypertable_id = %s
|
||||
%s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_class cl
|
||||
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
|
||||
WHERE ns.nspname = c.schema_name
|
||||
AND cl.relname = c.table_name
|
||||
)
|
||||
$fmt$,
|
||||
hid,
|
||||
CASE WHEN has_dropped THEN 'AND NOT c.dropped' ELSE '' END
|
||||
);
|
||||
|
||||
EXECUTE q INTO chunk_ids;
|
||||
|
||||
n := coalesce(array_length(chunk_ids, 1), 0);
|
||||
IF n = 0 THEN
|
||||
|
||||
18
db/migration/V036__pv_array_telemetry_source.sql
Normal file
18
db/migration/V036__pv_array_telemetry_source.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- =============================================================
|
||||
-- V036 – asset_pv_array.telemetry_source
|
||||
-- Explicitní mapování FVE pole → sloupec v telemetry_inverter.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE ems.asset_pv_array
|
||||
ADD COLUMN IF NOT EXISTS telemetry_source TEXT;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_pv_array.telemetry_source IS
|
||||
'Který sloupec v telemetry_inverter odpovídá tomuto poli.
|
||||
gen_port = gen_port_power_w (AC-coupled pole na GEN portu),
|
||||
pv_strings = pv1_power_w + pv2_power_w (DC stringy na MPPT),
|
||||
pv_total = pv_power_w (souhrnné, pokud pole nelze rozlišit).
|
||||
NULL = pole nemá přímou telemetrii (fallback na forecast).';
|
||||
|
||||
-- Seed pro referenční site home-01:
|
||||
UPDATE ems.asset_pv_array SET telemetry_source = 'pv_strings' WHERE code = 'pv-a';
|
||||
UPDATE ems.asset_pv_array SET telemetry_source = 'gen_port' WHERE code = 'pv-b';
|
||||
23
db/migration/V037__audit_day_lock.sql
Normal file
23
db/migration/V037__audit_day_lock.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- =============================================================
|
||||
-- V037 – audit_day_lock
|
||||
-- Zamknuté (finalizované) denní ekonomické výsledky.
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE ems.audit_day_lock (
|
||||
site_id INT NOT NULL REFERENCES ems.site(id),
|
||||
day_local DATE NOT NULL,
|
||||
import_cost_czk NUMERIC(12,2) NOT NULL,
|
||||
export_revenue_czk NUMERIC(12,2) NOT NULL,
|
||||
net_cost_czk NUMERIC(12,2) NOT NULL,
|
||||
green_bonus_czk NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
total_balance_czk NUMERIC(12,2) NOT NULL,
|
||||
locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
locked_by TEXT NOT NULL DEFAULT 'user',
|
||||
notes TEXT,
|
||||
PRIMARY KEY (site_id, day_local)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ems.audit_day_lock IS
|
||||
'Zamknuté (finalizované) denní ekonomické výsledky.
|
||||
Když řádek existuje, frontend zobrazí tyto hodnoty místo dynamických z vw_economics_daily.
|
||||
Uživatel zamkne den, až má jistotu o cenách – snapshot aktuálních dynamických hodnot.';
|
||||
@@ -92,24 +92,49 @@ BEGIN
|
||||
ELSE COALESCE(v_sell_price, 0) END;
|
||||
END IF;
|
||||
|
||||
-- Zelený bonus: výroba bonusových polí z poslední ok predikce pro slot (Wh = W × 0,25 h)
|
||||
-- Zelený bonus: výroba bonusových polí z reálné telemetrie (Wh = průměr W × 0,25 h)
|
||||
v_pv_b_production_wh := NULL;
|
||||
FOR r_bonus IN
|
||||
SELECT id
|
||||
FROM ems.asset_pv_array
|
||||
WHERE site_id = p_site_id
|
||||
AND green_bonus_czk_kwh IS NOT NULL
|
||||
SELECT pa.id, pa.inverter_id, pa.telemetry_source
|
||||
FROM ems.asset_pv_array pa
|
||||
WHERE pa.site_id = p_site_id
|
||||
AND pa.green_bonus_czk_kwh IS NOT NULL
|
||||
AND pa.green_bonus_valid_from <= p_interval_start::DATE
|
||||
AND (pa.green_bonus_valid_to IS NULL
|
||||
OR pa.green_bonus_valid_to > p_interval_start::DATE)
|
||||
LOOP
|
||||
SELECT fpi.power_w * 0.25
|
||||
INTO v_array_prod_wh
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
WHERE fpr.site_id = p_site_id
|
||||
AND fpr.pv_array_id = r_bonus.id
|
||||
AND fpi.interval_start = p_interval_start
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC
|
||||
LIMIT 1;
|
||||
v_array_prod_wh := NULL;
|
||||
|
||||
IF r_bonus.telemetry_source IS NOT NULL AND r_bonus.inverter_id IS NOT NULL THEN
|
||||
SELECT AVG(
|
||||
CASE r_bonus.telemetry_source
|
||||
WHEN 'gen_port' THEN ti.gen_port_power_w
|
||||
WHEN 'pv_strings' THEN COALESCE(ti.pv1_power_w, 0)
|
||||
+ COALESCE(ti.pv2_power_w, 0)
|
||||
WHEN 'pv_total' THEN ti.pv_power_w
|
||||
ELSE NULL
|
||||
END
|
||||
)::NUMERIC * 0.25
|
||||
INTO v_array_prod_wh
|
||||
FROM ems.telemetry_inverter ti
|
||||
WHERE ti.inverter_id = r_bonus.inverter_id
|
||||
AND ti.measured_at >= p_interval_start
|
||||
AND ti.measured_at < v_interval_end;
|
||||
END IF;
|
||||
|
||||
-- Fallback na forecast pokud telemetrie není k dispozici
|
||||
IF v_array_prod_wh IS NULL THEN
|
||||
SELECT fpi.power_w * 0.25
|
||||
INTO v_array_prod_wh
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
WHERE fpr.site_id = p_site_id
|
||||
AND fpr.pv_array_id = r_bonus.id
|
||||
AND fpi.interval_start = p_interval_start
|
||||
AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
v_array_prod_wh := COALESCE(v_array_prod_wh, 0);
|
||||
IF v_pv_b_production_wh IS NULL THEN
|
||||
@@ -175,7 +200,8 @@ $$;
|
||||
COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS
|
||||
'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval.
|
||||
Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky.
|
||||
Zelený bonus: součet přes všechna pole s nastaveným bonusem, výroba z poslední ok forecast_pv_interval.
|
||||
Zelený bonus: součet přes pole s green_bonus_czk_kwh; výroba primárně z reálné telemetrie
|
||||
(dle asset_pv_array.telemetry_source), fallback na forecast_pv_interval pokud telemetrie chybí.
|
||||
Volat každých 15 minut pro interval který právě skončil.';
|
||||
|
||||
-- ============================================================
|
||||
|
||||
75
db/views/R__vw_site_effective_price_economics.sql
Normal file
75
db/views/R__vw_site_effective_price_economics.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- =============================================================
|
||||
-- R__vw_site_effective_price_economics.sql
|
||||
-- EMS Platform – ekonomické views (závisí na vw_site_effective_price)
|
||||
-- Musí běžet až PO R__vw_site_effective_price.sql (abecední pořadí Flyway).
|
||||
-- Repeatable migration
|
||||
-- =============================================================
|
||||
|
||||
CREATE OR REPLACE VIEW ems.vw_economics_interval AS
|
||||
SELECT
|
||||
ai.site_id,
|
||||
ai.interval_start,
|
||||
ROUND(GREATEST(ai.actual_grid_power_w, 0)::NUMERIC / 4000, 4) AS import_kwh,
|
||||
ROUND(ABS(LEAST(ai.actual_grid_power_w, 0))::NUMERIC / 4000, 4) AS export_kwh,
|
||||
CASE WHEN ai.actual_grid_power_w >= 0
|
||||
THEN ROUND((ai.actual_grid_power_w::NUMERIC / 4000)
|
||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0), 4)
|
||||
ELSE ROUND((ai.actual_grid_power_w::NUMERIC / 4000)
|
||||
* COALESCE(ep.effective_sell_price_czk_kwh, 0), 4)
|
||||
END AS dynamic_cost_czk,
|
||||
ai.actual_cost_czk AS stored_cost_czk,
|
||||
ai.green_bonus_czk,
|
||||
pi.expected_cost_czk AS planned_cost_czk,
|
||||
pi.grid_setpoint_w AS planned_grid_w,
|
||||
ai.actual_grid_power_w,
|
||||
ep.effective_buy_price_czk_kwh,
|
||||
ep.effective_sell_price_czk_kwh,
|
||||
pi.effective_buy_price AS planned_buy_price,
|
||||
pi.effective_sell_price AS planned_sell_price,
|
||||
ai.actual_pv_power_w,
|
||||
ai.actual_load_power_w,
|
||||
ai.actual_ev_power_w,
|
||||
ai.actual_heat_pump_power_w,
|
||||
ai.actual_battery_power_w,
|
||||
ai.actual_battery_soc_pct
|
||||
FROM ems.audit_interval ai
|
||||
LEFT JOIN ems.vw_site_effective_price ep
|
||||
ON ep.site_id = ai.site_id AND ep.interval_start = ai.interval_start
|
||||
LEFT JOIN ems.planning_interval pi
|
||||
ON pi.run_id = ai.planning_run_id AND pi.interval_start = ai.interval_start;
|
||||
|
||||
COMMENT ON VIEW ems.vw_economics_interval IS
|
||||
'Dynamické ekonomické vyhodnocení per 15min slot (závisí na vw_site_effective_price).';
|
||||
|
||||
CREATE OR REPLACE VIEW ems.vw_economics_daily AS
|
||||
SELECT
|
||||
site_id,
|
||||
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date AS day_local,
|
||||
COUNT(*)::int AS interval_count,
|
||||
ROUND(SUM(import_kwh), 3) AS import_kwh,
|
||||
ROUND(SUM(export_kwh), 3) AS export_kwh,
|
||||
ROUND(SUM(GREATEST(actual_pv_power_w, 0)::NUMERIC / 4000), 3) AS pv_kwh,
|
||||
ROUND(SUM(GREATEST(actual_load_power_w, 0)::NUMERIC / 4000), 3) AS load_kwh,
|
||||
ROUND(SUM(GREATEST(actual_ev_power_w, 0)::NUMERIC / 4000), 3) AS ev_kwh,
|
||||
ROUND(SUM(GREATEST(actual_heat_pump_power_w, 0)::NUMERIC / 4000), 3) AS hp_kwh,
|
||||
ROUND(SUM(GREATEST(actual_pv_power_w, 0)::NUMERIC / 4000)
|
||||
- SUM(export_kwh), 3) AS self_consumption_kwh,
|
||||
ROUND(SUM(CASE WHEN dynamic_cost_czk > 0
|
||||
THEN dynamic_cost_czk ELSE 0 END), 2) AS import_cost_czk,
|
||||
ROUND(SUM(CASE WHEN dynamic_cost_czk < 0
|
||||
THEN ABS(dynamic_cost_czk) ELSE 0 END), 2) AS export_revenue_czk,
|
||||
ROUND(SUM(dynamic_cost_czk), 2) AS net_cost_czk,
|
||||
ROUND(COALESCE(SUM(green_bonus_czk), 0), 2) AS green_bonus_czk,
|
||||
ROUND(-SUM(dynamic_cost_czk)
|
||||
+ COALESCE(SUM(green_bonus_czk), 0), 2) AS total_balance_czk,
|
||||
ROUND(SUM(planned_cost_czk), 2) AS planned_net_cost_czk,
|
||||
ROUND(-COALESCE(SUM(planned_cost_czk), 0)
|
||||
+ COALESCE(SUM(green_bonus_czk), 0), 2) AS planned_balance_czk,
|
||||
ROUND(SUM(dynamic_cost_czk)
|
||||
- COALESCE(SUM(planned_cost_czk), 0), 2) AS deviation_cost_czk
|
||||
FROM ems.vw_economics_interval
|
||||
GROUP BY site_id,
|
||||
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date;
|
||||
|
||||
COMMENT ON VIEW ems.vw_economics_daily IS
|
||||
'Denní souhrn ekonomiky (závisí na vw_economics_interval).';
|
||||
@@ -3,6 +3,7 @@ import { NavLink, Outlet, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import Economics from './pages/Economics'
|
||||
import { Logs } from './pages/Logs'
|
||||
import Planning from './pages/Planning'
|
||||
import { Settings } from './pages/Settings'
|
||||
@@ -25,6 +26,9 @@ function AppLayout() {
|
||||
<NavLink to="/planning" className={tabClass}>
|
||||
Plánování
|
||||
</NavLink>
|
||||
<NavLink to="/economics" className={tabClass}>
|
||||
Ekonomika
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={tabClass}>
|
||||
Nastavení
|
||||
</NavLink>
|
||||
@@ -55,6 +59,7 @@ export default function App() {
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="planning" element={<Planning />} />
|
||||
<Route path="economics" element={<Economics />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="logs" element={<Logs />} />
|
||||
|
||||
123
frontend/src/components/charts/EconomicsChart.tsx
Normal file
123
frontend/src/components/charts/EconomicsChart.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import type { ChartDayPoint } from '../../types/economics'
|
||||
|
||||
type Props = {
|
||||
points: ChartDayPoint[]
|
||||
}
|
||||
|
||||
const GREEN = '#22c55e'
|
||||
const RED = '#ef4444'
|
||||
const BLUE = '#3b82f6'
|
||||
|
||||
function formatDay(iso: string): string {
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return `${d.getDate()}.`
|
||||
}
|
||||
|
||||
type PayloadEntry = {
|
||||
name?: string
|
||||
value?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: PayloadEntry[]
|
||||
label?: string
|
||||
}) {
|
||||
if (!active || !payload?.length || !label) return null
|
||||
const balance = payload.find((p) => p.name === 'daily_balance_czk')
|
||||
const cumulative = payload.find((p) => p.name === 'cumulative_balance_czk')
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs shadow-lg">
|
||||
<p className="mb-1 font-medium text-slate-200">{label}</p>
|
||||
{balance && (
|
||||
<p style={{ color: (balance.value ?? 0) >= 0 ? GREEN : RED }}>
|
||||
Den: {(balance.value ?? 0) >= 0 ? '+' : ''}
|
||||
{(balance.value ?? 0).toFixed(2)} Kč
|
||||
</p>
|
||||
)}
|
||||
{cumulative && (
|
||||
<p style={{ color: BLUE }}>
|
||||
Kumulativ: {(cumulative.value ?? 0) >= 0 ? '+' : ''}
|
||||
{(cumulative.value ?? 0).toFixed(2)} Kč
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EconomicsChart({ points }: Props) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-slate-500">
|
||||
Žádná data pro tento měsíc
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = points.map((p) => ({
|
||||
...p,
|
||||
label: formatDay(p.day),
|
||||
}))
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#94a3b8' }} />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||
label={{
|
||||
value: 'Kč/den',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: 11, fill: '#94a3b8' },
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 11, fill: BLUE }}
|
||||
label={{
|
||||
value: 'Kumulativ Kč',
|
||||
angle: 90,
|
||||
position: 'insideRight',
|
||||
style: { fontSize: 11, fill: BLUE },
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine yAxisId="left" y={0} stroke="#475569" strokeDasharray="2 2" />
|
||||
<Bar yAxisId="left" dataKey="daily_balance_czk" radius={[3, 3, 0, 0]} maxBarSize={32}>
|
||||
{data.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.daily_balance_czk >= 0 ? GREEN : RED} fillOpacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cumulative_balance_czk"
|
||||
stroke={BLUE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: BLUE }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
112
frontend/src/hooks/useEconomics.ts
Normal file
112
frontend/src/hooks/useEconomics.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { backendClient } from '../api/backend'
|
||||
import type {
|
||||
ChartDayPoint,
|
||||
DailyEconomics,
|
||||
DailyEconomicsResponse,
|
||||
IntervalEconomics,
|
||||
LockResponse,
|
||||
} from '../types/economics'
|
||||
|
||||
export function useEconomicsDaily(siteId: number | null, month: string) {
|
||||
const [days, setDays] = useState<DailyEconomics[]>([])
|
||||
const [hasGreenBonus, setHasGreenBonus] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null || !month) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await backendClient.get<DailyEconomicsResponse>(
|
||||
`/sites/${siteId}/economics/daily`,
|
||||
{ params: { month }, timeout: 30_000 },
|
||||
)
|
||||
setDays(data.days ?? [])
|
||||
setHasGreenBonus(data.has_green_bonus ?? false)
|
||||
} catch {
|
||||
setDays([])
|
||||
setError('Nepodařilo se načíst ekonomiku')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId, month])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { days, hasGreenBonus, loading, error, reload: load }
|
||||
}
|
||||
|
||||
export function useEconomicsIntervals(siteId: number | null, day: string | null) {
|
||||
const [intervals, setIntervals] = useState<IntervalEconomics[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null || !day) {
|
||||
setIntervals([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await backendClient.get<IntervalEconomics[]>(
|
||||
`/sites/${siteId}/economics/daily/${day}/intervals`,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
setIntervals(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setIntervals([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId, day])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { intervals, loading }
|
||||
}
|
||||
|
||||
export function useEconomicsChart(siteId: number | null, month: string) {
|
||||
const [points, setPoints] = useState<ChartDayPoint[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null || !month) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await backendClient.get<ChartDayPoint[]>(
|
||||
`/sites/${siteId}/economics/monthly-chart`,
|
||||
{ params: { month }, timeout: 30_000 },
|
||||
)
|
||||
setPoints(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
setPoints([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [siteId, month])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { points, loading }
|
||||
}
|
||||
|
||||
export async function lockDay(siteId: number, day: string): Promise<LockResponse> {
|
||||
const { data } = await backendClient.post<LockResponse>(
|
||||
`/sites/${siteId}/economics/daily/${day}/lock`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unlockDay(siteId: number, day: string): Promise<LockResponse> {
|
||||
const { data } = await backendClient.delete<LockResponse>(
|
||||
`/sites/${siteId}/economics/daily/${day}/lock`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
344
frontend/src/pages/Economics.tsx
Normal file
344
frontend/src/pages/Economics.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Lock, Unlock } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { EconomicsChart } from '../components/charts/EconomicsChart'
|
||||
import {
|
||||
lockDay,
|
||||
unlockDay,
|
||||
useEconomicsChart,
|
||||
useEconomicsDaily,
|
||||
useEconomicsIntervals,
|
||||
} from '../hooks/useEconomics'
|
||||
import { pragueCalendarDay } from '../lib/pragueDate'
|
||||
import type { DailyEconomics } from '../types/economics'
|
||||
|
||||
const SITE_ID = 1
|
||||
|
||||
function currentMonth(): string {
|
||||
const today = pragueCalendarDay()
|
||||
return today.slice(0, 7)
|
||||
}
|
||||
|
||||
function monthLabel(ym: string): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
const names = [
|
||||
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
|
||||
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec',
|
||||
]
|
||||
return `${names[m - 1]} ${y}`
|
||||
}
|
||||
|
||||
function shiftMonth(ym: string, delta: number): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
const d = new Date(y, m - 1 + delta, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return d.toLocaleDateString('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague' })
|
||||
}
|
||||
|
||||
function czk(v: number | null | undefined, decimals = 2): string {
|
||||
if (v == null) return '–'
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return `${sign}${v.toFixed(decimals)}`
|
||||
}
|
||||
|
||||
function kwh(v: number | null | undefined): string {
|
||||
if (v == null) return '–'
|
||||
return v.toFixed(1)
|
||||
}
|
||||
|
||||
function balanceColor(v: number): string {
|
||||
if (v > 0) return 'text-green-400'
|
||||
if (v < 0) return 'text-red-400'
|
||||
return 'text-slate-400'
|
||||
}
|
||||
|
||||
function IntervalDetail({ siteId, day, hasGreenBonus }: { siteId: number; day: string; hasGreenBonus: boolean }) {
|
||||
const { intervals, loading } = useEconomicsIntervals(siteId, day)
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-3 text-center text-xs text-slate-500">Načítání intervalů…</div>
|
||||
}
|
||||
|
||||
if (intervals.length === 0) {
|
||||
return <div className="py-3 text-center text-xs text-slate-500">Žádné intervaly</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-slate-400">
|
||||
<th className="px-2 py-1 text-left">Čas</th>
|
||||
<th className="px-2 py-1 text-right">Import kWh</th>
|
||||
<th className="px-2 py-1 text-right">Export kWh</th>
|
||||
<th className="px-2 py-1 text-right">Náklad Kč</th>
|
||||
<th className="px-2 py-1 text-right">Cena nákup</th>
|
||||
<th className="px-2 py-1 text-right">Cena prodej</th>
|
||||
{hasGreenBonus && <th className="px-2 py-1 text-right">Bonus Kč</th>}
|
||||
<th className="px-2 py-1 text-right">Plán grid W</th>
|
||||
<th className="px-2 py-1 text-right">Skuteč. grid W</th>
|
||||
<th className="px-2 py-1 text-right">Plán náklad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals.map((iv) => (
|
||||
<tr key={iv.interval_start} className="border-b border-slate-800 hover:bg-slate-800/40">
|
||||
<td className="px-2 py-1 text-slate-300">{fmtTime(iv.interval_start)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.import_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.export_kwh)}</td>
|
||||
<td className={`px-2 py-1 text-right font-medium ${iv.dynamic_cost_czk != null ? balanceColor(-iv.dynamic_cost_czk) : ''}`}>
|
||||
{iv.dynamic_cost_czk != null ? iv.dynamic_cost_czk.toFixed(2) : '–'}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-slate-400">
|
||||
{iv.effective_buy_price != null ? iv.effective_buy_price.toFixed(2) : '–'}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-slate-400">
|
||||
{iv.effective_sell_price != null ? iv.effective_sell_price.toFixed(2) : '–'}
|
||||
</td>
|
||||
{hasGreenBonus && (
|
||||
<td className="px-2 py-1 text-right text-amber-400">
|
||||
{iv.green_bonus_czk != null && iv.green_bonus_czk > 0
|
||||
? iv.green_bonus_czk.toFixed(2)
|
||||
: '–'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-1 text-right text-slate-400">
|
||||
{iv.planned_grid_w ?? '–'}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right">
|
||||
{iv.actual_grid_power_w ?? '–'}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-slate-400">
|
||||
{iv.planned_cost_czk != null ? iv.planned_cost_czk.toFixed(2) : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyRow({
|
||||
row,
|
||||
hasGreenBonus,
|
||||
siteId,
|
||||
expanded,
|
||||
onToggle,
|
||||
onLockToggle,
|
||||
}: {
|
||||
row: DailyEconomics
|
||||
hasGreenBonus: boolean
|
||||
siteId: number
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onLockToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer border-b border-slate-800 transition hover:bg-slate-800/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm text-slate-200">
|
||||
<span className="mr-1 inline-block w-4">
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
{fmtDay(row.day)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{kwh(row.import_kwh)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{kwh(row.export_kwh)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm text-slate-400">{kwh(row.self_consumption_kwh)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm text-red-400">{row.import_cost_czk.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm text-green-400">{row.export_revenue_czk.toFixed(2)}</td>
|
||||
{hasGreenBonus && (
|
||||
<td className="px-3 py-2 text-right text-sm text-amber-400">
|
||||
{row.green_bonus_czk > 0 ? row.green_bonus_czk.toFixed(2) : '–'}
|
||||
</td>
|
||||
)}
|
||||
<td className={`px-3 py-2 text-right text-sm font-semibold ${balanceColor(row.total_balance_czk)}`}>
|
||||
{czk(row.total_balance_czk)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-sm text-slate-400">
|
||||
{row.planned_balance_czk != null ? czk(row.planned_balance_czk) : '–'}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right text-sm ${row.deviation_cost_czk != null ? balanceColor(-row.deviation_cost_czk) : ''}`}>
|
||||
{row.deviation_cost_czk != null ? czk(row.deviation_cost_czk) : '–'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onLockToggle()
|
||||
}}
|
||||
className="rounded p-1 transition hover:bg-slate-700"
|
||||
title={row.is_locked ? 'Odemknout den' : 'Zamknout den'}
|
||||
>
|
||||
{row.is_locked ? (
|
||||
<Lock size={14} className="text-amber-400" />
|
||||
) : (
|
||||
<Unlock size={14} className="text-slate-500" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td colSpan={hasGreenBonus ? 11 : 10} className="bg-slate-900/50 px-4 py-2">
|
||||
<IntervalDetail siteId={siteId} day={row.day} hasGreenBonus={hasGreenBonus} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Economics() {
|
||||
const [month, setMonth] = useState(currentMonth)
|
||||
const [expandedDay, setExpandedDay] = useState<string | null>(null)
|
||||
|
||||
const { days, hasGreenBonus, loading, error, reload } = useEconomicsDaily(SITE_ID, month)
|
||||
const { points } = useEconomicsChart(SITE_ID, month)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (days.length === 0) return null
|
||||
return {
|
||||
import_cost: days.reduce((s, d) => s + d.import_cost_czk, 0),
|
||||
export_revenue: days.reduce((s, d) => s + d.export_revenue_czk, 0),
|
||||
green_bonus: days.reduce((s, d) => s + d.green_bonus_czk, 0),
|
||||
total_balance: days.reduce((s, d) => s + d.total_balance_czk, 0),
|
||||
}
|
||||
}, [days])
|
||||
|
||||
const handleLockToggle = useCallback(
|
||||
async (row: DailyEconomics) => {
|
||||
try {
|
||||
if (row.is_locked) {
|
||||
await unlockDay(SITE_ID, row.day)
|
||||
toast.success(`Den ${row.day} odemčen`)
|
||||
} else {
|
||||
await lockDay(SITE_ID, row.day)
|
||||
toast.success(`Den ${row.day} zamčen`)
|
||||
}
|
||||
reload()
|
||||
} catch {
|
||||
toast.error('Operace se nezdařila')
|
||||
}
|
||||
},
|
||||
[reload],
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
|
||||
{/* Month selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setMonth((m) => shiftMonth(m, -1))}
|
||||
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h1 className="min-w-[180px] text-center text-lg font-semibold text-white">
|
||||
{monthLabel(month)}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs text-slate-400">Nákup celkem</p>
|
||||
<p className="mt-1 text-lg font-semibold text-red-400">{summary.import_cost.toFixed(2)} Kč</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs text-slate-400">Prodej celkem</p>
|
||||
<p className="mt-1 text-lg font-semibold text-green-400">{summary.export_revenue.toFixed(2)} Kč</p>
|
||||
</div>
|
||||
{hasGreenBonus && (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs text-slate-400">Zelený bonus</p>
|
||||
<p className="mt-1 text-lg font-semibold text-amber-400">{summary.green_bonus.toFixed(2)} Kč</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs text-slate-400">Bilance měsíce</p>
|
||||
<p className={`mt-1 text-lg font-semibold ${balanceColor(summary.total_balance)}`}>
|
||||
{czk(summary.total_balance)} Kč
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<h2 className="mb-3 text-sm font-medium text-slate-300">
|
||||
Denní bilance + kumulativ od 1. v měsíci
|
||||
</h2>
|
||||
<EconomicsChart points={points} />
|
||||
</div>
|
||||
|
||||
{/* Daily table */}
|
||||
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900">
|
||||
{error && (
|
||||
<div className="border-b border-red-900/50 bg-red-900/20 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-slate-500">Načítání…</div>
|
||||
) : days.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-slate-500">Žádná data pro tento měsíc</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-xs text-slate-400">
|
||||
<th className="px-3 py-2 text-left">Den</th>
|
||||
<th className="px-3 py-2 text-right">Import kWh</th>
|
||||
<th className="px-3 py-2 text-right">Export kWh</th>
|
||||
<th className="px-3 py-2 text-right">Vl. spotřeba</th>
|
||||
<th className="px-3 py-2 text-right">Náklad Kč</th>
|
||||
<th className="px-3 py-2 text-right">Příjem Kč</th>
|
||||
{hasGreenBonus && <th className="px-3 py-2 text-right">Bonus Kč</th>}
|
||||
<th className="px-3 py-2 text-right">Bilance Kč</th>
|
||||
<th className="px-3 py-2 text-right">Plán Kč</th>
|
||||
<th className="px-3 py-2 text-right">Odchylka Kč</th>
|
||||
<th className="w-10 px-2 py-2 text-center" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((row) => (
|
||||
<DailyRow
|
||||
key={row.day}
|
||||
row={row}
|
||||
hasGreenBonus={hasGreenBonus}
|
||||
siteId={SITE_ID}
|
||||
expanded={expandedDay === row.day}
|
||||
onToggle={() => setExpandedDay((prev) => (prev === row.day ? null : row.day))}
|
||||
onLockToggle={() => handleLockToggle(row)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
55
frontend/src/types/economics.ts
Normal file
55
frontend/src/types/economics.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type DailyEconomics = {
|
||||
day: string
|
||||
interval_count: number
|
||||
import_kwh: number
|
||||
export_kwh: number
|
||||
pv_kwh: number
|
||||
load_kwh: number
|
||||
self_consumption_kwh: number
|
||||
ev_kwh: number
|
||||
hp_kwh: number
|
||||
import_cost_czk: number
|
||||
export_revenue_czk: number
|
||||
net_cost_czk: number
|
||||
green_bonus_czk: number
|
||||
total_balance_czk: number
|
||||
planned_balance_czk: number | null
|
||||
deviation_cost_czk: number | null
|
||||
is_locked: boolean
|
||||
}
|
||||
|
||||
export type DailyEconomicsResponse = {
|
||||
days: DailyEconomics[]
|
||||
has_green_bonus: boolean
|
||||
}
|
||||
|
||||
export type IntervalEconomics = {
|
||||
interval_start: string
|
||||
import_kwh: number
|
||||
export_kwh: number
|
||||
dynamic_cost_czk: number | null
|
||||
stored_cost_czk: number | null
|
||||
green_bonus_czk: number | null
|
||||
planned_cost_czk: number | null
|
||||
planned_grid_w: number | null
|
||||
actual_grid_power_w: number | null
|
||||
effective_buy_price: number | null
|
||||
effective_sell_price: number | null
|
||||
planned_buy_price: number | null
|
||||
planned_sell_price: number | null
|
||||
actual_pv_power_w: number | null
|
||||
actual_load_power_w: number | null
|
||||
actual_battery_power_w: number | null
|
||||
actual_battery_soc_pct: number | null
|
||||
}
|
||||
|
||||
export type ChartDayPoint = {
|
||||
day: string
|
||||
daily_balance_czk: number
|
||||
cumulative_balance_czk: number
|
||||
}
|
||||
|
||||
export type LockResponse = {
|
||||
locked: boolean
|
||||
day: string
|
||||
}
|
||||
Reference in New Issue
Block a user