diff --git a/.env.example b/.env.example index 7862c09..1fe33f6 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ 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 # Deterministic clock for a fresh demo database only; also set APP_ENV=demo: # DEMO_SIMULATION_START_UTC=2026-09-18T11:58:00Z diff --git a/README.md b/README.md index 68564ec..098f79b 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,14 @@ python3 scripts/sprint3_demo.py - [Fase 6 — Contratos da API de analytics](docs/PHASE_6_ANALYTICS.md) - [Fase 6 — Integração e limites](docs/PHASE_6.md) - [Fase 7 — ML, treinamento e inferência](docs/PHASE_7.md) +- [Fase 8 — ESG e alertas](docs/PHASE_8.md) +- [Fase 9 — Auditoria final do MVP](docs/PHASE_9.md) ## Estado atual -As Fases 1 (fundação), 2 (domínio), 3 (simulação e leituras), 4 (gestão -energética) e 5 (billing e histórico de invoices) estão concluídas, com -critérios de saída e evidências documentados. As migrations das Fases 3 e 5 -foram validadas online em PostgreSQL. O backend entrega +As Fases 1 a 9 do MVP foram implementadas e auditadas, com limitações e +resultados de comandos registrados em [docs/PHASE_9.md](docs/PHASE_9.md). +As migrations foram validadas em PostgreSQL 16. O backend entrega Users/Auth, Vehicles, Stations, Chargers e Sessions sob `/api/v1`, com JWT, autorização por papel e propriedade, persistência via Alembic e regras de início/encerramento de sessão na camada de serviço. @@ -132,7 +133,7 @@ por padrão), calcula energia em kWh e atualiza os acumuladores das sessões. 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. +transação das leituras. Analytics e dashboards consultam as leituras persistidas. Cada estação processada recebe uma `SolarReading` única por timestamp simulado; cada sessão recebe uma `EnergyReading` única por timestamp. Uma reexecução no diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py index 5ae0b02..7d97bcf 100644 --- a/backend/app/api/routes/analytics.py +++ b/backend/app/api/routes/analytics.py @@ -3,10 +3,11 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status +from sqlalchemy import select 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.prediction import SystemConfiguration from app.models.user import UserRole from app.schemas.analytics import DashboardResponse, SustainabilityResponse from app.services.analytics import AnalyticsFilters, dashboard, sustainability @@ -75,10 +76,13 @@ async def get_sustainability( station_id: UUID | None = None, user_id: UUID | None = None, ) -> SustainabilityResponse: + configuration = db.scalar(select(SystemConfiguration).limit(1)) + if configuration is None: + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "ESG configuration unavailable") 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, + configuration.grid_emission_factor_kg_per_kwh, ) diff --git a/backend/app/api/routes/predictions.py b/backend/app/api/routes/predictions.py index 62767e1..f0b0e01 100644 --- a/backend/app/api/routes/predictions.py +++ b/backend/app/api/routes/predictions.py @@ -155,6 +155,9 @@ async def update_system_configuration( "simulation_speed": configuration.simulation_speed, "grid_emission_factor_kg_per_kwh": configuration.grid_emission_factor_kg_per_kwh, "high_demand_threshold": configuration.high_demand_threshold, + "high_solar_availability_threshold": ( + configuration.high_solar_availability_threshold + ), "medium_peak_threshold": configuration.medium_peak_threshold, "high_peak_threshold": configuration.high_peak_threshold, } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ca0194b..b1b3d2c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,7 +22,6 @@ 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) demand_model_path: Path = Path("../data/models/demand_forecast.joblib") demo_simulation_start_utc: datetime | None = None database_url: str = "postgresql+psycopg://chargegrid:chargegrid@localhost:5432/chargegrid" diff --git a/backend/app/demo_seed.py b/backend/app/demo_seed.py index a5067b8..4eb77cc 100644 --- a/backend/app/demo_seed.py +++ b/backend/app/demo_seed.py @@ -80,6 +80,7 @@ def seed(db: Session, admin_password: str, user_password: str) -> None: db.add(SystemConfiguration(simulation_speed=60, grid_emission_factor_kg_per_kwh=EMISSION_FACTOR, high_demand_threshold=0.85, + high_solar_availability_threshold=0.8, medium_peak_threshold=0.7, high_peak_threshold=0.9)) elif config.grid_emission_factor_kg_per_kwh != EMISSION_FACTOR or config.simulation_speed != 60: raise ValueError("Existing system configuration conflicts with demo") diff --git a/backend/app/models/prediction.py b/backend/app/models/prediction.py index 1a20135..1a6e596 100644 --- a/backend/app/models/prediction.py +++ b/backend/app/models/prediction.py @@ -67,6 +67,11 @@ class SystemConfiguration(TimestampMixin, Base): "high_demand_threshold > 0 AND high_demand_threshold <= 1", name="ck_config_high_demand_range", ), + CheckConstraint( + "high_solar_availability_threshold > 0 " + "AND high_solar_availability_threshold <= 1", + name="ck_config_high_solar_availability_range", + ), CheckConstraint( "medium_peak_threshold > 0 AND medium_peak_threshold < high_peak_threshold " "AND high_peak_threshold <= 1", @@ -79,5 +84,8 @@ class SystemConfiguration(TimestampMixin, Base): simulation_speed: Mapped[int] = mapped_column(Integer, nullable=False) grid_emission_factor_kg_per_kwh: Mapped[float] = mapped_column(Float, nullable=False) high_demand_threshold: Mapped[float] = mapped_column(Float, nullable=False) + high_solar_availability_threshold: Mapped[float] = mapped_column( + Float, default=0.8, nullable=False + ) medium_peak_threshold: Mapped[float] = mapped_column(Float, nullable=False) high_peak_threshold: Mapped[float] = mapped_column(Float, nullable=False) diff --git a/backend/app/schemas/prediction.py b/backend/app/schemas/prediction.py index 5959364..57e2325 100644 --- a/backend/app/schemas/prediction.py +++ b/backend/app/schemas/prediction.py @@ -40,6 +40,9 @@ class SystemConfigurationValues(BaseModel): simulation_speed: int = Field(gt=0) grid_emission_factor_kg_per_kwh: float = Field(ge=0, allow_inf_nan=False) high_demand_threshold: float = Field(gt=0, le=1, allow_inf_nan=False) + high_solar_availability_threshold: float = Field( + default=0.8, gt=0, le=1, allow_inf_nan=False + ) medium_peak_threshold: float = Field(gt=0, lt=1, allow_inf_nan=False) high_peak_threshold: float = Field(gt=0, le=1, allow_inf_nan=False) @@ -60,6 +63,9 @@ class SystemConfigurationUpdate(BaseModel): default=None, ge=0, allow_inf_nan=False ) high_demand_threshold: float | None = Field(default=None, gt=0, le=1, allow_inf_nan=False) + high_solar_availability_threshold: float | None = Field( + default=None, gt=0, le=1, allow_inf_nan=False + ) medium_peak_threshold: float | None = Field(default=None, gt=0, lt=1, allow_inf_nan=False) high_peak_threshold: float | None = Field(default=None, gt=0, le=1, allow_inf_nan=False) diff --git a/backend/app/simulation/tick.py b/backend/app/simulation/tick.py index 4555627..a97fb90 100644 --- a/backend/app/simulation/tick.py +++ b/backend/app/simulation/tick.py @@ -76,6 +76,11 @@ def execute_tick( high_demand_threshold = ( configuration.high_demand_threshold if configuration is not None else 0.85 ) + high_solar_threshold = ( + configuration.high_solar_availability_threshold + if configuration is not None + else None + ) rows = db.execute( select(ChargingSession, Charger, Vehicle, ChargingStation) .join(Charger, ChargingSession.charger_id == Charger.id) @@ -168,6 +173,17 @@ def execute_tick( ) or 0.0 ) + previous_solar_available = 0.0 + if previous_station_tick is not None: + previous_solar_available = ( + db.scalar( + select(SolarReading.available_power_kw).where( + SolarReading.station_id == station_id, + SolarReading.timestamp == previous_timestamp, + ) + ) + or 0.0 + ) if ( grid_total / station.grid_limit_kw >= high_demand_threshold and previous_grid_power / station.grid_limit_kw < high_demand_threshold @@ -185,6 +201,27 @@ def execute_tick( created_at=timestamp, ) ) + if ( + high_solar_threshold is not None + and station.station_peak_solar_kw > 0 + and solar_available / station.station_peak_solar_kw >= high_solar_threshold + and previous_solar_available / station.station_peak_solar_kw + < high_solar_threshold + ): + db.add( + Alert( + station_id=station_id, + type=AlertType.HIGH_SOLAR_AVAILABILITY, + severity=AlertSeverity.INFO, + title="High solar availability", + message=( + f"Solar generation reached {solar_available:.2f} kW " + f"({solar_available / station.station_peak_solar_kw:.0%} " + "of configured station peak)." + ), + created_at=timestamp, + ) + ) db.add( SolarReading( diff --git a/backend/migrations/versions/20260924_0012_high_solar_threshold.py b/backend/migrations/versions/20260924_0012_high_solar_threshold.py new file mode 100644 index 0000000..457b451 --- /dev/null +++ b/backend/migrations/versions/20260924_0012_high_solar_threshold.py @@ -0,0 +1,43 @@ +"""Add the configurable high-solar alert threshold. + +Revision ID: 20260924_0012 +Revises: 20260916_0011 +Create Date: 2026-09-24 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260924_0012" +down_revision: str | None = "20260916_0011" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "system_configurations", + sa.Column( + "high_solar_availability_threshold", + sa.Float(), + server_default="0.8", + nullable=False, + ), + ) + op.create_check_constraint( + "ck_config_high_solar_availability_range", + "system_configurations", + "high_solar_availability_threshold > 0 " + "AND high_solar_availability_threshold <= 1", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_config_high_solar_availability_range", + "system_configurations", + type_="check", + ) + op.drop_column("system_configurations", "high_solar_availability_threshold") diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py index 3db786a..707e137 100644 --- a/backend/tests/test_analytics.py +++ b/backend/tests/test_analytics.py @@ -5,11 +5,11 @@ from httpx import AsyncClient from sqlalchemy.orm import Session -from app.core.config import get_settings from app.core.security import hash_password from app.models.billing import Invoice, InvoiceStatus from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading from app.models.infrastructure import Charger, ChargingStation +from app.models.prediction import SystemConfiguration from app.models.user import User, UserRole from app.models.vehicle import Vehicle from app.services.analytics import AnalyticsFilters, dashboard, sustainability @@ -29,7 +29,14 @@ def seed(db: Session) -> tuple[User, User, ChargingStation, ChargingSession, Cha role=UserRole.USER, ) station = ChargingStation(name="One", grid_limit_kw=60) - db.add_all([first, second, station]) + db.add_all([first, second, station, SystemConfiguration( + simulation_speed=60, + grid_emission_factor_kg_per_kwh=0.4, + high_demand_threshold=0.85, + high_solar_availability_threshold=0.8, + medium_peak_threshold=0.7, + high_peak_threshold=0.9, + )]) db.flush() charger_a = Charger(station_id=station.id, name="A", code="A", max_power_kw=22) charger_b = Charger(station_id=station.id, name="B", code="B", max_power_kw=22) @@ -181,6 +188,10 @@ async def test_analytics_api_scope_filters_and_validation( assert (await client.get(f"{base}/sustainability", headers=headers)).json()[ "solar_percentage" ] == 25 + report = (await client.get(f"{base}/sustainability", headers=headers)).json() + assert report["grid_emission_factor_kg_per_kwh"] == 0.4 + assert report["avoided_co2_kg"] == 2 + assert report["estimated_solar_savings"] == "4.60" for endpoint in ("dashboard", "sustainability"): assert ( await client.get( @@ -215,8 +226,10 @@ async def test_analytics_api_scope_filters_and_validation( assert (await client.get(f"{base}/dashboard")).status_code == 401 -def test_emission_factor_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.45") - get_settings.cache_clear() - assert get_settings().grid_emission_factor_kg_per_kwh == 0.45 - get_settings.cache_clear() +@pytest.mark.anyio +async def test_sustainability_requires_identified_esg_configuration( + client: AsyncClient, +) -> None: + response = await client.get("/api/v1/analytics/sustainability") + assert response.status_code == 503 + assert response.json()["detail"] == "ESG configuration unavailable" diff --git a/backend/tests/test_phase_6_flow.py b/backend/tests/test_phase_6_flow.py index e01cffe..53d3833 100644 --- a/backend/tests/test_phase_6_flow.py +++ b/backend/tests/test_phase_6_flow.py @@ -7,7 +7,6 @@ from httpx import AsyncClient from sqlalchemy.orm import Session -from app.core.config import get_settings from app.main import app from app.models.infrastructure import ChargingStation from app.models.prediction import SystemConfiguration @@ -36,10 +35,7 @@ async def override() -> SimulationController: @pytest.mark.anyio async def test_phase_6_golden_path( client: AsyncClient, db_session: Session, control: SimulationController, - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("GRID_EMISSION_FACTOR_KG_PER_KWH", "0.4") - get_settings.cache_clear() station = (await client.post( "/api/v1/stations", json={"name": "Golden Path", "grid_limit_kw": 60, "station_peak_solar_kw": 0}, @@ -144,4 +140,3 @@ async def test_phase_6_golden_path( assert len((await client.get("/api/v1/alerts", params={ "station_id": station["id"], })).json()) >= 2 - get_settings.cache_clear() diff --git a/backend/tests/test_simulation_tick.py b/backend/tests/test_simulation_tick.py index 2c414cd..528692f 100644 --- a/backend/tests/test_simulation_tick.py +++ b/backend/tests/test_simulation_tick.py @@ -40,6 +40,14 @@ def resolve( return {request.session_id: self.powers[request.session_id] for request in sessions} +class FixedSolarProvider: + def __init__(self, value: float) -> None: + self.value = value + + def solar_available_kw(self, timestamp: datetime, station_peak_solar_kw: float) -> float: + return self.value + + def clock() -> SimulationClock: value = SimulationClock(initial_instant=INSTANT) value.start() @@ -105,6 +113,7 @@ def configured_threshold(db: Session, threshold: float) -> None: simulation_speed=60, grid_emission_factor_kg_per_kwh=0.1, high_demand_threshold=threshold, + high_solar_availability_threshold=0.8, medium_peak_threshold=0.7, high_peak_threshold=0.9, ) @@ -112,6 +121,39 @@ def configured_threshold(db: Session, threshold: float) -> None: db.commit() +def alerts_of_type(db: Session, alert_type: AlertType) -> list[Alert]: + return list(db.scalars(select(Alert).where(Alert.type == alert_type))) + + +def test_high_solar_alert_is_configurable_and_deduplicated_by_episode( + db_session: Session, +) -> None: + station, session = add_session(db_session) + configured_threshold(db_session, 0.9) + configuration = db_session.scalar(select(SystemConfiguration)) + assert configuration is not None + configuration.high_solar_availability_threshold = 0.8 + db_session.commit() + resolver = FixedResolver({session.id: PowerBreakdown(7, 7, 0)}) + solar = FixedSolarProvider(8) + tick_clock = clock() + + execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver) + assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1 + db_session.rollback() + execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver) + assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1 + + db_session.rollback() + solar.value = 7 + execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver) + assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 1 + db_session.rollback() + solar.value = 8 + execute_tick(db_session, clock=tick_clock, solar_provider=solar, power_resolver=resolver) + assert len(alerts_of_type(db_session, AlertType.HIGH_SOLAR_AVAILABILITY)) == 2 + + def test_high_demand_threshold_and_new_episode(db_session: Session) -> None: station, session = add_session(db_session) session_id = session.id diff --git a/backend/tests/test_sprint3_demo_script.py b/backend/tests/test_sprint3_demo_script.py index 6bd9c61..6da0529 100644 --- a/backend/tests/test_sprint3_demo_script.py +++ b/backend/tests/test_sprint3_demo_script.py @@ -57,3 +57,36 @@ def test_wait_for_api_reports_backend_diagnostics(monkeypatch: pytest.MonkeyPatc with pytest.raises(RuntimeError, match=r"docker compose logs backend"): demo.wait_for_api(timeout_seconds=0.5) + + +def test_tick_authenticates_energy_history(monkeypatch: pytest.MonkeyPatch) -> None: + demo = load_demo_script() + calls: list[tuple[str, str, str | None]] = [] + + def fake_call( + method: str, + path: str, + token: str | None = None, + body: dict | None = None, + params: dict | None = None, + ) -> object: + calls.append((method, path, token)) + if path == "/simulation/ticks": + return {"timestamp": "2026-09-18T11:58:00Z"} + return [ + { + "timestamp": "2026-09-18T11:58:00Z", + "allocated_power_kw": 20, + "solar_power_kw": 0, + "grid_power_kw": 20, + "interval_energy_kwh": 1 / 3, + } + for _ in range(3) + ] + + monkeypatch.setattr(demo, "call", fake_call) + monkeypatch.setattr(demo, "show", lambda *_: None) + + demo.tick("admin-token", "station-id", expected_count=3, expected_solar_kw=0) + + assert ("GET", "/energy/history", "admin-token") in calls diff --git a/docker-compose.yml b/docker-compose.yml index 5bbd55a..5686b56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,6 @@ services: JWT_SECRET_KEY: ${JWT_SECRET_KEY:-development-only-change-me-minimum-32-bytes} JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-60} JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} - GRID_EMISSION_FACTOR_KG_PER_KWH: ${GRID_EMISSION_FACTOR_KG_PER_KWH:-0.0} DEMAND_MODEL_PATH: ${DEMAND_MODEL_PATH:-../data/models/demand_forecast.joblib} DEMO_SIMULATION_START_UTC: ${DEMO_SIMULATION_START_UTC:-} command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" diff --git a/docs/PHASE_6.md b/docs/PHASE_6.md index fde82f6..eadc770 100644 --- a/docs/PHASE_6.md +++ b/docs/PHASE_6.md @@ -1,9 +1,8 @@ # Fase 6 — Integração e limites -**Estado: parcial.** Os dashboards, gráficos, alertas e indicadores de -sustentabilidade estão implementados. O requisito obrigatório de risco de pico -do dashboard administrativo ainda depende do pipeline de previsão da Fase 7. -Não há treinamento de ML nesta entrega. +**Estado histórico da entrega:** concluída quanto a dashboards e integração; +o risco de pico, então dependente da Fase 7, foi posteriormente implementado e +validado. Consulte `PHASE_7.md` e `PHASE_9.md` para o estado final do MVP. ## Entregue @@ -32,7 +31,7 @@ uma sessão concluída, o faturamento e o histórico do usuário. A API de sustentabilidade retorna a fração solar e o CO₂ evitado com fator de emissão configurado em 0,4 kg/kWh para o teste. -## Limites restantes +## Limites no momento desta fase - O KPI de risco de pico só aparece quando há uma previsão futura válida para a estação. O pipeline que produz previsões e classificações é trabalho da diff --git a/docs/PHASE_8.md b/docs/PHASE_8.md new file mode 100644 index 0000000..de074f9 --- /dev/null +++ b/docs/PHASE_8.md @@ -0,0 +1,46 @@ +# Fase 8 — ESG e alertas + +## Escopo consolidado + +O relatório `GET /api/v1/analytics/sustainability` aceita `station_id`, `from` e `to` e apresenta energia total, solar e da rede, participação solar, CO₂ evitado, fator de emissão utilizado e economia solar estimada. O fator de emissão vem exclusivamente de `SystemConfiguration`; na ausência dessa configuração, a API retorna `503` em vez de aplicar um valor ambiental implícito. + +As fórmulas do SPEC foram preservadas: + +- participação solar = energia solar / energia total × 100; com consumo zero, o resultado é zero; +- CO₂ evitado = energia solar × fator de emissão configurado; +- economia estimada = soma da energia solar de cada leitura × tarifa registrada na respectiva sessão. + +A economia é apresentada como estimativa teórica da energia que teria sido comprada da rede e não como economia financeira real da instalação. + +## Critérios dos alertas + +- `HIGH_DEMAND`: entrada da rede / limite da rede alcança o limiar configurado; +- `PEAK_RISK`: previsão da Fase 7 entra no nível `HIGH`; +- `HIGH_SOLAR_AVAILABILITY`: geração solar disponível / pico solar configurado da estação alcança `high_solar_availability_threshold`; +- `SESSION_FINISHED`: encerramento bem-sucedido da sessão. + +Os alertas de condição são emitidos apenas na entrada de um episódio: ticks consecutivos acima do limiar não repetem o alerta; depois que a condição fica abaixo do limiar, uma nova subida inicia outro episódio. Estações com pico solar zero não geram `HIGH_SOLAR_AVAILABILITY`. + +## Critérios de aceite + +- filtros de estação e período afetam todos os totais ESG; +- consumo zero produz participação solar igual a zero; +- fator ambiental exibido é o mesmo fator configurado usado no cálculo; +- economia aparece explicitamente como “economia estimada” e “teórica”; +- limiares aceitam somente proporções no intervalo `(0, 1]`; +- o dashboard administrativo apresenta o relatório ESG completo; +- `HIGH_DEMAND`, `PEAK_RISK` e `SESSION_FINISHED` permanecem ativos; +- alertas por condição são deduplicados por episódio. + +## Resultados dos testes + +Resultados executados em 24/09/2026: + +- backend: `209 passed`, cobertura total de 96%; +- frontend: `31 passed`; +- Ruff: aprovado; +- mypy: aprovado em 74 arquivos; +- ESLint: aprovado; +- build de produção Vite/TypeScript: aprovado (com o aviso informativo já existente sobre tamanho do bundle). + +A suíte cobre fórmulas ESG, consumo zero, fator configurado, filtros, validação de limites, episódios de alerta solar e de demanda, integração do risco de pico e apresentação administrativa. diff --git a/docs/PHASE_9.md b/docs/PHASE_9.md new file mode 100644 index 0000000..96f9442 --- /dev/null +++ b/docs/PHASE_9.md @@ -0,0 +1,95 @@ +# Fase 9 — Auditoria final do MVP + +**Estado:** concluída em 24/09/2026, com limitações explícitas abaixo. + +Esta auditoria confrontou `SPEC.md`, `BRIEFING.md`, implementação, testes, +migrations e roteiro de demonstração. O `SPEC.md` foi tratado como fonte de +verdade. Não foram adicionadas funcionalidades de escopo secundário. + +## Matriz requisito → implementação → teste → estado + +| Requisito | Implementação principal | Evidência automatizada | Estado | +| --- | --- | --- | --- | +| Entidades, UUID, UTC e migrations | `app/models`, `migrations/versions` | testes de domínio e `alembic current/check` | Atendido | +| Auth, papéis e isolamento por usuário | dependencies, rotas de auth, vehicles, sessions, energy, billing e dashboards | `test_auth_authorization.py`, `test_energy.py`, `test_invoice_history.py`, `test_user_dashboard.py` | Atendido | +| Sessões e limites individuais | `services/charging_sessions.py` | `test_charging_session_domain.py` | Atendido | +| Equal Share e limite da rede | `services/energy_allocation.py`, `simulation/tick.py` | `test_energy_allocation.py`, `test_simulation_tick.py`, `test_phase_6_flow.py` | Atendido | +| Prioridade e rateio solar | alocador e tick | testes de alocação, tick e Golden Path | Atendido | +| Leituras e conservação de energia | `services/energy_readings.py` | `test_energy_reading_domain.py`, `test_simulation_api.py` | Atendido | +| Billing decimal e invoice fechada | `services/billing.py`, serviço de sessões | `test_billing_alerts.py`, `test_invoice_history.py`, Golden Path | Atendido | +| Analytics, ESG e dashboards | serviços de analytics e páginas admin/user | `test_analytics.py`, `test_phase_6_flow.py`, testes React | Atendido | +| Dataset, baseline e ausência de vazamento temporal | `app/ml/dataset.py`, `baseline.py` | `test_ml_dataset.py`, `test_ml_training.py` | Atendido | +| Inferência consultiva, risco e fallback sem modelo | `services/demand_predictions.py`, rota `/predictions/demand/run` | `test_demand_inference.py`, `test_predictions_configuration.py` | Atendido | +| Alertas de demanda, pico, solar e encerramento | tick, inferência e encerramento | `test_simulation_tick.py`, `test_demand_inference.py`, `test_billing_alerts.py` | Atendido | +| Health, OpenAPI e erros sem stack trace | `main.py`, health e handlers | `test_health.py`, `test_openapi.py`, `test_error_handling.py` | Atendido | +| Frontend responsivo, vazio, loading, retry e erros | páginas React e breakpoints em `styles.css` | 31 testes Vitest e inspeção visual em 390 px | Atendido | +| Seed e cenário oficial 3→4 carregadores | `app/demo_seed.py`, `scripts/sprint3_demo.py` | `test_demo_seed.py`, `test_sprint3_demo_script.py`, PostgreSQL real | Atendido com limitação de ML | +| Relógio acelerado automático | relógio configurável e ticks administrativos manuais | testes do relógio e simulação | Parcial | + +## Lacunas encontradas e decisão + +- O roteiro chamava endpoints protegidos de energia e solar sem JWT. Isso fazia + a demo falhar em uma API real, embora passasse em testes com dependências + substituídas. O token ADMIN foi incluído e um teste de regressão foi criado. +- O roteiro oficial ainda não prepara o histórico causal exigido pela inferência. + A previsão real foi validada separadamente no PostgreSQL com uma observação + histórica anterior e o artefato treinado; não se ocultou essa preparação no seed. +- A simulação é acionada por ticks administrativos; não existe loop em background. + Isso é aceito como limitação do MVP demonstrável, não como integração com hardware. +- O modelo Random Forest versionado como artefato local não superou o baseline no + ensaio documentado da Fase 7. Ele permanece consultivo e não controla potência. +- A cobertura por família de endpoint é completa. Não foi encontrado endpoint + público do `SPEC.md` sem teste; as linhas não cobertas do relatório são sobretudo + ramos de erro/defesa, não famílias inteiras de API. + +## Validação executada + +Em 24/09/2026: + +```text +make check + Ruff: aprovado + mypy: aprovado (74 arquivos) + pytest: 210 aprovados; cobertura total 96% + ESLint: aprovado + TypeScript: aprovado + Vitest: 31 aprovados + Vite build: aprovado; aviso não bloqueante de chunk de 683,05 kB + +PostgreSQL 16 em stack Docker isolada + alembic current: 20260924_0012 (head) + alembic check: No new upgrade operations detected + seed: aprovado + health: HTTP 200, {"status":"ok"} + OpenAPI: 3.1.0, 36 paths + frontend: HTTP 200 +``` + +O Golden Path real confirmou 3 × 20 kW, depois 4 × 15 kW sob limite de +60 kW, e em seguida 80 kW totais com 20 kW solares e 60 kW de rede. A quarta +sessão foi encerrada com invoice `CLOSED` de R$ 0,47 e os dashboards refletiram +billing e ESG. A inferência real produziu previsão para +60 minutos, risco +`HIGH` e alerta `PEAK_RISK`; limiares reduzidos foram usados somente no banco +isolado de auditoria para tornar o ramo HIGH determinístico. + +## Limitações assumidas do MVP + +- simulador manual, sem scheduler e sem hardware/OCPP/Modbus; +- billing simulado, sem pagamento real; +- modelo local consultivo e dependente de artefato e histórico causal; +- seed oficial não garante inferência imediata; +- sem MLOps, cloud complexa, app mobile ou recursos do escopo secundário; +- aviso de tamanho do bundle frontend, sem falha funcional observada. + +## Roteiro final de demonstração + +1. Subir Docker, aplicar migrations e executar o seed com senhas no ambiente. +2. Abrir health, OpenAPI e autenticar gestor e motorista. +3. Iniciar três sessões e executar tick: 3 × 20 kW, 60 kW de rede. +4. Iniciar a quarta e executar tick: 4 × 15 kW, ainda 60 kW de rede. +5. Configurar pico solar em 20 kW e executar tick: 80 kW alocados, 20 solar + 60 rede. +6. Mostrar leituras, `HIGH_DEMAND`/`HIGH_SOLAR_AVAILABILITY` e dashboards. +7. Com artefato e histórico causal preparados, executar previsão e mostrar risco, + recomendação e `PEAK_RISK` sem alteração da alocação. +8. Encerrar a quarta sessão e mostrar `COMPLETED`, carregador `AVAILABLE`, invoice + `CLOSED`, custo, participação solar, CO₂ evitado e dashboards atualizados. diff --git a/docs/sprint-3/README.md b/docs/sprint-3/README.md index 784e7de..7df1964 100644 --- a/docs/sprint-3/README.md +++ b/docs/sprint-3/README.md @@ -12,13 +12,13 @@ - Marcos Peterson Martins Pereira - Matheus Jorge Santana -Este roteiro demonstra, por simulação, o Golden Path de `SPEC.md` §§ 54–55. As leituras solares e de energia são **simuladas**; não representam medições físicas. Billing e invoice também são simulados. A previsão automática e o risco de pico dependem da Fase 7; o dashboard deixa esse KPI sem valor quando não há previsão válida. +Este roteiro demonstra, por simulação, o cenário energético de `SPEC.md` §§ 54–55. As leituras solares e de energia são **simuladas**; não representam medições físicas. Billing e invoice também são simulados. A previsão e o risco de pico foram implementados na Fase 7, mas exigem artefato treinado e histórico causal; o seed deste roteiro não prepara sozinho esse histórico. ## Arquitetura e escolhas - [Arquitetura executada em PNG](diagrams/architecture.png) ([fonte Mermaid](architecture.mmd)) e [sequência da demo em PNG](diagrams/sequence.png) ([fonte Mermaid](sequence.mmd)) representam o fluxo conferido contra a API. - O monólito modular usa React, FastAPI, SQLAlchemy e PostgreSQL. O controlador de simulação recebe ticks manuais; `SimulationEnergyDataProvider` fornece a curva solar UTC. O alocador Equal Share respeita os limites de rede, carregador e veículo. A API calcula billing e analytics. -- A simulação não possui agendador, controle físico, OCPP/Modbus nem modelo ML treinado. O script apenas orquestra endpoints públicos. +- A simulação não possui agendador, controle físico ou OCPP/Modbus. O script orquestra endpoints públicos; o treinamento e a inferência ML são executados separadamente conforme a Fase 7. ## Reprodução em banco de demo limpo @@ -49,6 +49,7 @@ Abra [OpenAPI](http://localhost:8000/docs), [dashboard administrativo](http://lo 4. Configurar 20 kW de pico solar pela API e mostrar o terceiro tick de cerca de 80 kW. 5. Mostrar leituras, alerta e dashboards atualizados. 6. Encerrar a quarta sessão, mostrar invoice, tarifa e CO₂ evitado. -7. Explicar os limites: sem equipamento real e sem previsão automática de demanda. +7. Mostrar, em ambiente preparado conforme a Fase 7, previsão, risco e alerta consultivos. +8. Explicar os limites: sem equipamento real, scheduler ou inferência imediata a partir do seed. Esta entrega do EV Challenge 2026 aplica Pensamento Computacional e Automação com Python à modelagem do cenário, ao controlador de simulação, ao seed reproduzível, à orquestração das chamadas HTTP e à validação automática dos limites energéticos. Inclua as evidências visuais capturadas antes da apresentação. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e5a94f..8ea6082 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { LoginPage } from './pages/LoginPage' import { UserDashboardPage } from './pages/UserDashboardPage' import { AppShell } from './layouts/AppShell' import { AdminDashboardPage } from './pages/AdminDashboardPage' +import { AdminOperationsPage } from './pages/AdminOperationsPage' function Home() { const { state } = useAuth() @@ -16,7 +17,10 @@ export function App() { return } /> } /> - }>} /> + }> + } /> + } /> + }>} />

Acesso não permitido

Seu perfil não tem acesso a esta área.

Voltar
} /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 369f7a6..3f371a7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,8 +14,15 @@ export interface UserDashboard { current_session: UserSessionSummary | null; ses export interface Dashboard { station_id: string | null; user_id: string | null; session_count: number; completed_session_count: number; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; billed_total: string; currency: string } export interface Sustainability { station_id: string | null; user_id: string | null; energy_consumed_kwh: number; solar_energy_kwh: number; grid_energy_kwh: number; solar_percentage: number; avoided_co2_kg: number; grid_emission_factor_kg_per_kwh: number; estimated_solar_savings: string; currency: string } export interface Filters { station_id?: string; user_id?: string; from?: string; to?: string } -export interface Station { id: string; name: string; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } +export interface Station { id: string; name: string; description: string | null; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } +export interface StationInput { name: string; description: string | null; grid_limit_kw: number; station_peak_solar_kw: number; is_active: boolean } export interface Charger { id: string; station_id: string; name: string; code: string; max_power_kw: number; status: 'AVAILABLE' | 'CHARGING' | 'UNAVAILABLE'; is_active: boolean } +export interface ChargerInput { station_id: string; name: string; code: string; max_power_kw: number; status: Charger['status']; is_active: boolean } +export interface Tariff { id: string; name: string; price_per_kwh: string; currency: string; is_active: boolean; valid_from: string; valid_until: string | null; created_at: string } +export interface TariffInput { name: string; price_per_kwh: number; currency: string; is_active: boolean; valid_from: string; valid_until: string | null } +export interface SystemConfiguration { id: string; simulation_speed: number; grid_emission_factor_kg_per_kwh: number; high_demand_threshold: number; high_solar_availability_threshold: number; medium_peak_threshold: number; high_peak_threshold: number; created_at: string; updated_at: string } +export type SystemConfigurationInput = Omit +export interface SimulationStatus { state: 'RUNNING' | 'STOPPED'; current_instant: string; tick_duration_seconds: number; simulation_speed: number; last_tick: string | null } export interface SolarReading { id: string; station_id: string; timestamp: string; available_power_kw: number } export interface DemandPrediction { id: string; station_id: string; generated_at: string; prediction_for: string; predicted_demand_kw: number; capacity_kw: number; risk_level: 'LOW' | 'MEDIUM' | 'HIGH'; prediction_horizon_minutes: number; model_version: string; recommendation: string } export interface Alert { id: string; station_id: string; type: string; severity: 'INFO' | 'WARNING' | 'CRITICAL'; title: string; message: string; created_at: string; acknowledged_at: string | null } @@ -47,7 +54,9 @@ async function request(path: string, options: RequestInit = {}, authenticated if (response.status === 401 && authenticated && token && tokenStore.get() === token) tokenStore.clear() if (!response.ok) { const body: unknown = await response.json().catch(() => null) - const detail = typeof body === 'object' && body !== null && 'detail' in body && typeof body.detail === 'string' ? body.detail : undefined + const rawDetail = typeof body === 'object' && body !== null && 'detail' in body ? body.detail : undefined + const detail = typeof rawDetail === 'string' ? rawDetail : Array.isArray(rawDetail) + ? rawDetail.map(item => typeof item === 'object' && item !== null && 'msg' in item ? String(item.msg) : '').filter(Boolean).join(' ') : undefined throw new ApiError(response.status, response.status === 403 ? 'Acesso não permitido.' : detail ?? `Erro HTTP ${response.status}`) } if (response.status === 204) return undefined as T @@ -73,7 +82,21 @@ export const api = { dashboard: (filters?: Filters) => request(`/analytics/dashboard${query(filters)}`), sustainability: (filters?: Filters) => request(`/analytics/sustainability${query(filters)}`), stations: () => request('/stations'), + createStation: (value: StationInput) => request('/stations', { method: 'POST', body: JSON.stringify(value) }), + updateStation: (id: string, value: Partial) => request(`/stations/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), chargers: () => request('/chargers'), + createCharger: (value: ChargerInput) => request('/chargers', { method: 'POST', body: JSON.stringify(value) }), + updateCharger: (id: string, value: Partial) => request(`/chargers/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), + tariffs: () => request('/tariffs'), + createTariff: (value: TariffInput) => request('/tariffs', { method: 'POST', body: JSON.stringify(value) }), + updateTariff: (id: string, value: Partial) => request(`/tariffs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(value) }), + systemConfiguration: () => request('/system-configuration'), + createSystemConfiguration: (value: SystemConfigurationInput) => request('/system-configuration', { method: 'POST', body: JSON.stringify(value) }), + updateSystemConfiguration: (value: SystemConfigurationInput) => request('/system-configuration', { method: 'PATCH', body: JSON.stringify(value) }), + simulationStatus: () => request('/simulation/status'), + startSimulation: () => request('/simulation/start', { method: 'POST' }), + stopSimulation: () => request('/simulation/stop', { method: 'POST' }), + resetSimulation: () => request('/simulation/reset', { method: 'POST' }), solarHistory: (filters?: Filters) => request(`/solar/history${query(filters)}`), prediction: (station_id: string) => request(`/predictions/demand${query({ station_id })}`), runPrediction: (station_id: string) => request('/predictions/demand/run', { method: 'POST', body: JSON.stringify({ station_id }) }), diff --git a/frontend/src/layouts/AppShell.tsx b/frontend/src/layouts/AppShell.tsx index 5aa708a..adaad76 100644 --- a/frontend/src/layouts/AppShell.tsx +++ b/frontend/src/layouts/AppShell.tsx @@ -1,14 +1,16 @@ import type { PropsWithChildren } from 'react' +import { Link, useLocation } from 'react-router-dom' export function AppShell({ children }: PropsWithChildren) { + const location = useLocation() return (
- + CG ChargeGrid Intelligence - - ChargeGrid Intelligence + + {location.pathname.startsWith('/admin') && }
{children}
diff --git a/frontend/src/pages/AdminDashboardPage.test.tsx b/frontend/src/pages/AdminDashboardPage.test.tsx index 22a0dbd..4cbf68b 100644 --- a/frontend/src/pages/AdminDashboardPage.test.tsx +++ b/frontend/src/pages/AdminDashboardPage.test.tsx @@ -11,7 +11,7 @@ const session = { id: 'se1', user_id: 'u1', charger_id: 'c1', status: 'CHARGING' const reading = { id: 'r1', session_id: 'se1', timestamp: '2026-09-17T12:00:00Z', allocated_power_kw: 20, solar_power_kw: 8, grid_power_kw: 12 } const alert = { id: 'a1', station_id: 's1', title: 'Demanda elevada', message: 'Verifique a rede', severity: 'WARNING', type: 'HIGH_DEMAND', created_at: '2026-09-17T12:00:00Z', acknowledged_at: null } const summary = { session_count: 1, completed_session_count: 0, energy_consumed_kwh: 5, billed_total: '0.00' } -const sustainability = { solar_energy_kwh: 2, avoided_co2_kg: 0.8 } +const sustainability = { station_id: null, user_id: null, energy_consumed_kwh: 5, solar_energy_kwh: 2, grid_energy_kwh: 3, solar_percentage: 40, avoided_co2_kg: 0.8, grid_emission_factor_kg_per_kwh: 0.4, estimated_solar_savings: '1.84', currency: 'BRL' } const prediction: DemandPrediction = { id: 'p1', station_id: 's1', generated_at: new Date().toISOString(), prediction_for: new Date(Date.now() + 3600000).toISOString(), predicted_demand_kw: 54, capacity_kw: 60, risk_level: 'HIGH', prediction_horizon_minutes: 60, model_version: 'rf-2026.09', recommendation: 'Monitore novas sessões.' } type MockOptions = { prediction?: DemandPrediction | null; predictionFailure?: number | 'network'; runResult?: DemandPrediction; runFailure?: number | 'network'; stationList?: typeof stations } @@ -103,6 +103,13 @@ describe('admin dashboard prediction', () => { }) describe('admin dashboard operations', () => { + it('shows the complete filtered ESG report and identifies theoretical savings', async () => { + mockApi({ prediction }); show() + expect(await screen.findByRole('heading', { name: 'Relatório de sustentabilidade' })).toBeInTheDocument() + for (const label of ['Energia total', 'Energia solar', 'Energia da rede', 'Participação solar', 'CO₂ evitado', 'Fator de emissão', 'Economia estimada']) expect(screen.getAllByText(label).length).toBeGreaterThan(0) + expect(screen.getByText(/Economia estimada teórica/)).toBeInTheDocument() + }) + it('applies period filters and acknowledges alerts through the API', async () => { const fetch = mockApi({ prediction }); show() await screen.findByText('54 kW') diff --git a/frontend/src/pages/AdminDashboardPage.tsx b/frontend/src/pages/AdminDashboardPage.tsx index 4849745..a80f66f 100644 --- a/frontend/src/pages/AdminDashboardPage.tsx +++ b/frontend/src/pages/AdminDashboardPage.tsx @@ -116,6 +116,11 @@ export function AdminDashboardPage() { {data.predictionState === 'valid' && prediction ?
Demanda{number(prediction.predicted_demand_kw)} kW
Risco de pico {risk[prediction.risk_level].label}
Horário-alvo{time(prediction.prediction_for)}
Versão do modelo{prediction.model_version}
Recomendação{prediction.recommendation}
:

{predictionMessages[data.predictionState as Exclude]}

}

A previsão apoia decisões operacionais e não executa controle energético.

+
+

Fase 8 · ESG

Relatório de sustentabilidade

+
Energia total{number(data.sustainability.energy_consumed_kwh)} kWh
Energia solar{number(data.sustainability.solar_energy_kwh)} kWh
Energia da rede{number(data.sustainability.grid_energy_kwh)} kWh
Participação solar{number(data.sustainability.solar_percentage)}%
CO₂ evitado{number(data.sustainability.avoided_co2_kg)} kg
Fator de emissão{number(data.sustainability.grid_emission_factor_kg_per_kwh)} kg CO₂/kWh
Economia estimada{money(data.sustainability.estimated_solar_savings)}
+

Economia estimada teórica: valor da energia solar que teria sido comprada da rede; não representa necessariamente economia financeira real da instalação.

+

Demanda ao longo do tempo

{history.length ? :

Sem histórico de demanda no período.

}

Solar vs rede

{history.length ? :

Sem dados de energia no período.

}

Sessões

{data.summary.session_count} iniciadas · {data.summary.completed_session_count} concluídas no período

    {shownSessions.slice(-8).reverse().map(session =>
  • {session.status}{number(session.energy_consumed_kwh)} kWh
  • )}
{!shownSessions.length &&

Nenhuma sessão encontrada.

}

Faturamento

{invoiceHistory.length ? money(String(value))} /> :

Sem faturamento no período.

}

Histórico recente

{history.length ?
{(data.readings ?? []).slice(-10).reverse().map(reading => )}
DataDemandaSolarRede
{time(reading.timestamp)}{number(reading.allocated_power_kw)} kW{number(reading.solar_power_kw)} kW{number(reading.grid_power_kw)} kW
:

Nenhum registro no período.

}

Alertas recentes

{actionError &&

{actionError}

}{data.alerts.length ?
    {data.alerts.slice(0, 10).map(alert =>
  • {alert.title}

    {alert.message}

    {time(alert.created_at)} · {alert.severity}
    {alert.acknowledged_at ? Reconhecido : }
  • )}
:

Nenhum alerta recente.

}
diff --git a/frontend/src/pages/AdminOperationsPage.test.tsx b/frontend/src/pages/AdminOperationsPage.test.tsx new file mode 100644 index 0000000..01c452f --- /dev/null +++ b/frontend/src/pages/AdminOperationsPage.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { App } from '../App' +import { tokenStore } from '../api/client' + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) +const admin = { id: 'a1', name: 'Admin', email: 'admin@example.com', role: 'ADMIN', is_active: true, created_at: '', updated_at: '' } +const user = { ...admin, id: 'u1', role: 'USER' } +const station = { id: 's1', name: 'Central', description: 'Matriz', grid_limit_kw: 60, station_peak_solar_kw: 20, is_active: true } +const charger = { id: 'c1', station_id: 's1', name: 'Carga 1', code: 'CG-01', max_power_kw: 22, status: 'AVAILABLE', is_active: true } +const tariff = { id: 't1', name: 'Padrão', price_per_kwh: '0.9200', currency: 'BRL', is_active: true, valid_from: '2026-09-01T00:00:00Z', valid_until: null, created_at: '' } +const configuration = { id: 'cfg1', simulation_speed: 60, grid_emission_factor_kg_per_kwh: 0.084, high_demand_threshold: 0.85, high_solar_availability_threshold: 0.8, medium_peak_threshold: 0.7, high_peak_threshold: 0.9, created_at: '', updated_at: '' } +const simulation = { state: 'STOPPED', current_instant: '2026-09-23T12:00:00Z', tick_duration_seconds: 60, simulation_speed: 60, last_tick: null } + +function mockApi(role: 'ADMIN' | 'USER' = 'ADMIN') { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(String(input)); const method = init?.method ?? 'GET' + if (url.pathname.endsWith('/auth/me')) return json(role === 'ADMIN' ? admin : user) + if (url.pathname.endsWith('/stations')) return method === 'POST' ? json(station, 201) : json([station]) + if (url.pathname.endsWith('/chargers')) return json([charger]) + if (url.pathname.endsWith('/tariffs')) return json([tariff]) + if (url.pathname.endsWith('/system-configuration')) return method === 'PATCH' ? json(configuration) : json(configuration) + if (url.pathname.endsWith('/simulation/status')) return json(simulation) + if (url.pathname.endsWith('/simulation/reset')) return json(simulation) + return json({ detail: 'unexpected' }, 500) + }) +} + +function show() { tokenStore.set('token'); render() } +beforeEach(() => localStorage.clear()) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('admin operations authorization and rendering', () => { + it('prevents a regular user from opening the administration route', async () => { + mockApi('USER'); show() + expect(await screen.findByRole('heading', { name: 'Acesso não permitido' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Operação do MVP' })).not.toBeInTheDocument() + }) + + it('renders every minimum management area with loaded data', async () => { + mockApi(); show() + expect(await screen.findByRole('heading', { name: 'Estações' })).toBeInTheDocument() + for (const name of ['Carregadores', 'Tarifas', 'Energia, risco e simulação']) expect(screen.getByRole('heading', { name })).toBeInTheDocument() + expect(screen.getAllByText('Central').length).toBeGreaterThan(0) + expect(screen.getByText('Carga 1 (CG-01)')).toBeInTheDocument() + expect(screen.getByText('Padrão')).toBeInTheDocument() + expect(screen.getByText(/classificação de risco permanece calculada pelo backend/i)).toBeInTheDocument() + }) + + it('validates risk threshold ordering before calling the API', async () => { + const fetch = mockApi(); show(); await screen.findByRole('heading', { name: 'Estações' }) + fireEvent.change(screen.getByLabelText('Limiar de risco médio'), { target: { value: '0.95' } }) + fireEvent.change(screen.getByLabelText('Limiar de risco alto'), { target: { value: '0.9' } }) + fireEvent.click(screen.getByRole('button', { name: 'Atualizar parâmetros' })) + expect(await screen.findByRole('alert')).toHaveTextContent('O limiar médio deve ser menor que o limiar alto.') + expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/system-configuration') && init?.method === 'PATCH')).toHaveLength(0) + }) + + it('requires confirmation before resetting the simulation clock', async () => { + const fetch = mockApi(); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); show(); await screen.findByRole('heading', { name: 'Estações' }) + fireEvent.click(screen.getByRole('button', { name: 'Redefinir relógio' })) + expect(confirm).toHaveBeenCalled() + expect(fetch.mock.calls.some(([url]) => String(url).endsWith('/simulation/reset'))).toBe(false) + }) + + it('shows API validation and conflict messages near the form', async () => { + const fetch = mockApi(); const original = fetch.getMockImplementation()! + fetch.mockImplementation(async (input, init) => String(input).endsWith('/stations') && init?.method === 'POST' ? json({ detail: 'Station already exists' }, 409) : original(input, init)) + show(); await screen.findByRole('heading', { name: 'Estações' }) + const names = screen.getAllByLabelText('Nome'); fireEvent.change(names[0], { target: { value: 'Central' } }) + fireEvent.click(screen.getByRole('button', { name: 'Cadastrar estação' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Conflito: Station already exists') + }) +}) diff --git a/frontend/src/pages/AdminOperationsPage.tsx b/frontend/src/pages/AdminOperationsPage.tsx new file mode 100644 index 0000000..b35f8bf --- /dev/null +++ b/frontend/src/pages/AdminOperationsPage.tsx @@ -0,0 +1,65 @@ +import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from 'react' +import { api, ApiError, type Charger, type ChargerInput, type SimulationStatus, type Station, type StationInput, type SystemConfiguration, type SystemConfigurationInput, type Tariff, type TariffInput } from '../api/client' +import { useAuth } from '../auth/context' +import { AppShell } from '../layouts/AppShell' + +type Notice = { kind: 'success' | 'error'; text: string } | null +const stationBlank: StationInput = { name: '', description: null, grid_limit_kw: 60, station_peak_solar_kw: 0, is_active: true } +const chargerBlank: ChargerInput = { station_id: '', name: '', code: '', max_power_kw: 22, status: 'AVAILABLE', is_active: true } +const tariffBlank = (): TariffInput => ({ name: '', price_per_kwh: 0.92, currency: 'BRL', is_active: true, valid_from: new Date().toISOString().slice(0, 16), valid_until: null }) +const configurationBlank: SystemConfigurationInput = { simulation_speed: 60, grid_emission_factor_kg_per_kwh: 0.084, high_demand_threshold: 0.85, high_solar_availability_threshold: 0.8, medium_peak_threshold: 0.7, high_peak_threshold: 0.9 } + +function message(error: unknown) { + if (!(error instanceof ApiError)) return 'Não foi possível concluir a operação.' + if (error.status === 409) return `Conflito: ${error.message}` + if (error.status === 422) return `Revise os campos: ${error.message}` + return error.message +} + +function Section({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return

{title}

{description}

{children}
+} + +function Feedback({ notice }: { notice: Notice }) { + if (!notice) return null + return

{notice.text}

+} + +function StationForm({ initial = stationBlank, label, onSave }: { initial?: StationInput; label: string; onSave: (value: StationInput) => Promise }) { + const [value, setValue] = useState(initial); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null) + async function submit(event: FormEvent) { event.preventDefault(); setBusy(true); setNotice(null); try { const saved = await onSave({ ...value, description: value.description || null }); if (saved !== false) setNotice({ kind: 'success', text: 'Estação salva.' }) } catch (error) { setNotice({ kind: 'error', text: message(error) }) } finally { setBusy(false) } } + return