diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/ConsumptionDashboardController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/ConsumptionDashboardController.java new file mode 100644 index 0000000..0a63a8b --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/ConsumptionDashboardController.java @@ -0,0 +1,32 @@ +package at.mueller.eeg.backend.community.api; + +import at.mueller.eeg.backend.common.security.CurrentUserId; +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse; +import at.mueller.eeg.backend.community.service.ConsumptionDashboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +@RestController +@RequestMapping("/api/community/consumption") +@RequiredArgsConstructor +public class ConsumptionDashboardController { + + private final ConsumptionDashboardService consumptionDashboardService; + + @GetMapping("/{meteringPointId}/dashboard") + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity getDashboard( + @CurrentUserId String userId, + @PathVariable UUID meteringPointId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + return ResponseEntity.ok(consumptionDashboardService.getDashboard( + UUID.fromString(userId), meteringPointId, from, to)); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/ConsumptionDashboardDtos.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/ConsumptionDashboardDtos.java new file mode 100644 index 0000000..2818c83 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/ConsumptionDashboardDtos.java @@ -0,0 +1,35 @@ +package at.mueller.eeg.backend.community.api.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface ConsumptionDashboardDtos { + + record IntervalData( + LocalDateTime timestamp, + double consumptionKwh, + double communityFeedInKwh, + double coveredKwh, + double coveragePercent + ) {} + + record ConsumptionDashboardResponse( + UUID meteringPointId, + String atNumber, + UUID energyCommunityId, + String communityName, + LocalDateTime from, + LocalDateTime to, + String granularity, + List data, + Summary summary + ) {} + + record Summary( + double totalConsumptionKwh, + double totalCoveredKwh, + double totalCommunityFeedInKwh, + double averageCoveragePercent + ) {} +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MembershipRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MembershipRepository.java index 0768e8e..b0afec0 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MembershipRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MembershipRepository.java @@ -42,4 +42,7 @@ public interface MembershipRepository extends JpaRepository { @Query("SELECT COUNT(m) > 0 FROM Membership m JOIN m.meteringPoint mp WHERE mp.userId = :userId AND m.energyCommunity.id = :communityId AND m.status != 'INACTIVE'") boolean isActiveMemberOfCommunity(@Param("userId") UUID userId, @Param("communityId") UUID communityId); + + @Query("SELECT mp.id FROM Membership m JOIN m.meteringPoint mp WHERE m.energyCommunity.id = :communityId AND m.status = 'ACTIVE'") + List findActiveMeteringPointIdsByCommunityId(@Param("communityId") UUID communityId); } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java index de91e19..0720cd3 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java @@ -3,6 +3,8 @@ package at.mueller.eeg.backend.community.repository; import at.mueller.eeg.backend.community.domain.MeteringData; import at.mueller.eeg.backend.community.domain.MeteringDataType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; @@ -20,4 +22,40 @@ public interface MeteringDataRepository extends JpaRepository findByMeteringPointIdAndDataTypeAndIntervalStart( UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart); + + @Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " + + "FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " + + "AND m.intervalStart BETWEEN :from AND :to " + + "GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " + + "ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)") + List aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " + + "FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " + + "AND m.intervalStart BETWEEN :from AND :to " + + "GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " + + "ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)") + List aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " + + "FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " + + "AND m.intervalStart BETWEEN :from AND :to " + + "GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " + + "ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)") + List aggregateFeedInByDay(@Param("meteringPointIds") List meteringPointIds, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " + + "FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " + + "AND m.intervalStart BETWEEN :from AND :to " + + "GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " + + "ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)") + List aggregateFeedInByHour(@Param("meteringPointIds") List meteringPointIds, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardService.java new file mode 100644 index 0000000..a74000a --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardService.java @@ -0,0 +1,141 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos; +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse; +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.IntervalData; +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.Summary; +import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.domain.Membership; +import at.mueller.eeg.backend.community.repository.MembershipRepository; +import at.mueller.eeg.backend.community.repository.MeteringDataRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ConsumptionDashboardService { + + private final MeteringDataRepository meteringDataRepository; + private final MeteringPointRepository meteringPointRepository; + private final MembershipRepository membershipRepository; + + public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId, + LocalDateTime from, LocalDateTime to) { + MeteringPoint mp = verifyOwnership(userId, meteringPointId); + + Optional membership = membershipRepository.findByMeteringPointId(meteringPointId) + .stream() + .filter(m -> m.getStatus() == at.mueller.eeg.backend.community.domain.MembershipStatus.ACTIVE) + .findFirst(); + + UUID communityId = membership.map(m -> m.getEnergyCommunity().getId()).orElse(null); + String communityName = membership.map(m -> m.getEnergyCommunity().getName()).orElse(null); + + Duration range = Duration.between(from, to); + String granularity; + List rawConsumption; + List rawFeedIn; + + if (range.toHours() <= 24) { + granularity = "HOURLY"; + rawConsumption = meteringDataRepository.aggregateConsumptionByHour(meteringPointId, from, to); + if (communityId != null) { + List producerIds = membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId); + rawFeedIn = producerIds.isEmpty() ? List.of() : + meteringDataRepository.aggregateFeedInByHour(producerIds, from, to); + } else { + rawFeedIn = List.of(); + } + } else { + granularity = "DAILY"; + rawConsumption = meteringDataRepository.aggregateConsumptionByDay(meteringPointId, from, to); + if (communityId != null) { + List producerIds = membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId); + rawFeedIn = producerIds.isEmpty() ? List.of() : + meteringDataRepository.aggregateFeedInByDay(producerIds, from, to); + } else { + rawFeedIn = List.of(); + } + } + + Map consumptionMap; + Map feedInMap; + + if ("HOURLY".equals(granularity)) { + consumptionMap = parseHourlyMap(rawConsumption); + feedInMap = parseHourlyMap(rawFeedIn); + } else { + consumptionMap = parseDailyMap(rawConsumption); + feedInMap = parseDailyMap(rawFeedIn); + } + + Set allTimestamps = new TreeSet<>(consumptionMap.keySet()); + allTimestamps.addAll(feedInMap.keySet()); + + List data = allTimestamps.stream() + .map(ts -> { + double consumption = consumptionMap.getOrDefault(ts, 0.0); + double feedIn = feedInMap.getOrDefault(ts, 0.0); + double covered = Math.min(consumption, feedIn); + double coveragePct = consumption > 0 ? (covered / consumption * 100) : 0.0; + return new IntervalData(ts, consumption, feedIn, covered, coveragePct); + }) + .toList(); + + double totalConsumption = data.stream().mapToDouble(IntervalData::consumptionKwh).sum(); + double totalCovered = data.stream().mapToDouble(IntervalData::coveredKwh).sum(); + double totalFeedIn = data.stream().mapToDouble(IntervalData::communityFeedInKwh).sum(); + double avgCoverage = totalConsumption > 0 ? (totalCovered / totalConsumption * 100) : 0.0; + + Summary summary = new Summary(totalConsumption, totalCovered, totalFeedIn, avgCoverage); + + return new ConsumptionDashboardResponse( + meteringPointId, mp.getAtNumber(), communityId, communityName, + from, to, granularity, data, summary); + } + + private MeteringPoint verifyOwnership(UUID userId, UUID meteringPointId) { + return meteringPointRepository.findById(meteringPointId) + .filter(mp -> mp.getUserId().equals(userId)) + .orElseThrow(() -> new AccessDeniedException( + "Keine Berechtigung zum Zugriff auf diesen Zählpunkt")); + } + + private Map parseDailyMap(List rows) { + Map result = new TreeMap<>(); + for (Object[] row : rows) { + int dayOfYear = ((Number) row[0]).intValue(); + int year = ((Number) row[1]).intValue(); + double kwh = ((Number) row[2]).doubleValue(); + LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0).plusDays(dayOfYear - 1); + result.merge(ts, kwh, Double::sum); + } + return result; + } + + private Map parseHourlyMap(List rows) { + Map result = new TreeMap<>(); + for (Object[] row : rows) { + int hour = ((Number) row[0]).intValue(); + int dayOfYear = ((Number) row[1]).intValue(); + int year = ((Number) row[2]).intValue(); + double kwh = ((Number) row[3]).doubleValue(); + LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0) + .plusDays(dayOfYear - 1) + .plusHours(hour); + result.merge(ts, kwh, Double::sum); + } + return result; + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardServiceTest.java new file mode 100644 index 0000000..2d838d0 --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/ConsumptionDashboardServiceTest.java @@ -0,0 +1,217 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.ConsumptionDashboardDtos.ConsumptionDashboardResponse; +import at.mueller.eeg.backend.community.domain.*; +import at.mueller.eeg.backend.community.domain.state.MakoState; +import at.mueller.eeg.backend.community.repository.MembershipRepository; +import at.mueller.eeg.backend.community.repository.MeteringDataRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConsumptionDashboardServiceTest { + + @Mock + private MeteringDataRepository meteringDataRepository; + @Mock + private MeteringPointRepository meteringPointRepository; + @Mock + private MembershipRepository membershipRepository; + + @InjectMocks + private ConsumptionDashboardService service; + + private UUID userId; + private UUID meteringPointId; + private UUID communityId; + private MeteringPoint meteringPoint; + private EnergyCommunity community; + private Membership membership; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + meteringPointId = UUID.randomUUID(); + communityId = UUID.randomUUID(); + + meteringPoint = new MeteringPoint(); + meteringPoint.setId(meteringPointId); + meteringPoint.setUserId(userId); + meteringPoint.setAtNumber("AT0010000000000000000000001234567"); + meteringPoint.setMakoState(MakoState.ACTIVE); + + community = new EnergyCommunity(); + community.setId(communityId); + community.setName("Test-EEG"); + + membership = new Membership(); + membership.setMeteringPoint(meteringPoint); + membership.setEnergyCommunity(community); + membership.setStatus(MembershipStatus.ACTIVE); + } + + @SuppressWarnings("unchecked") + private List dailyRows(Object[]... rows) { + List list = new ArrayList<>(); + for (Object[] row : rows) { + list.add(row); + } + return list; + } + + @SuppressWarnings("unchecked") + private List hourlyRows(Object[]... rows) { + List list = new ArrayList<>(); + for (Object[] row : rows) { + list.add(row); + } + return list; + } + + @Test + void getDashboard_returnsDataWithCoverage() { + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership)); + when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID())); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0); + + when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) + .thenReturn(dailyRows( + new Object[]{152, 2026, 10.0}, + new Object[]{153, 2026, 5.0} + )); + when(meteringDataRepository.aggregateFeedInByDay(any(), eq(from), eq(to))) + .thenReturn(dailyRows( + new Object[]{152, 2026, 8.0}, + new Object[]{153, 2026, 12.0} + )); + + ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to); + + assertNotNull(response); + assertEquals(meteringPointId, response.meteringPointId()); + assertEquals(communityId, response.energyCommunityId()); + assertEquals("Test-EEG", response.communityName()); + assertEquals("DAILY", response.granularity()); + assertEquals(2, response.data().size()); + + assertEquals(15.0, response.summary().totalConsumptionKwh(), 0.01); + assertEquals(13.0, response.summary().totalCoveredKwh(), 0.01); + assertEquals(86.67, response.summary().averageCoveragePercent(), 0.1); + } + + @Test + void getDashboard_noMembership_showsConsumptionOnly() { + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of()); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0); + + when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) + .thenReturn(dailyRows(new Object[]{152, 2026, 10.0})); + + ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to); + + assertNull(response.energyCommunityId()); + assertNull(response.communityName()); + assertEquals(10.0, response.data().get(0).consumptionKwh()); + assertEquals(0.0, response.data().get(0).communityFeedInKwh()); + assertEquals(0.0, response.data().get(0).coveragePercent()); + } + + @Test + void getDashboard_emptyRange_returnsZeroSummary() { + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of()); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0); + + when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) + .thenReturn(dailyRows()); + + ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to); + + assertEquals(0.0, response.summary().totalConsumptionKwh()); + assertEquals(0.0, response.summary().averageCoveragePercent()); + assertTrue(response.data().isEmpty()); + } + + @Test + void getDashboard_wrongUser_throwsAccessDenied() { + UUID otherUserId = UUID.randomUUID(); + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0); + + assertThrows(AccessDeniedException.class, + () -> service.getDashboard(otherUserId, meteringPointId, from, to)); + } + + @Test + void getDashboard_hourlyGranularity_rangeUnder24h() { + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership)); + when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID())); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 1, 12, 0); + + when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to))) + .thenReturn(hourlyRows( + new Object[]{0, 152, 2026, 2.5}, + new Object[]{6, 152, 2026, 3.0} + )); + when(meteringDataRepository.aggregateFeedInByHour(any(), eq(from), eq(to))) + .thenReturn(hourlyRows( + new Object[]{0, 152, 2026, 1.0}, + new Object[]{6, 152, 2026, 5.0} + )); + + ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to); + + assertEquals("HOURLY", response.granularity()); + assertEquals(2, response.data().size()); + } + + @Test + void getDashboard_communityFeedInHigherThanConsumption_coverage100() { + when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint)); + when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership)); + when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID())); + + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0); + + when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to))) + .thenReturn(dailyRows(new Object[]{152, 2026, 5.0})); + when(meteringDataRepository.aggregateFeedInByDay(any(), eq(from), eq(to))) + .thenReturn(dailyRows(new Object[]{152, 2026, 20.0})); + + ConsumptionDashboardResponse response = service.getDashboard(userId, meteringPointId, from, to); + + assertEquals(5.0, response.data().get(0).coveredKwh()); + assertEquals(100.0, response.data().get(0).coveragePercent()); + } +} diff --git a/eeg_frontend/openapi.yaml b/eeg_frontend/openapi.yaml index 5616b3f..7ab64a9 100644 --- a/eeg_frontend/openapi.yaml +++ b/eeg_frontend/openapi.yaml @@ -835,6 +835,37 @@ paths: type: array items: $ref: "#/components/schemas/MembershipResponse" + /api/community/consumption/{meteringPointId}/dashboard: + get: + tags: + - consumption-dashboard-controller + operationId: getDashboard + parameters: + - name: meteringPointId + in: path + required: true + schema: + type: string + format: uuid + - name: from + in: query + required: true + schema: + type: string + format: date-time + - name: to + in: query + required: true + schema: + type: string + format: date-time + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ConsumptionDashboardResponse" /api/community/admin/memberships/pending: get: tags: @@ -1507,6 +1538,66 @@ components: validTo: type: string format: date + ConsumptionDashboardResponse: + type: object + properties: + meteringPointId: + type: string + format: uuid + atNumber: + type: string + energyCommunityId: + type: string + format: uuid + communityName: + type: string + from: + type: string + format: date-time + to: + type: string + format: date-time + granularity: + type: string + data: + type: array + items: + $ref: "#/components/schemas/IntervalData" + summary: + $ref: "#/components/schemas/Summary" + IntervalData: + type: object + properties: + timestamp: + type: string + format: date-time + consumptionKwh: + type: number + format: double + communityFeedInKwh: + type: number + format: double + coveredKwh: + type: number + format: double + coveragePercent: + type: number + format: double + Summary: + type: object + properties: + totalConsumptionKwh: + type: number + format: double + totalCoveredKwh: + type: number + format: double + totalCommunityFeedInKwh: + type: number + format: double + averageCoveragePercent: + type: number + format: double PendingMembershipResponse: type: object properties: diff --git a/eeg_frontend/package-lock.json b/eeg_frontend/package-lock.json index 263f5a8..b9477d3 100644 --- a/eeg_frontend/package-lock.json +++ b/eeg_frontend/package-lock.json @@ -17,6 +17,8 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@tailwindcss/postcss": "^4.3.0", + "echarts": "^6.1.0", + "ngx-echarts": "^18.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "^0.16.2" @@ -6776,6 +6778,22 @@ "wcwidth": ">=1.0.1" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9096,6 +9114,18 @@ "node": ">= 0.4.0" } }, + "node_modules/ngx-echarts": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-18.0.0.tgz", + "integrity": "sha512-1rJW7vhMTTQMZNO5AhbHfTDorhP7dcvwRsDH5jFk2SPb/gjIFWvXBY9VSNAOKumuSBnopm2+uSz6BRO5oWxovA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "echarts": ">=5.0.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -11569,6 +11599,21 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz", "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/eeg_frontend/package.json b/eeg_frontend/package.json index 9d32011..dfd62ce 100644 --- a/eeg_frontend/package.json +++ b/eeg_frontend/package.json @@ -21,6 +21,8 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@tailwindcss/postcss": "^4.3.0", + "echarts": "^6.1.0", + "ngx-echarts": "^18.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "^0.16.2" diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index c05d9ce..982bd4e 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -18,6 +18,7 @@ import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password'; import {ResetPasswordComponent} from './pages/reset-password/reset-password'; import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff'; import {UserTariffComponent} from './pages/user-tariff/user-tariff'; +import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard'; export const routes: Routes = [ { path: '', component: LandingPage }, @@ -72,6 +73,11 @@ export const routes: Routes = [ component: UserTariffComponent, canActivate: [roleGuard(['MEMBER'])] }, + { + path: 'consumption', + component: ConsumptionDashboardComponent, + canActivate: [roleGuard(['MEMBER'])] + }, { path: 'profile', component: ProfileComponent, diff --git a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts index 6a4b831..bd6638c 100644 --- a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts +++ b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts @@ -9,6 +9,7 @@ export const NAV_ITEMS: NavItem[] = [ {label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']}, {label: 'Community beitreten', path: '/dashboard/join-community', roles: ['MEMBER']}, {label: 'Meine Mitgliedschaften', path: '/dashboard/my-memberships', roles: ['MEMBER']}, + {label: 'Verbrauch', path: '/dashboard/consumption', roles: ['MEMBER']}, {label: 'Mitgliedschaften', path: '/dashboard/admin-memberships', roles: ['ADMIN']}, {label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']}, {label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']}, diff --git a/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.html b/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.html new file mode 100644 index 0000000..613c069 --- /dev/null +++ b/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.html @@ -0,0 +1,99 @@ +
+ +
+

Verbrauchs-Dashboard

+

Überblick über deinen Stromverbrauch und die Abdeckung durch die Energiegemeinschaft

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + @if (dashboardData(); as data) { + @if (!data.energyCommunityId) { +
+

Du bist noch keiner Energiegemeinschaft beigetreten. Die Verbrauchsdaten werden ohne Community-Abdeckung angezeigt.

+
+ } + } + + + @if (dashboardData(); as data) { +
+
+

Gesamtverbrauch

+

{{ data.summary.totalConsumptionKwh.toFixed(1) }} kWh

+
+
+

Abdeckung

+

{{ data.summary.averageCoveragePercent.toFixed(1) }} %

+
+
+

Community-Feed-In

+

{{ data.summary.totalCommunityFeedInKwh.toFixed(1) }} kWh

+
+
+

Abgedeckt

+

{{ data.summary.totalCoveredKwh.toFixed(1) }} kWh

+
+
+ } + + + @if (isLoading()) { +
+
+ Lade Verbrauchsdaten... +
+ } + + + @if (error(); as errorMsg) { +
+

{{ errorMsg }}

+
+ } + + + @if (!isLoading() && !error() && chartOptions()) { +
+

Verbrauch vs. Community-Verfügbarkeit

+
+
+ } +
diff --git a/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.ts b/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.ts new file mode 100644 index 0000000..62a47ee --- /dev/null +++ b/eeg_frontend/src/app/pages/consumption-dashboard/consumption-dashboard.ts @@ -0,0 +1,158 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgxEchartsDirective } from 'ngx-echarts'; +import { ConsumptionService, ConsumptionDashboardResponse } from '../../services/consumption'; +import { MeteringPoint, MeteringPointService } from '../../services/metering-point'; + +@Component({ + selector: 'app-consumption-dashboard', + standalone: true, + imports: [FormsModule, NgxEchartsDirective], + templateUrl: './consumption-dashboard.html' +}) +export class ConsumptionDashboardComponent implements OnInit { + private consumptionService = inject(ConsumptionService); + private meteringPointService = inject(MeteringPointService); + + meteringPoints = signal([]); + selectedMeteringPointId = signal(''); + dashboardData = signal(null); + isLoading = signal(false); + error = signal(null); + + dateFrom = signal(this.getDefaultFromDate()); + dateTo = signal(this.getDefaultToDate()); + + chartOptions = signal({}); + + ngOnInit() { + this.meteringPointService.getMyMeteringPoints().subscribe({ + next: (points) => { + this.meteringPoints.set(points); + if (points.length === 1) { + this.selectedMeteringPointId.set(points[0].id); + this.loadDashboard(); + } + }, + error: () => this.error.set('Fehler beim Laden der Zählpunkte.') + }); + } + + onMeteringPointChange(mpId: string) { + this.selectedMeteringPointId.set(mpId); + this.loadDashboard(); + } + + onDateRangeChange() { + this.loadDashboard(); + } + + setQuickRange(days: number) { + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - days); + this.dateFrom.set(this.formatDate(from)); + this.dateTo.set(this.formatDate(to)); + this.loadDashboard(); + } + + private loadDashboard() { + const mpId = this.selectedMeteringPointId(); + if (!mpId) return; + + this.isLoading.set(true); + this.error.set(null); + + const from = this.dateFrom() + 'T00:00:00'; + const to = this.dateTo() + 'T23:59:59'; + + this.consumptionService.getDashboard(mpId, from, to).subscribe({ + next: (data) => { + this.dashboardData.set(data); + this.buildChartOptions(data); + this.isLoading.set(false); + }, + error: () => { + this.isLoading.set(false); + this.error.set('Fehler beim Laden der Verbrauchsdaten.'); + } + }); + } + + private buildChartOptions(data: ConsumptionDashboardResponse) { + const labels = data.data.map(d => { + const date = new Date(d.timestamp); + return data.granularity === 'DAILY' + ? date.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit' }) + : date.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }); + }); + + this.chartOptions.set({ + tooltip: { + trigger: 'axis', + formatter: (params: any[]) => { + const label = params[0]?.axisValue || ''; + let html = `${label}
`; + params.forEach((p: any) => { + html += `${p.marker} ${p.seriesName}: ${p.value?.toFixed(2)} kWh
`; + }); + const idx = params[0]?.dataIndex; + if (idx !== undefined && data.data[idx]) { + html += `
Abdeckung: ${data.data[idx].coveragePercent.toFixed(1)}%`; + } + return html; + } + }, + legend: { + data: ['Verbrauch', 'Community-Verfügbarkeit', 'Abgedeckt'], + top: 0 + }, + grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, + xAxis: { + type: 'category', + data: labels, + axisLabel: { rotate: labels.length > 15 ? 45 : 0 } + }, + yAxis: { type: 'value', name: 'kWh' }, + series: [ + { + name: 'Verbrauch', + type: 'bar', + data: data.data.map(d => d.consumptionKwh), + itemStyle: { color: '#3b82f6' }, + barMaxWidth: 40 + }, + { + name: 'Abgedeckt', + type: 'bar', + data: data.data.map(d => d.coveredKwh), + itemStyle: { color: '#22c55e' }, + barMaxWidth: 40 + }, + { + name: 'Community-Verfügbarkeit', + type: 'line', + data: data.data.map(d => d.communityFeedInKwh), + itemStyle: { color: '#f97316' }, + lineStyle: { width: 2 }, + symbol: 'circle', + symbolSize: 4 + } + ] + }); + } + + private getDefaultFromDate(): string { + const d = new Date(); + d.setDate(d.getDate() - 30); + return this.formatDate(d); + } + + private getDefaultToDate(): string { + return this.formatDate(new Date()); + } + + private formatDate(d: Date): string { + return d.toISOString().split('T')[0]; + } +} diff --git a/eeg_frontend/src/app/services/consumption.ts b/eeg_frontend/src/app/services/consumption.ts new file mode 100644 index 0000000..16f729c --- /dev/null +++ b/eeg_frontend/src/app/services/consumption.ts @@ -0,0 +1,52 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface IntervalData { + timestamp: string; + consumptionKwh: number; + communityFeedInKwh: number; + coveredKwh: number; + coveragePercent: number; +} + +export interface Summary { + totalConsumptionKwh: number; + totalCoveredKwh: number; + totalCommunityFeedInKwh: number; + averageCoveragePercent: number; +} + +export interface ConsumptionDashboardResponse { + meteringPointId: string; + atNumber: string; + energyCommunityId: string | null; + communityName: string | null; + from: string; + to: string; + granularity: 'DAILY' | 'HOURLY'; + data: IntervalData[]; + summary: Summary; +} + +@Injectable({ providedIn: 'root' }) +export class ConsumptionService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/api/community/consumption`; + + getDashboard( + meteringPointId: string, + from: string, + to: string + ): Observable { + const params = new HttpParams() + .set('from', from) + .set('to', to); + + return this.http.get( + `${this.apiUrl}/${meteringPointId}/dashboard`, + { params } + ); + } +}