Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ APP_CORS_ORIGINS=["http://localhost:5173"]
JWT_SECRET_KEY=development-only-change-me-minimum-32-bytes
JWT_EXPIRATION_MINUTES=60
JWT_ALGORITHM=HS256
GRID_EMISSION_FACTOR_KG_PER_KWH=0.0

POSTGRES_DB=chargegrid
POSTGRES_USER=chargegrid
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ docker compose up --build
Serviços:

- frontend: <http://localhost:5173>

O frontend usa `VITE_API_URL` (padrão: `http://localhost:8000/api/v1`). Entre com uma conta existente da API. O token é mantido no armazenamento local do navegador e validado em `/auth/me` ao recarregar; “Sair” ou uma resposta 401 remove a sessão. As rotas `/admin` e `/user` exigem o perfil correspondente.

Em `/admin`, o gestor pode filtrar o dashboard por estação e período, consultar indicadores, gráficos, histórico e alertas, e reconhecer alertas pela API. A previsão e o risco de pico aparecem somente para uma estação com previsão futura válida; na ausência de dados de ML, a tela mostra um estado informativo.
Em `/user`, o usuário consulta a recarga atual, o histórico de sessões e as invoices. O custo durante a recarga é uma estimativa; o valor fechado vem da invoice. Os dados são limitados ao usuário autenticado pela API.
- API: <http://localhost:8000/api/v1/health>
- OpenAPI: <http://localhost:8000/docs>
- PostgreSQL: `localhost:5432`
Expand Down Expand Up @@ -70,6 +75,8 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a
- [Fase 3 — Simulação e leituras (concluída)](docs/PHASE_3.md)
- [Fase 4 — Gestão energética (concluída)](docs/PHASE_4.md)
- [Fase 5 — Billing e histórico de invoices (concluída)](docs/PHASE_5.md)
- [Fase 6 — Contratos da API de analytics](docs/PHASE_6_ANALYTICS.md)
- [Fase 6 — Integração e limites](docs/PHASE_6.md)

## Estado atual

Expand All @@ -93,7 +100,10 @@ A potência solar disponível cobre primeiro a demanda alocada e é rateada
proporcionalmente; a parcela restante vem da rede, limitada por estação.
Cada tick corresponde à duração configurada do relógio (60 segundos simulados
por padrão), calcula energia em kWh e atualiza os acumuladores das sessões.
Esta etapa ainda não gera alertas nem atualiza analytics derivados por tick.
O tick também cria `HIGH_DEMAND` quando a importação da rede atinge o limiar
configurado em `SystemConfiguration` (0,85 na ausência de configuração). O
alerta é emitido uma vez por episódio de alta demanda e participa da mesma
transação das leituras. O tick ainda não atualiza analytics derivados.

Cada estação processada recebe uma `SolarReading` única por timestamp simulado;
cada sessão recebe uma `EnergyReading` única por timestamp. Uma reexecução no
Expand All @@ -109,3 +119,18 @@ alerta na mesma transação. O histórico de invoices está disponível em
`GET /api/v1/billing/invoices` e `GET /api/v1/billing/invoices/{invoice_id}`,
com acesso restrito às próprias invoices para usuários comuns. Consulte
[a validação da Fase 5](docs/PHASE_5.md) para os critérios de aceite e testes.
Listagem e reconhecimento de alertas em `/api/v1/alerts` exigem papel ADMIN.

A integração da Fase 6 cobre os dashboards administrativo e do usuário, os
gráficos de demanda/solar/rede e faturamento, alertas e os indicadores de
sustentabilidade. Um teste integrado percorre início de sessões, três ticks,
redistribuição de potência, prioridade solar, alerta, encerramento, invoice e
atualização das respostas dos dashboards. Os gráficos do gestor somam leituras
simultâneas para mostrar a demanda total de cada tick. Veja os resultados e
limites em [docs/PHASE_6.md](docs/PHASE_6.md).

A Fase 6 ainda não está concluída: o KPI obrigatório de risco de pico depende
de uma previsão futura válida, e o fluxo automático de previsão/classificação
da Fase 7 ainda não existe. Sem esses dados, a tela mostra um estado
informativo. O simulador também depende de ticks manuais pela API; não há
seed reproduzível oficial para a demonstração completa.
4 changes: 4 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter

from app.api.routes.alerts import router as alerts_router
from app.api.routes.analytics import router as analytics_router
from app.api.routes.auth import router as auth_router
from app.api.routes.billing import router as billing_router
from app.api.routes.chargers import router as chargers_router
Expand All @@ -11,13 +12,15 @@
from app.api.routes.simulation import router as simulation_router
from app.api.routes.solar import router as solar_router
from app.api.routes.stations import router as stations_router
from app.api.routes.user_dashboard import router as user_dashboard_router
from app.api.routes.users import router as users_router
from app.api.routes.vehicles import router as vehicles_router

api_router = APIRouter()
api_router.include_router(health_router)
api_router.include_router(auth_router)
api_router.include_router(users_router)
api_router.include_router(user_dashboard_router)
api_router.include_router(vehicles_router)
api_router.include_router(stations_router)
api_router.include_router(chargers_router)
Expand All @@ -27,5 +30,6 @@
api_router.include_router(solar_router)
api_router.include_router(billing_router)
api_router.include_router(alerts_router)
api_router.include_router(analytics_router)
api_router.include_router(predictions_router)
api_router.include_router(configuration_router)
21 changes: 14 additions & 7 deletions backend/app/api/routes/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
from fastapi import APIRouter, Query
from sqlalchemy import select

from app.api.routes.common import DbSession, commit_or_conflict, get_or_404
from app.api.dependencies import AdminUser
from app.api.routes.common import (
FORBIDDEN_RESPONSE,
UNAUTHORIZED_RESPONSE,
DbSession,
commit_or_conflict,
get_or_404,
)
from app.models.alert import Alert, AlertSeverity, AlertType
from app.schemas.alert import AlertResponse

router = APIRouter(prefix="/alerts", tags=["alerts"])
AUTH_RESPONSES = UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE


@router.get("", response_model=list[AlertResponse])
@router.get("", response_model=list[AlertResponse], responses=AUTH_RESPONSES)
async def list_alerts(
db: DbSession,
_: AdminUser,
station_id: UUID | None = None,
alert_type: Annotated[AlertType | None, Query(alias="type")] = None,
severity: AlertSeverity | None = None,
Expand All @@ -29,16 +38,14 @@ async def list_alerts(
statement = statement.where(Alert.severity == severity)
if acknowledged is not None:
condition = (
Alert.acknowledged_at.is_not(None)
if acknowledged
else Alert.acknowledged_at.is_(None)
Alert.acknowledged_at.is_not(None) if acknowledged else Alert.acknowledged_at.is_(None)
)
statement = statement.where(condition)
return list(db.scalars(statement.order_by(Alert.created_at.desc(), Alert.id)).all())


@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse)
async def acknowledge_alert(alert_id: UUID, db: DbSession) -> Alert:
@router.patch("/{alert_id}/acknowledge", response_model=AlertResponse, responses=AUTH_RESPONSES)
async def acknowledge_alert(alert_id: UUID, db: DbSession, _: AdminUser) -> Alert:
alert = get_or_404(db, Alert, alert_id)
if alert.acknowledged_at is None:
alert.acknowledged_at = datetime.now(UTC)
Expand Down
84 changes: 84 additions & 0 deletions backend/app/api/routes/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from datetime import UTC, datetime
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, status

from app.api.dependencies import CurrentUser
from app.api.routes.common import NOT_FOUND_RESPONSE, UNAUTHORIZED_RESPONSE, DbSession
from app.core.config import get_settings
from app.models.user import UserRole
from app.schemas.analytics import DashboardResponse, SustainabilityResponse
from app.services.analytics import AnalyticsFilters, dashboard, sustainability

router = APIRouter(prefix="/analytics", tags=["analytics"])
DateFrom = Annotated[datetime | None, Query(alias="from")]
DateTo = Annotated[datetime | None, Query(alias="to")]


def filters_for_user(
current_user_id: UUID,
role: UserRole,
station_id: UUID | None,
user_id: UUID | None,
date_from: datetime | None,
date_to: datetime | None,
) -> AnalyticsFilters:
if role != UserRole.ADMIN:
if user_id is not None and user_id != current_user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Resource not found")
user_id = current_user_id
if date_from is not None and date_from.tzinfo is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "from must include a timezone")
if date_to is not None and date_to.tzinfo is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "to must include a timezone")
if date_from is not None:
date_from = date_from.astimezone(UTC)
if date_to is not None:
date_to = date_to.astimezone(UTC)
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "from must not exceed to")
return AnalyticsFilters(station_id, user_id, date_from, date_to)


@router.get(
"/dashboard",
response_model=DashboardResponse,
responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE,
)
async def get_dashboard(
db: DbSession,
current_user: CurrentUser,
date_from: DateFrom = None,
date_to: DateTo = None,
station_id: UUID | None = None,
user_id: UUID | None = None,
) -> DashboardResponse:
return dashboard(
db,
filters_for_user(
current_user.id, current_user.role, station_id, user_id, date_from, date_to
),
)


@router.get(
"/sustainability",
response_model=SustainabilityResponse,
responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE,
)
async def get_sustainability(
db: DbSession,
current_user: CurrentUser,
date_from: DateFrom = None,
date_to: DateTo = None,
station_id: UUID | None = None,
user_id: UUID | None = None,
) -> SustainabilityResponse:
return sustainability(
db,
filters_for_user(
current_user.id, current_user.role, station_id, user_id, date_from, date_to
),
get_settings().grid_emission_factor_kg_per_kwh,
)
17 changes: 17 additions & 0 deletions backend/app/api/routes/user_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from fastapi import APIRouter

from app.api.dependencies import RegularUser
from app.api.routes.common import FORBIDDEN_RESPONSE, UNAUTHORIZED_RESPONSE, DbSession
from app.schemas.user_dashboard import UserDashboardResponse
from app.services.user_dashboard import get_user_dashboard

router = APIRouter(prefix="/user", tags=["user dashboard"])


@router.get(
"/dashboard",
response_model=UserDashboardResponse,
responses=UNAUTHORIZED_RESPONSE | FORBIDDEN_RESPONSE,
)
async def user_dashboard(db: DbSession, current_user: RegularUser) -> UserDashboardResponse:
return get_user_dashboard(db, current_user.id)
1 change: 1 addition & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Settings(BaseSettings):
app_log_level: str = "INFO"
app_cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
api_v1_prefix: str = "/api/v1"
grid_emission_factor_kg_per_kwh: float = Field(default=0.0, ge=0)
database_url: str = "postgresql+psycopg://chargegrid:chargegrid@localhost:5432/chargegrid"
jwt_secret_key: str = Field(default=DEFAULT_JWT_SECRET, min_length=32)
jwt_expiration_minutes: int = Field(default=60, gt=0)
Expand Down
29 changes: 29 additions & 0 deletions backend/app/schemas/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel


class DashboardResponse(BaseModel):
station_id: UUID | None
user_id: UUID | None
session_count: int
completed_session_count: int
energy_consumed_kwh: float
solar_energy_kwh: float
grid_energy_kwh: float
billed_total: Decimal
currency: str = "BRL"


class SustainabilityResponse(BaseModel):
station_id: UUID | None
user_id: UUID | None
energy_consumed_kwh: float
solar_energy_kwh: float
grid_energy_kwh: float
solar_percentage: float
avoided_co2_kg: float
grid_emission_factor_kg_per_kwh: float
estimated_solar_savings: Decimal
currency: str = "BRL"
29 changes: 29 additions & 0 deletions backend/app/schemas/user_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel

from app.models.energy import ChargingSessionStatus
from app.schemas.billing import InvoiceResponse


class UserSessionSummary(BaseModel):
id: UUID
status: ChargingSessionStatus
vehicle_name: str
charger_name: str
started_at: datetime | None
ended_at: datetime | None
duration_seconds: int
allocated_power_kw: float
energy_consumed_kwh: float
solar_percentage: float
estimated_cost: Decimal | None
invoice_total: Decimal | None


class UserDashboardResponse(BaseModel):
current_session: UserSessionSummary | None
session_history: list[UserSessionSummary]
invoices: list[InvoiceResponse]
Loading
Loading