implementace Ekonomiky
This commit is contained in:
@@ -15,6 +15,7 @@ import httpx
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import record_to_dict
|
||||||
from app.deps import set_pg_pool
|
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.ev import router as ev_router
|
||||||
from app.routers.full_status import router as full_status_router
|
from app.routers.full_status import router as full_status_router
|
||||||
from app.routers.plan import router as plan_router
|
from app.routers.plan import router as plan_router
|
||||||
@@ -423,6 +424,63 @@ async def lifespan(app: FastAPI):
|
|||||||
id="forecast_refresh_2h",
|
id="forecast_refresh_2h",
|
||||||
replace_existing=True,
|
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()
|
scheduler.start()
|
||||||
|
|
||||||
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
|
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(plan_router, prefix="/api/v1")
|
||||||
app.include_router(ev_router, prefix="/api/v1")
|
app.include_router(ev_router, prefix="/api/v1")
|
||||||
app.include_router(full_status_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"])
|
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}"
|
f"Důvod: {reason}"
|
||||||
)
|
)
|
||||||
await send_discord(msg, level="critical")
|
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
|
SET search_path = pg_catalog, public
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
hid integer;
|
hid integer;
|
||||||
chunk_ids integer[];
|
chunk_ids integer[];
|
||||||
n integer;
|
n integer;
|
||||||
|
has_dropped boolean;
|
||||||
|
q text;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT h.id
|
SELECT h.id
|
||||||
INTO hid
|
INTO hid
|
||||||
@@ -40,18 +42,39 @@ BEGIN
|
|||||||
PERFORM _timescaledb_functions.remove_dropped_chunk_metadata(hid);
|
PERFORM _timescaledb_functions.remove_dropped_chunk_metadata(hid);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT coalesce(array_agg(c.id ORDER BY c.id), ARRAY[]::integer[])
|
-- Sloupec chunk.dropped byl v novějším TimescaleDB odstraněn (metadata dropnutých chunků se maže).
|
||||||
INTO chunk_ids
|
SELECT EXISTS (
|
||||||
FROM _timescaledb_catalog.chunk c
|
SELECT 1
|
||||||
WHERE c.hypertable_id = hid
|
FROM pg_catalog.pg_attribute a
|
||||||
AND NOT c.dropped
|
JOIN pg_catalog.pg_class r ON r.oid = a.attrelid
|
||||||
AND NOT EXISTS (
|
JOIN pg_catalog.pg_namespace n ON n.oid = r.relnamespace
|
||||||
SELECT 1
|
WHERE n.nspname = '_timescaledb_catalog'
|
||||||
FROM pg_catalog.pg_class cl
|
AND r.relname = 'chunk'
|
||||||
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
|
AND a.attname = 'dropped'
|
||||||
WHERE ns.nspname = c.schema_name
|
AND a.attnum > 0
|
||||||
AND cl.relname = c.table_name
|
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);
|
n := coalesce(array_length(chunk_ids, 1), 0);
|
||||||
IF n = 0 THEN
|
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;
|
ELSE COALESCE(v_sell_price, 0) END;
|
||||||
END IF;
|
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;
|
v_pv_b_production_wh := NULL;
|
||||||
FOR r_bonus IN
|
FOR r_bonus IN
|
||||||
SELECT id
|
SELECT pa.id, pa.inverter_id, pa.telemetry_source
|
||||||
FROM ems.asset_pv_array
|
FROM ems.asset_pv_array pa
|
||||||
WHERE site_id = p_site_id
|
WHERE pa.site_id = p_site_id
|
||||||
AND green_bonus_czk_kwh IS NOT NULL
|
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
|
LOOP
|
||||||
SELECT fpi.power_w * 0.25
|
v_array_prod_wh := NULL;
|
||||||
INTO v_array_prod_wh
|
|
||||||
FROM ems.forecast_pv_interval fpi
|
IF r_bonus.telemetry_source IS NOT NULL AND r_bonus.inverter_id IS NOT NULL THEN
|
||||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
SELECT AVG(
|
||||||
WHERE fpr.site_id = p_site_id
|
CASE r_bonus.telemetry_source
|
||||||
AND fpr.pv_array_id = r_bonus.id
|
WHEN 'gen_port' THEN ti.gen_port_power_w
|
||||||
AND fpi.interval_start = p_interval_start
|
WHEN 'pv_strings' THEN COALESCE(ti.pv1_power_w, 0)
|
||||||
AND fpr.status = 'ok'
|
+ COALESCE(ti.pv2_power_w, 0)
|
||||||
ORDER BY fpr.created_at DESC
|
WHEN 'pv_total' THEN ti.pv_power_w
|
||||||
LIMIT 1;
|
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);
|
v_array_prod_wh := COALESCE(v_array_prod_wh, 0);
|
||||||
IF v_pv_b_production_wh IS NULL THEN
|
IF v_pv_b_production_wh IS NULL THEN
|
||||||
@@ -175,7 +200,8 @@ $$;
|
|||||||
COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS
|
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.
|
'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.
|
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.';
|
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 { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
|
import Economics from './pages/Economics'
|
||||||
import { Logs } from './pages/Logs'
|
import { Logs } from './pages/Logs'
|
||||||
import Planning from './pages/Planning'
|
import Planning from './pages/Planning'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
@@ -25,6 +26,9 @@ function AppLayout() {
|
|||||||
<NavLink to="/planning" className={tabClass}>
|
<NavLink to="/planning" className={tabClass}>
|
||||||
Plánování
|
Plánování
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/economics" className={tabClass}>
|
||||||
|
Ekonomika
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/settings" className={tabClass}>
|
<NavLink to="/settings" className={tabClass}>
|
||||||
Nastavení
|
Nastavení
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -55,6 +59,7 @@ export default function App() {
|
|||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="planning" element={<Planning />} />
|
<Route path="planning" element={<Planning />} />
|
||||||
|
<Route path="economics" element={<Economics />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="logs" element={<Logs />} />
|
<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