diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..bc13d9c 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, SiteServiceDep +from app.api.deps import LecteurDep, ReadingServiceDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse +from app.schemas.reading import ReadingResponse from app.schemas.site import SiteResponse from app.services.site import SiteNotFoundError @@ -34,3 +35,22 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=ReadingResponse | None, + summary="Dernière mesure connue d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, sites: SiteServiceDep, readings: ReadingServiceDep +) -> ReadingResponse | None: + try: + await sites.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + derniere = await readings.get_latest(site_id) + return ReadingResponse.model_validate(derniere) if derniere is not None else None diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 71352da..d2d06a1 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -21,6 +21,15 @@ class ReadingRepository: ) return (await self._session.execute(requete)).scalars().all() + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(1) + ) + return (await self._session.scalars(requete)).first() + async def list_history( self, *, diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py index 818c202..7d746c2 100644 --- a/apps/backend/app/services/reading.py +++ b/apps/backend/app/services/reading.py @@ -20,6 +20,9 @@ class ReadingService: def __init__(self, *, readings: ReadingRepository) -> None: self._readings = readings + async def get_latest(self, site_id: str) -> Reading | None: + return await self._readings.latest_for_site(site_id) + async def list_history( self, *, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 25fc9ee..ab4ff74 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1115,6 +1115,101 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure connue d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReadingResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Current Api V1 Sites Site Id Current Get" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..5980b0b 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,14 +1,15 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest from fastapi import FastAPI from httpx import AsyncClient -from app.api.deps import get_current_principal, get_site_service +from app.api.deps import get_current_principal, get_reading_service, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.models.energy import Site +from app.models.energy import Reading, Site from app.services.site import SiteNotFoundError @@ -47,6 +48,26 @@ class FauxService: return self.site +def reading(site_id: str = "site-1") -> Reading: + return Reading( + reading_id=1, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="csv", + consumption_kw=42.5, + data_quality="good", + raw_data={}, + ) + + +class FauxReadingService: + def __init__(self, derniere: Reading | None) -> None: + self._derniere = derniere + + async def get_latest(self, site_id: str) -> Reading | None: + return self._derniere + + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: app.dependency_overrides[get_current_principal] = lambda: principal() @@ -67,6 +88,19 @@ def servi( app.dependency_overrides.pop(get_site_service, None) +@pytest.fixture +def readings_servis( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Reading | None], FauxReadingService]]: + def installe(derniere: Reading | None) -> FauxReadingService: + service = FauxReadingService(derniere) + app.dependency_overrides[get_reading_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_reading_service, None) + + async def test_list_sites_returns_the_sites( servi: Callable[..., FauxService], client: AsyncClient ) -> None: @@ -139,3 +173,45 @@ async def test_get_site_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/sites/inconnu") assert response.status_code == 404 + + +async def test_get_current_returns_the_latest_reading_regardless_of_its_age( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(reading()) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["consumption_kw"] == 42.5 + + +async def test_get_current_returns_null_when_the_site_has_no_reading( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(None) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + assert response.json() is None + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi(SiteNotFoundError("site-inconnu")) + readings_servis(None) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 150fa29..70af2d2 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,31 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_for_site_ignores_how_old_the_reading_is(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2024, 1, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2024, 12, 31, tzinfo=UTC) + ) + + resultat = await depot.latest_for_site(site.site_id) + await session.rollback() + + assert resultat is not None + assert resultat.reading_id == recente.reading_id + + +async def test_latest_for_site_returns_none_when_the_site_has_no_reading( + session: AsyncSession, +) -> None: + depot = ReadingRepository(session) + + resultat = await depot.latest_for_site(identifiant_site()) + + assert resultat is None + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index a3f0826..1295aa9 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -28,6 +28,11 @@ class FakeRepository: def __init__(self, readings: list[Reading]) -> None: self._readings = readings self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] + self.site_interroge: str | None = None + + async def latest_for_site(self, site_id: str) -> Reading | None: + self.site_interroge = site_id + return self._readings[0] if self._readings else None async def list_history( self, @@ -42,6 +47,25 @@ class FakeRepository: return self._readings +async def test_get_latest_relays_the_repository_reading() -> None: + depot = FakeRepository([reading(1)]) + service = ReadingService(readings=depot) + + lecture = await service.get_latest("site-1") + + assert lecture is not None + assert lecture.reading_id == 1 + assert depot.site_interroge == "site-1" + + +async def test_get_latest_returns_none_when_the_site_has_no_reading() -> None: + service = ReadingService(readings=FakeRepository([])) + + lecture = await service.get_latest("site-1") + + assert lecture is None + + async def test_list_history_returns_the_repository_readings() -> None: service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index e619268..1cb0752 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,8 +21,6 @@ export const routes: Routes = [ path: 'sites/:siteId', canActivate: [authGuard], loadComponent: () => - import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( - (m) => m.SiteDetailPlaceholder, - ), + import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, ]; diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts new file mode 100644 index 0000000..98a3177 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { ReadingsService } from './readings.service'; +import { environment } from '../../../environments/environment'; + +describe('ReadingsService', () => { + let service: ReadingsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ReadingsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("demande la dernière lecture connue du site, quel que soit son âge", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); + + expect((result as { reading_id: number }).reading_id).toBe(1); + }); + + it("retourne null quand le site n'a aucune lecture", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + req.flush(null); + + expect(result).toBeNull(); + }); + + it("demande l'historique du site avec la fenêtre temporelle donnée", () => { + let result: unknown; + service.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z').subscribe( + (r) => (result = r), + ); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z'); + expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z'); + + req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]); + + expect((result as unknown[]).length).toBe(1); + }); + + it("ne pose pas de paramètres start/end quand ils sont omis", () => { + service.getHistory('SITE001').subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.has('start')).toBe(false); + expect(req.request.params.has('end')).toBe(false); + + req.flush([]); + }); +}); diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts new file mode 100644 index 0000000..d692237 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -0,0 +1,24 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Reading } from '../../shared/models/reading.model'; + +@Service() +export class ReadingsService { + private http = inject(HttpClient); + + getLatest(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } + + getHistory(siteId: string, start?: string, end?: string) { + let params = new HttpParams().set('site_id', siteId); + if (start) { + params = params.set('start', start); + } + if (end) { + params = params.set('end', end); + } + return this.http.get(`${environment.apiUrl}/readings`, { params }); + } +} diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 45aee2c..8eabeb2 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -38,4 +38,23 @@ describe('SitesService', () => { expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne un site', () => { + let result: unknown; + service.getSite('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`); + expect(req.request.method).toBe('GET'); + + req.flush({ + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }); + + expect((result as { site_id: string }).site_id).toBe('SITE001'); + }); }); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 85754cc..449bd29 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -10,4 +10,8 @@ export class SitesService { getSites() { return this.http.get(`${environment.apiUrl}/sites`); } + + getSite(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}`); + } } diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html deleted file mode 100644 index 9533177..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - -
- - -

Site {{ siteId() }}

-
- - -

Le détail de ce site est à venir (voir issue #51).

- Retour aux sites -
-
diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss deleted file mode 100644 index ca04d4a..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss +++ /dev/null @@ -1,28 +0,0 @@ -:host { - display: block; - color: var(--color-text); - padding: 2.5rem 2rem; - max-width: 640px; - margin: 0 auto; -} - -.site-detail-placeholder__header { - display: flex; - align-items: center; - gap: 0.85rem; - margin-bottom: 1.5rem; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - } -} - -.site-detail-placeholder__logo { - font-size: 1.3rem; -} - -ev-card p { - margin: 0 0 0.75rem; -} diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts deleted file mode 100644 index f229e36..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { SiteDetailPlaceholder } from './site-detail-placeholder'; - -describe('SiteDetailPlaceholder', () => { - it("affiche l'identifiant du site depuis la route", () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE001'); - }); - - it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - paramMap.next(convertToParamMap({ siteId: 'SITE002' })); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE002'); - expect(fixture.nativeElement.textContent).not.toContain('SITE001'); - }); -}); diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts deleted file mode 100644 index 39e53dc..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { map } from 'rxjs'; -import { Card } from '../../../shared/components/ui/card/card'; -import { Brand } from '../../../shared/components/ui/brand/brand'; - -@Component({ - selector: 'app-site-detail-placeholder', - standalone: true, - imports: [RouterLink, Card, Brand], - templateUrl: './site-detail-placeholder.html', - styleUrl: './site-detail-placeholder.scss', -}) -export class SiteDetailPlaceholder { - private route = inject(ActivatedRoute); - - siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId')))); -} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html new file mode 100644 index 0000000..f2fbae9 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -0,0 +1,71 @@ +
+ + +
+ + +
+

{{ site()?.site_name ?? siteId() }}

+ @if (site(); as s) { +

+ {{ s.site_type }} · {{ s.location || 'Localisation inconnue' }} +

+ } +
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } +
+ + @if (error(); as message) { + + } + + @if (site(); as s) { +
+ + Consommation vs capacité + + + {{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW + + + + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } + + Retour aux sites +
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss new file mode 100644 index 0000000..fbb17c6 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -0,0 +1,102 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.site-detail__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.site-detail__logo { + font-size: 1.3rem; +} + +.site-detail__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + padding: 1.25rem; + gap: 0.35rem; +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem 1.5rem; + margin: 0.5rem 0 0; +} + +.metric { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.metric__unavailable { + color: var(--color-text-muted); + font-weight: 400; +} + +.metric__reason { + font-size: 0.8rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.chart-section { + margin-bottom: 2rem; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts new file mode 100644 index 0000000..11e6c8f --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -0,0 +1,170 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { SiteDetail } from './site-detail'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; + +const SITE = { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', +}; + +const READING_COMPLETE = { + reading_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_current' as const, + consumption_kw: 120, + consumption_kwh: null, + consumption_euros: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + solar_irradiance_wm2: null, + is_working_hours: true, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +function setup( + siteId: string, + sitesMock: Partial, + readingsMock: Partial, +) { + const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + TestBed.configureTestingModule({ + imports: [SiteDetail], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { paramMap } }, + { provide: SitesService, useValue: sitesMock }, + { provide: ReadingsService, useValue: readingsMock }, + ], + }); + return { fixture: TestBed.createComponent(SiteDetail), paramMap }; +} + +describe('SiteDetail', () => { + it('charge le site, la dernière lecture et son historique au démarrage', () => { + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + expect(fixture.componentInstance.latestReading()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.history().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("signale l'indisponibilité quand un des appels échoue", () => { + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + }); + + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { + const readingPartielle = { + ...READING_COMPLETE, + voltage_v: null, + current_a: null, + power_factor: null, + null_reasons: ['electrical_sensor_failure'], + }; + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(readingPartielle)), + getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + }, + ); + + fixture.detectChanges(); + + const tension = fixture.componentInstance + .metrics() + .find((m) => m.key === 'voltage_v'); + expect(tension?.value).toBeNull(); + expect(tension?.reason).toBe('capteur électrique en panne'); + + const html = fixture.nativeElement.textContent; + expect(html).toContain('Indisponible'); + expect(html).toContain('capteur électrique en panne'); + }); + + it('recharge les données quand le paramètre de route siteId change', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([])), + }, + ); + + fixture.detectChanges(); + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE002'); + }); + + it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith( + 'SITE001', + '2026-09-16T10:00:00.000Z', + '2026-09-17T10:00:00Z', + ); + }); + + it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); + expect(fixture.componentInstance.latestReading()).toBeNull(); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts new file mode 100644 index 0000000..fa267d0 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -0,0 +1,161 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, EMPTY, map, Observable, switchMap } from 'rxjs'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; +import { Site } from '../../../shared/models/site.model'; +import { Reading } from '../../../shared/models/reading.model'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; +import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; + +const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; +const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +type MetricKey = + | 'consumption_kw' + | 'voltage_v' + | 'current_a' + | 'power_factor' + | 'temperature_celsius' + | 'humidity_percent'; + +interface MetricDef { + key: MetricKey; + label: string; + format: (value: number) => string; +} + +const METRIC_DEFS: MetricDef[] = [ + { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, + { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, + { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, + { key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` }, + { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, +]; + +// Contrainte : miroir de `RAISON_VERS_CAPTEUR`/`CHAMPS_PAR_CAPTEUR` côté backend +// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur, +// jamais le nom du champ. +const RAISONS_PAR_CHAMP: Record = { + consumption_kw: ['consumption_sensor_failure', 'network_loss'], + voltage_v: ['electrical_sensor_failure', 'network_loss'], + current_a: ['electrical_sensor_failure', 'network_loss'], + power_factor: ['electrical_sensor_failure', 'network_loss'], + temperature_celsius: ['temperature_sensor_failure', 'network_loss'], + humidity_percent: ['humidity_sensor_failure', 'network_loss'], +}; + +const LIBELLE_PAR_RAISON: Record = { + consumption_sensor_failure: 'capteur de consommation en panne', + electrical_sensor_failure: 'capteur électrique en panne', + temperature_sensor_failure: 'capteur de température en panne', + humidity_sensor_failure: 'capteur d\'humidité en panne', + network_loss: 'perte réseau', +}; + +export interface MetricView { + key: MetricKey; + label: string; + value: string | null; + reason: string; +} + +@Component({ + selector: 'app-site-detail', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + templateUrl: './site-detail.html', + styleUrl: './site-detail.scss', +}) +export class SiteDetail { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private readingsService = inject(ReadingsService); + + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); + + site = signal(null); + latestReading = signal(null); + history = signal([]); + error = signal(null); + + metrics = computed(() => { + const reading = this.latestReading(); + return METRIC_DEFS.map((def) => { + const valeur = reading ? reading[def.key] : null; + return { + key: def.key, + label: def.label, + value: valeur != null ? def.format(valeur) : null, + reason: valeur == null ? this.reasonFor(def.key, reading) : '', + }; + }); + }); + + constructor() { + effect(() => { + const siteId = this.siteId(); + if (siteId) { + this.load(siteId); + } + }); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string): void { + this.sitesService + .getSite(siteId) + .pipe( + switchMap((site) => + this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + ), + switchMap(({ site, latest }) => { + // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la + // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique + // vide dès que le jeu de données n'est plus récent. + const end = latest?.timestamp; + const start = end + ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() + : undefined; + return this.readingsService + .getHistory(siteId, start, end) + .pipe(map((history) => ({ site, latest, history }))); + }), + catchError(() => this.reportUnavailable()), + ) + .subscribe((result) => { + this.error.set(null); + this.site.set(result.site); + this.latestReading.set(result.latest); + this.history.set(result.history); + }); + } + + private reasonFor(field: MetricKey, reading: Reading | null): string { + const raisons = RAISONS_PAR_CHAMP[field]; + const trouvees = (reading?.null_reasons ?? []) + .filter((raison) => raisons.includes(raison)) + .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); + return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts new file mode 100644 index 0000000..bb6232f --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { Chart } from 'chart.js'; +import { ReadingHistoryChart } from './reading-history-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + static instances: ChartMock[] = []; + static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } + } + return { Chart: ChartMock, registerables: [] }; +}); + +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + +const READING = { + reading_id: 1, + site_id: 'S1', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_history' as const, + consumption_kw: 42, + consumption_kwh: null, + consumption_euros: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + solar_irradiance_wm2: null, + is_working_hours: null, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +describe('ReadingHistoryChart', () => { + it('se crée sans erreur avec une liste de lectures valide', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('met à jour le graphique quand les lectures changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const }, + ]); + fixture.detectChanges(); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts new file mode 100644 index 0000000..922ca01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -0,0 +1,80 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { Reading, ReadingDataQuality } from '../../models/reading.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#3b82f6', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; +const UNKNOWN_QUALITY_COLOR = '#9ca3af'; + +function pointColors(readings: Reading[]): string[] { + return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +} + +@Component({ + selector: 'app-reading-history-chart', + standalone: true, + templateUrl: './reading-history-chart.html', + styleUrl: './reading-history-chart.scss', +}) +export class ReadingHistoryChart implements AfterViewInit, OnDestroy { + readings = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart<'line'>; + + constructor() { + effect(() => { + const readings = this.readings(); + if (this.chart) { + this.chart.data.labels = readings.map((r) => r.timestamp); + this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); + this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const readings = this.readings(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + labels: readings.map((r) => r.timestamp), + datasets: [ + { + data: readings.map((r) => r.consumption_kw ?? 0), + borderColor: '#3b82f6', + pointBackgroundColor: pointColors(readings), + tension: 0.25, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/apps/frontend/src/app/shared/models/reading.model.ts b/apps/frontend/src/app/shared/models/reading.model.ts new file mode 100644 index 0000000..daba555 --- /dev/null +++ b/apps/frontend/src/app/shared/models/reading.model.ts @@ -0,0 +1,23 @@ +export type ReadingSource = 'csv' | 'api_current' | 'api_history'; +export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical'; + +export interface Reading { + reading_id: number; + site_id: string; + timestamp: string; + source: ReadingSource; + consumption_kw: number | null; + consumption_kwh: number | null; + consumption_euros: string | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + solar_irradiance_wm2: number | null; + is_working_hours: boolean | null; + data_quality: ReadingDataQuality | null; + null_reasons: string[] | null; + imputed_values: Record | null; + imputation_method: string | null; +}